Compare commits
11 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 803f86d5b3 | |||
| 043f6b2426 | |||
| f7695308b7 | |||
| a8936e79d6 | |||
| f6b29e6caa | |||
| 3069b2e2a9 | |||
| 6625391f76 | |||
| c40dcd6159 | |||
| 35f5c9513c | |||
| 13b58dc51e | |||
| 6306975352 |
@@ -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/pokemon-gen1-recomp-project/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/pokemon-gen1-recomp-project/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
|
||||||
|
|||||||
@@ -10,6 +10,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:
|
||||||
@@ -214,8 +217,21 @@ 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"
|
||||||
|
|
||||||
|
# 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 +282,9 @@ 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}.love" \
|
||||||
|
"dist/release/sha256sums.txt"
|
||||||
|
|
||||||
echo "Published release $tag"
|
echo "Published release $tag"
|
||||||
|
|
||||||
|
|||||||
@@ -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 |
@@ -47,8 +47,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 = {
|
||||||
|
|||||||
@@ -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);
|
||||||
@@ -59,52 +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", 16 }, -- 3
|
{ "jump_if_true", "come_see" },
|
||||||
{ "check_flag", "EVENT_OAK_GOT_PARCEL" }, -- 4
|
{ "check_flag", "EVENT_BEAT_ROUTE22_RIVAL_1ST_BATTLE" },
|
||||||
{ "jump_if_true", 16 }, -- 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" },
|
||||||
-- OaksLab.asm OakGivesPokedex: HideObject TOGGLE_POKEDEX_1/2
|
{ "jump_if_false", "raise_young" },
|
||||||
-- so the table sprites leave with the gift (#106).
|
-- OaksLabOak1Text.got_parcel → RivalArrives + OakGivesPokedex
|
||||||
{ "hide_object", "OAKS_LAB", "OAKSLAB_POKEDEX1" }, -- 11
|
{ "show_text", "_OaksLabOak1DeliverParcelText" },
|
||||||
{ "hide_object", "OAKS_LAB", "OAKSLAB_POKEDEX2" }, -- 12
|
{ "play_sound", "Get_Key_Item" },
|
||||||
-- the Pokédex swaps Viridian's two old men (OaksLab.asm:602-606:
|
{ "show_text", "_OaksLabOak1ParcelThanksText" },
|
||||||
-- HideObject TOGGLE_LYING_OLD_MAN / ShowObject TOGGLE_OLD_MAN).
|
{ "take_item", "OAKS_PARCEL", 1 },
|
||||||
-- Until this ran, the walking man at (17,5) -- who owns the coffee
|
{ "stop_music" },
|
||||||
-- ask and the catch tutorial -- stayed OFF for the whole game
|
{ "play_music", "Music_MeetRival" },
|
||||||
-- (toggleable_objects.asm seeds him OFF, the sleeper ON).
|
{ "show_text", "_OaksLabRivalGrampsText" },
|
||||||
{ "hide_object", "VIRIDIAN_CITY", "VIRIDIANCITY_OLD_MAN_SLEEPY" }, -- 13
|
{ "show_object", "OAKS_LAB", "OAKSLAB_RIVAL" },
|
||||||
{ "show_object", "VIRIDIAN_CITY", "VIRIDIANCITY_OLD_MAN" }, -- 14
|
-- OaksLabCalcRivalMovementScript: sprite map (8,11) → cell (4,7)
|
||||||
{ "jump", "end" }, -- 15
|
-- when the player stands below Oak (the usual desk talk).
|
||||||
{ "check_flag", "EVENT_GOT_STARTER" }, -- 16
|
{ "place_npc", 1, 4, 7, "up" },
|
||||||
{ "jump_if_false", 31 }, -- 17
|
{ "move_npc_to", 1, 4, 3 },
|
||||||
{ "check_item", "POKE_BALL" }, -- 18
|
{ "play_music", "Music_OaksLab" },
|
||||||
{ "jump_if_true", 29 }, -- 19
|
{ "face_object", 1, "up" },
|
||||||
{ "check_flag", "EVENT_BEAT_ROUTE22_RIVAL_1ST_BATTLE" }, -- 20
|
{ "face_object", 5, "down" },
|
||||||
{ "jump_if_false", 33 }, -- 21
|
{ "show_text", "_OaksLabRivalWhatDidYouCallMeForText" },
|
||||||
{ "check_flag", "EVENT_GOT_POKEBALLS_FROM_OAK" }, -- 22
|
{ "face_object", 1, "up" },
|
||||||
{ "jump_if_true", 29 }, -- 23
|
{ "face_object", 5, "down" },
|
||||||
{ "set_flag", "EVENT_GOT_POKEBALLS_FROM_OAK" }, -- 24
|
{ "show_text", "_OaksLabOakIHaveARequestText" },
|
||||||
{ "give_item", "POKE_BALL", 5, false }, -- 25
|
{ "face_object", 1, "up" },
|
||||||
{ "show_text", "_OaksLabOak1ReceivedPokeballsText" }, -- 26
|
{ "face_object", 5, "down" },
|
||||||
{ "show_text", "_OaksLabGivePokeballsExplanationText" }, -- 27
|
{ "show_text", "_OaksLabOakMyInventionPokedexText" },
|
||||||
{ "jump", "end" }, -- 28
|
{ "show_text", "_OaksLabOakGotPokedexText" },
|
||||||
{ "show_text", "_OaksLabOak1ComeSeeMeSometimesText" }, -- 29
|
{ "play_sound", "Get_Key_Item" },
|
||||||
{ "jump", "end" }, -- 30
|
{ "hide_object", "OAKS_LAB", "OAKSLAB_POKEDEX1" },
|
||||||
{ "show_text", "_OaksLabOak1WhichPokemonDoYouWantText" }, -- 31
|
{ "hide_object", "OAKS_LAB", "OAKSLAB_POKEDEX2" },
|
||||||
{ "jump", "end" }, -- 32
|
{ "face_object", 1, "up" },
|
||||||
{ "show_text", "_OaksLabOak1RaiseYourYoungPokemonText" }, -- 33
|
{ "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 =
|
||||||
@@ -117,36 +163,45 @@ 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
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -168,9 +223,16 @@ return {
|
|||||||
-- 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 },
|
||||||
@@ -209,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 })
|
||||||
|
|||||||
@@ -395,7 +395,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
|
||||||
@@ -878,16 +964,23 @@ 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
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -822,6 +860,10 @@ M.DAYCARE = {
|
|||||||
if def then
|
if def then
|
||||||
mon.stats = Stats.calc(def, mon.level, mon.dvs, mon.statExp)
|
mon.stats = Stats.calc(def, mon.level, mon.dvs, mon.statExp)
|
||||||
mon.hp = mon.stats.hp
|
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
|
end
|
||||||
table.insert(game.save.party, mon)
|
table.insert(game.save.party, mon)
|
||||||
game.save.daycare = nil
|
game.save.daycare = nil
|
||||||
|
|||||||
@@ -524,8 +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()
|
||||||
hideRocket()
|
-- #198: GameCornerRocketExitScript (scripts/GameCorner.asm)
|
||||||
done()
|
-- ApplyMovementData walks the grunt one tile UP into the poster
|
||||||
|
-- (the hideout's secret entrance at 9,4) before HideObject, so
|
||||||
|
-- he leaves the floor rather than popping out of existence on
|
||||||
|
-- (9,5). scriptMove locks player input (#scriptMoves>0) and
|
||||||
|
-- ignores collision, so we despawn + unfreeze (done) only once
|
||||||
|
-- the step lands.
|
||||||
|
ow:scriptMove(npc, "up", 1, function()
|
||||||
|
hideRocket()
|
||||||
|
done()
|
||||||
|
end)
|
||||||
end))
|
end))
|
||||||
end)
|
end)
|
||||||
end,
|
end,
|
||||||
@@ -606,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 = {
|
||||||
|
|||||||
@@ -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,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -457,6 +467,19 @@ 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).
|
||||||
|
local function route22ExitDirs(n, py)
|
||||||
|
if n == 2 then
|
||||||
|
if py == 5 then return { "left", "left", "left", "left" } end
|
||||||
|
return { "left", "left", "left" }
|
||||||
|
end
|
||||||
|
if py == 5 then
|
||||||
|
return { "right", "right", "down", "down", "down", "down", "down" }
|
||||||
|
end
|
||||||
|
return { "up", "right", "right", "right",
|
||||||
|
"down", "down", "down", "down", "down", "down" }
|
||||||
|
end
|
||||||
|
|
||||||
local function route22Scene(n, objIndex, objName, oppClass, baseParty, beatFlag, py)
|
local function route22Scene(n, objIndex, objName, oppClass, baseParty, beatFlag, py)
|
||||||
return {
|
return {
|
||||||
{ "show_object", "ROUTE_22", objName }, -- 1
|
{ "show_object", "ROUTE_22", objName }, -- 1
|
||||||
@@ -468,7 +491,7 @@ local function route22Scene(n, objIndex, objName, oppClass, baseParty, beatFlag,
|
|||||||
{ "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
|
||||||
@@ -495,6 +518,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
|
||||||
@@ -502,41 +533,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 = {
|
||||||
|
|||||||
@@ -12,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 = {}
|
||||||
@@ -31,28 +37,76 @@ return {
|
|||||||
hide = {
|
hide = {
|
||||||
{ "PEWTER_CITY", "PEWTERCITY_YOUNGSTER" },
|
{ "PEWTER_CITY", "PEWTERCITY_YOUNGSTER" },
|
||||||
{ "ROUTE_22", "ROUTE22_RIVAL1" },
|
{ "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" },
|
||||||
@@ -60,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,195 @@
|
|||||||
|
# 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" } }`.
|
||||||
|
- **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.
|
||||||
@@ -18,6 +18,111 @@ Regenerate the reference straight into a wiki checkout:
|
|||||||
luajit tools/gen_registry_docs.lua ../pokemon-gen1-recomp-project.wiki
|
luajit tools/gen_registry_docs.lua ../pokemon-gen1-recomp-project.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
|
||||||
|
|||||||
@@ -180,3 +180,17 @@ 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,
|
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
|
letterbox voids around the battle canvas fill **white** instead of black
|
||||||
so the whole window reads as one continuous battle screen.
|
so the whole window reads as one continuous battle screen.
|
||||||
|
|
||||||
|
## On-screen touch controls (mobile)
|
||||||
|
|
||||||
|
On Android/iOS the game draws a translucent d-pad (bottom-left), A/B
|
||||||
|
buttons (bottom-right, Game Boy diagonal), and +/- START/SELECT (bottom
|
||||||
|
center) over the frame, using Xelu's CC0 controller prompts
|
||||||
|
(`assets/touch/`). Real buttons, not gestures: press lands the frame the
|
||||||
|
finger does, sliding on the d-pad changes direction without lifting, and
|
||||||
|
multi-touch chords (e.g. hold a direction + tap B) work. The overlay only
|
||||||
|
appears while no controller is being used: the first gamepad button or
|
||||||
|
stick push hides it, the next screen touch brings it back, and unplugging
|
||||||
|
the last controller restores it immediately. Layout re-derives from the
|
||||||
|
window size on rotation. Desktop testing: `POKEPORT_TOUCH=1 love .` forces
|
||||||
|
the overlay on and lets the mouse act as a finger (`=0` forces it off).
|
||||||
@@ -0,0 +1,126 @@
|
|||||||
|
# Updater
|
||||||
|
|
||||||
|
A fused build (`love.filesystem.isFused()` true) ships a bundled `game.love`
|
||||||
|
baked into the executable, but that bundled copy is only ever the *fallback*.
|
||||||
|
On every launch, before anything else runs, `Boot.run` (`src/update/Boot.lua`)
|
||||||
|
looks in the save directory's `updates/` folder for a downloaded
|
||||||
|
`gen1recomp-X.Y.Z.love` payload that is both strictly newer than the bundled
|
||||||
|
engine version and runnable on this shell. If one qualifies, it is mounted
|
||||||
|
over `/` (so its files win over the fused source for every subsequent
|
||||||
|
`require`) and chainloaded in place: the payload's `main.lua` and `love.load`
|
||||||
|
run as if they had shipped in the executable. A dev/source checkout is never
|
||||||
|
fused, so `Boot.run` no-ops there and the working tree always runs itself.
|
||||||
|
|
||||||
|
The pieces are deliberately layered so the risky part is small. `Boot.select`
|
||||||
|
is a pure function (no `love.*` calls) that, given probed candidates and the
|
||||||
|
bundled `engine`/`shell`, decides what to run and what stale payloads to
|
||||||
|
delete. `Boot.probePayload` mounts one archive at an isolated mountpoint and
|
||||||
|
reads its `src/core/Version.lua` with `loadstring` (never `require`, so it is
|
||||||
|
never cached as a module) to learn its `engine` and `minShell`. `Boot.run`
|
||||||
|
orchestrates the crash guard, enumeration, selection, and the mount +
|
||||||
|
chainload, with full rollback on any failure. Checking for and fetching a
|
||||||
|
new payload is a separate, slower path: `src/update/Check.lua` is a thin
|
||||||
|
main-thread state machine the launcher screen polls, while the curl calls,
|
||||||
|
JSON parsing, and sha256 verification run on a background `love.thread`
|
||||||
|
(`src/update/check_worker.lua`) so a hung network call never blocks a frame.
|
||||||
|
|
||||||
|
## Version.lua fields
|
||||||
|
|
||||||
|
`src/core/Version.lua` carries three fields the updater reads directly (the
|
||||||
|
existing `modApi`, `linkProtocol`, `saveFormat`, and `cache` fields are
|
||||||
|
untouched):
|
||||||
|
|
||||||
|
- `engine` - the semver release, e.g. `"1.4.0"`. The repo default is the
|
||||||
|
`"0.0.0-dev"` placeholder; CI stamps the real `X.Y.Z` into the packed
|
||||||
|
`game.love` only, never the working tree. A `"0.0.0-dev"` engine always
|
||||||
|
reports itself up to date (it never chases a release, and it never counts
|
||||||
|
as a valid payload to chainload).
|
||||||
|
- `shell` - the native-shell contract this build's fused executable
|
||||||
|
implements.
|
||||||
|
- `minShell` - the lowest shell contract required to *run* this payload.
|
||||||
|
|
||||||
|
Bump `minShell` only when a payload needs something the currently-shipped
|
||||||
|
native shell cannot provide, for example a LOVE version bump, a new required
|
||||||
|
system binary, or a change to `love.run` itself (see Known limitations
|
||||||
|
below). An older shell refuses to chainload a payload whose `minShell`
|
||||||
|
exceeds the shell it provides; `Boot.select` keeps that payload in `updates/`
|
||||||
|
rather than deleting it, in case a future shell upgrade can run it, and
|
||||||
|
`Check`'s worker reports `needs_full` so the player is pointed at a full
|
||||||
|
installer instead. Do not bump `minShell` for an ordinary Lua/data release;
|
||||||
|
that is exactly the case the updater exists to avoid a reinstall for.
|
||||||
|
|
||||||
|
## Release assets
|
||||||
|
|
||||||
|
Each tagged release `vX.Y.Z` carries the existing per-platform archives
|
||||||
|
(`gen1recomp-X.Y.Z-macos.zip`, `-windows.zip`, `-linux.zip`,
|
||||||
|
`-android.apk`) plus two assets the updater itself consumes:
|
||||||
|
|
||||||
|
- `gen1recomp-X.Y.Z.love` - the payload, matched by the exact pattern
|
||||||
|
`gen1recomp-<version>.love` (see `isPayloadName` in `Boot.lua` and
|
||||||
|
`Check.parseRelease`).
|
||||||
|
- `sha256sums.txt` - `shasum -a 256` output (`<hex> <filename>`, bare
|
||||||
|
filenames) covering at least the `.love` payload. `Check.parseSums`
|
||||||
|
tolerates a leading `*` binary marker and a `./` prefix but expects the
|
||||||
|
filename otherwise to match the asset name exactly.
|
||||||
|
|
||||||
|
A release missing either asset is treated as "no in-place update available":
|
||||||
|
`Check` reports `needs_full` and sends the player to `Check.releaseUrl()`
|
||||||
|
(`https://github.com/bryanthaboi/pokemon-gen1-recomp-project/releases/latest`).
|
||||||
|
|
||||||
|
## Save-directory layout
|
||||||
|
|
||||||
|
Under the save directory (identity `pokemon-love2d`):
|
||||||
|
|
||||||
|
```
|
||||||
|
updates/gen1recomp-<X.Y.Z>.love downloaded payload(s)
|
||||||
|
updates/pending.txt crash-guard marker
|
||||||
|
```
|
||||||
|
|
||||||
|
`pending.txt` holds the filename of the payload currently being chainloaded.
|
||||||
|
`Boot.run`'s `chainload` writes it immediately before mounting, and removes it
|
||||||
|
on both a successful handoff and a clean rollback. If it is still present the
|
||||||
|
*next* time `Boot.run` starts, the previous boot died mid-handoff, so that
|
||||||
|
named payload is distrusted: it and the marker are deleted before candidates
|
||||||
|
are enumerated. Boot may still fall back to an older valid payload, or to the
|
||||||
|
bundled game, in that case.
|
||||||
|
|
||||||
|
## Update flow
|
||||||
|
|
||||||
|
1. **Boot** (every launch, fused builds only): crash-guard check, enumerate
|
||||||
|
and probe every `updates/*.love`, pick the highest engine that is
|
||||||
|
strictly newer than the bundled one and whose `minShell` this shell
|
||||||
|
satisfies, delete stale payloads, chainload the winner (or run the
|
||||||
|
bundled game if none qualifies).
|
||||||
|
2. **Check** (launcher screen): `Check.start()` kicks off an async check
|
||||||
|
against the GitHub releases API; safe to call every frame, it is a no-op
|
||||||
|
once a check is in flight or has reached a terminal state. `Check.state()`
|
||||||
|
reports `idle | checking | uptodate | available | downloading | ready |
|
||||||
|
needs_full | error` plus the latest version and download progress.
|
||||||
|
3. **Download + verify**: on `available`, `Check.download()` tells the
|
||||||
|
worker to fetch the payload, polling the growing `.part` file for
|
||||||
|
progress. On completion the worker re-fetches `sha256sums.txt`, verifies
|
||||||
|
the payload's sha256, and probes it with `Boot.probePayload` to gate its
|
||||||
|
`minShell` against this shell's `shell`. A verified, runnable payload is
|
||||||
|
renamed into place and reported as `ready`; anything else reports
|
||||||
|
`error` or `needs_full` and leaves `updates/` clean.
|
||||||
|
4. **Restart to apply**: a `ready` payload just sits in `updates/` until the
|
||||||
|
player relaunches; the next launch's Boot step (1) is what actually
|
||||||
|
mounts and runs it. There is no in-session hot-swap.
|
||||||
|
|
||||||
|
## Known limitations
|
||||||
|
|
||||||
|
- **`love.run` persists across handoff.** By the time `chainload` runs, the
|
||||||
|
bundled `love.run` has already returned its stepper to LOVE; redefining the
|
||||||
|
global `love.run` from the payload's `main.lua` does not affect the loop
|
||||||
|
already driving the frame. A payload that must change `love.run` itself
|
||||||
|
needs a `minShell` bump so an older shell refuses to chainload it rather
|
||||||
|
than running with half its intended behavior.
|
||||||
|
- **Android has no in-app download transport yet.** `check_worker.lua`
|
||||||
|
shells out to curl for both the release check and the download; curl is
|
||||||
|
absent on Android, so `Check` degrades to `status = "error"` there (the
|
||||||
|
launcher UI hides on that status) and the player is directed to the
|
||||||
|
releases page via `Check.releaseUrl()` instead.
|
||||||
|
- **Dev/source runs never self-update.** `Boot.run` returns immediately when
|
||||||
|
`love.filesystem.isFused()` is false, and a working tree's `engine` is the
|
||||||
|
`"0.0.0-dev"` placeholder that always reports up to date, so a source
|
||||||
|
checkout is always "the game" itself; updating it means pulling the repo.
|
||||||
@@ -18,6 +18,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.
|
||||||
@@ -56,6 +61,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
|
||||||
@@ -268,6 +279,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,6 +289,16 @@ 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)
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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": [],
|
||||||
|
|||||||
@@ -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": [],
|
||||||
|
|||||||
@@ -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": [],
|
||||||
|
|||||||
@@ -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": [],
|
||||||
|
|||||||
@@ -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": [],
|
||||||
|
|||||||
@@ -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": [],
|
||||||
|
|||||||
@@ -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/pokemon-gen1-recomp-project",
|
||||||
|
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": [],
|
||||||
|
|||||||
@@ -68,6 +68,31 @@ if unzip -Z1 "$LOVE_FILE" \
|
|||||||
fi
|
fi
|
||||||
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"
|
||||||
|
|||||||
@@ -145,6 +145,32 @@ pack_game_love() {
|
|||||||
fail "game.love unexpectedly contains generated ROM data"
|
fail "game.love unexpectedly contains generated ROM data"
|
||||||
fi
|
fi
|
||||||
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
|
||||||
|
|||||||
@@ -173,9 +173,32 @@ local function grayImage(img)
|
|||||||
return getImage(meta.path) or img
|
return getImage(meta.path) or img
|
||||||
end
|
end
|
||||||
|
|
||||||
|
-- the asset path a loaded battle image came from (nil for the headless
|
||||||
|
-- stub images), so the battle_sprite_scales registry can be looked up by
|
||||||
|
-- the same path data references
|
||||||
|
local function imagePathOf(img)
|
||||||
|
local m = imageMeta[img]
|
||||||
|
return m and m.path
|
||||||
|
end
|
||||||
|
|
||||||
-- the image a battler pic actually draws with this frame
|
-- the image a battler pic actually draws with this frame
|
||||||
function BattleState:picImage(img)
|
function BattleState:picImage(img)
|
||||||
if self.grayPics then return grayImage(img) end
|
local PaletteFX = require("src.render.PaletteFX")
|
||||||
|
-- #207: OG / OG INV / CLASSIC are forced-mono display modes. A battle that
|
||||||
|
-- exposes no SGB zones (sgbPalettes() == nil) has a whole-screen GRAYS zone
|
||||||
|
-- invented by PaletteFX.ensureZones, so Renderer:endFrame re-thresholds the
|
||||||
|
-- WHOLE finished frame through the shade shader a second time (keyed on the
|
||||||
|
-- red channel). A pic baked with the species' SGB color is then remapped
|
||||||
|
-- again and loses its warm mid shades -- REDMON's reds 1.0/0.839 both land in
|
||||||
|
-- the c0 bucket, collapsing CHARMANDER's body into the white paper and
|
||||||
|
-- leaving only the outline. Emit the raw DMG-gray build instead (exactly
|
||||||
|
-- what the SE_WAVY_SCREEN grayPics path already does) so the downstream remap
|
||||||
|
-- recolors 255->c0/170->c1/85->c2/0->c3 and all four shades survive; cool
|
||||||
|
-- palettes (CYANMON reds 0.678/0.451) already rendered correctly. This mode
|
||||||
|
-- set mirrors PaletteFX.ensureZones / effectiveColors -- keep them in sync.
|
||||||
|
local mono = PaletteFX.mode == "og" or PaletteFX.mode == "og_inv"
|
||||||
|
or PaletteFX.mode == "classic"
|
||||||
|
if self.grayPics or mono then return grayImage(img) end
|
||||||
return fadeImage(img, self:activeBgp())
|
return fadeImage(img, self:activeBgp())
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -267,13 +290,22 @@ local function makeBattler(data, mon, isPlayer, save)
|
|||||||
badgeBoosts = badgeBoosts,
|
badgeBoosts = badgeBoosts,
|
||||||
statuses = data.statuses,
|
statuses = data.statuses,
|
||||||
shownHP = mon.hp, -- the HP the bar displays (UpdateHPBar drain)
|
shownHP = mon.hp, -- the HP the bar displays (UpdateHPBar drain)
|
||||||
|
-- HUD status label (DrawHUDsAndHPBars); mon.status can land mid-move
|
||||||
|
-- while the tilemap still shows the prior condition until the next
|
||||||
|
-- post-action HUD refresh (core.asm after Execute*Move)
|
||||||
|
shownStatus = mon.status,
|
||||||
stages = {},
|
stages = {},
|
||||||
-- volatile state; Transform/Conversion/Mimic override the cur* fields
|
-- volatile state; Transform/Conversion/Mimic override the cur* fields
|
||||||
curStats = mon.stats,
|
curStats = mon.stats,
|
||||||
curTypes = def.types,
|
curTypes = def.types,
|
||||||
curMoves = mon.moves,
|
curMoves = mon.moves,
|
||||||
sprite = getImage(isPlayer and def.spriteBack or def.spriteFront,
|
sprite = (function()
|
||||||
monPalette(data, mon.species), def.trueColor),
|
local Sprites = require("src.pokemon.Sprites")
|
||||||
|
local path, tc = Sprites.path(data, mon.species,
|
||||||
|
isPlayer and "back" or "front",
|
||||||
|
{ mon = mon, kind = "battle" })
|
||||||
|
return getImage(path, monPalette(data, mon.species), tc)
|
||||||
|
end)(),
|
||||||
}
|
}
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -291,13 +323,16 @@ BattleState.makeBattler = makeBattler
|
|||||||
function BattleState:speciesSprite(species, isPlayerSide)
|
function BattleState:speciesSprite(species, isPlayerSide)
|
||||||
local def = self.data.pokemon[species]
|
local def = self.data.pokemon[species]
|
||||||
if not def then return nil end
|
if not def then return nil end
|
||||||
|
local Sprites = require("src.pokemon.Sprites")
|
||||||
|
local path, tc = Sprites.path(self.data, species,
|
||||||
|
isPlayerSide and "back" or "front", { kind = "battle" })
|
||||||
local PaletteFX = require("src.render.PaletteFX")
|
local PaletteFX = require("src.render.PaletteFX")
|
||||||
local colors = PaletteFX.monPal(self.data, species, true)
|
local colors = PaletteFX.monPal(self.data, species, true)
|
||||||
local name = "GRAYMON"
|
local name = "GRAYMON"
|
||||||
if PaletteFX.usesGbcPack() then name = "redpp:GRAYMON" end
|
if PaletteFX.usesGbcPack() then name = "redpp:GRAYMON" end
|
||||||
return getImage(isPlayerSide and def.spriteBack or def.spriteFront,
|
return getImage(path,
|
||||||
colors and { name = name, colors = colors } or nil,
|
colors and { name = name, colors = colors } or nil,
|
||||||
def.trueColor)
|
tc)
|
||||||
end
|
end
|
||||||
|
|
||||||
local function markSeen(game, species)
|
local function markSeen(game, species)
|
||||||
@@ -548,6 +583,12 @@ function BattleState:say(text)
|
|||||||
table.insert(self.queue, { text = text })
|
table.insert(self.queue, { text = text })
|
||||||
end
|
end
|
||||||
|
|
||||||
|
-- Message that opens YES/NO once typed out, keeping the text visible
|
||||||
|
-- underneath (pokered `done` + TWO_OPTION_MENU / TextBox opts.choice).
|
||||||
|
function BattleState:sayChoice(text, onChoose)
|
||||||
|
table.insert(self.queue, { text = text, choice = onChoose })
|
||||||
|
end
|
||||||
|
|
||||||
function BattleState:act(fn)
|
function BattleState:act(fn)
|
||||||
table.insert(self.queue, { fn = fn })
|
table.insert(self.queue, { fn = fn })
|
||||||
end
|
end
|
||||||
@@ -648,16 +689,53 @@ local function shownHP(b)
|
|||||||
return math.floor(shown)
|
return math.floor(shown)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
-- Parse a battle message into its rendered lines. The extractor marks
|
||||||
|
-- \n = next line and \v = CONT (home/text.asm ContText: draw the blinking
|
||||||
|
-- ▼, WaitForTextScrollButtonPress, then ScrollTextUpOneLine); the boosted /
|
||||||
|
-- EXP.ALL exp lines end in the CONT code in the ROM (data/generated/text.lua
|
||||||
|
-- _BoostedText/_WithExpAllText = "...\011"). Each entry is { codes, cont },
|
||||||
|
-- cont true when the line was preceded by \v. Splitting before Font.encode
|
||||||
|
-- keeps the control chars out of the glyph stream. The box then types into
|
||||||
|
-- a rolling 2-line window (self.shown) that scrolls when a 3rd line arrives
|
||||||
|
-- instead of drawing it off-screen at y=144 (#216).
|
||||||
function BattleState:startMessage(item)
|
function BattleState:startMessage(item)
|
||||||
self.current = item
|
self.current = item
|
||||||
self.lines = {}
|
self.lines = {}
|
||||||
self.total = 0
|
self.total = 0
|
||||||
for chunk in (item.text .. "\n"):gmatch("(.-)\n") do
|
local text = item.text or ""
|
||||||
|
local pos, cont = 1, false
|
||||||
|
while true do
|
||||||
|
local npos = text:find("[\n\v]", pos)
|
||||||
|
local chunk = npos and text:sub(pos, npos - 1) or text:sub(pos)
|
||||||
local codes = Font.encode(chunk)
|
local codes = Font.encode(chunk)
|
||||||
table.insert(self.lines, codes)
|
self.lines[#self.lines + 1] = { codes = codes, cont = cont }
|
||||||
self.total = self.total + #codes
|
self.total = self.total + #codes
|
||||||
|
if not npos then break end
|
||||||
|
cont = text:sub(npos, npos) == "\v"
|
||||||
|
pos = npos + 1
|
||||||
end
|
end
|
||||||
|
self.shown = {} -- up to two visible lines of revealed glyph codes
|
||||||
|
self.lineIndex = 0
|
||||||
|
-- self.charIndex counts glyphs typed across the WHOLE message (drivers read
|
||||||
|
-- it against self.total); the current line's revealed count is #shown[last]
|
||||||
self.charIndex = 0
|
self.charIndex = 0
|
||||||
|
self.msgWaiting = nil
|
||||||
|
self.scrollPx = nil
|
||||||
|
self:beginMsgLine()
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Start typing the next line into the rolling window. When the box already
|
||||||
|
-- shows two lines, drop the top one and set the pixel scroll-up
|
||||||
|
-- (ScrollTextUpOneLine), mirroring TextBox:beginLine.
|
||||||
|
function BattleState:beginMsgLine()
|
||||||
|
self.lineIndex = self.lineIndex + 1
|
||||||
|
local ln = self.lines[self.lineIndex]
|
||||||
|
self.codes = ln and ln.codes or {}
|
||||||
|
if #self.shown >= 2 then
|
||||||
|
table.remove(self.shown, 1)
|
||||||
|
self.scrollPx = 8
|
||||||
|
end
|
||||||
|
self.shown[#self.shown + 1] = {}
|
||||||
end
|
end
|
||||||
|
|
||||||
function BattleState:updateQueue()
|
function BattleState:updateQueue()
|
||||||
@@ -805,11 +883,50 @@ function BattleState:updateQueue()
|
|||||||
end
|
end
|
||||||
self:startMessage(item)
|
self:startMessage(item)
|
||||||
end
|
end
|
||||||
if self.charIndex < self.total then
|
local input = self.game.input
|
||||||
self.charIndex = math.min(self.total, self.charIndex + 2)
|
-- a \v CONT wait holds the box until A/B, then scrolls the next line in
|
||||||
else
|
-- (home/text.asm ContText); this keeps a 3rd line on-screen (#216)
|
||||||
local input = self.game.input
|
if self.msgWaiting then
|
||||||
if input:wasPressed("a") or input:wasPressed("b") then
|
if input:wasPressed("a") or input:wasPressed("b") then
|
||||||
|
self.msgWaiting = nil
|
||||||
|
self:beginMsgLine()
|
||||||
|
end
|
||||||
|
return true
|
||||||
|
end
|
||||||
|
local cur = self.shown[#self.shown]
|
||||||
|
if #cur < #self.codes then
|
||||||
|
-- battle typewriter cadence: two glyphs per fixed step (as before)
|
||||||
|
for _ = 1, 2 do
|
||||||
|
if #cur >= #self.codes then break end
|
||||||
|
cur[#cur + 1] = self.codes[#cur + 1]
|
||||||
|
self.charIndex = self.charIndex + 1
|
||||||
|
end
|
||||||
|
elseif self.lineIndex < #self.lines then
|
||||||
|
-- current line finished, more lines remain: \v waits for A/B + ▼ before
|
||||||
|
-- scrolling, \n advances now (beginMsgLine scrolls if the box is full)
|
||||||
|
if self.lines[self.lineIndex + 1].cont then
|
||||||
|
self.msgWaiting = true
|
||||||
|
else
|
||||||
|
self:beginMsgLine()
|
||||||
|
end
|
||||||
|
else
|
||||||
|
local item = self.current
|
||||||
|
-- TrainerAboutToUseText ends in `done` then DisplayTextBoxID: YES/NO
|
||||||
|
-- overlays the still-visible "Will … change POKéMON?" page.
|
||||||
|
if item and item.choice and not item.choiceOpen then
|
||||||
|
item.choiceOpen = true
|
||||||
|
self.waitingUI = true
|
||||||
|
local ChoiceBox = require("src.ui.ChoiceBox")
|
||||||
|
local battle = self
|
||||||
|
self.game.stack:push(ChoiceBox.new(self.game, function(yes)
|
||||||
|
local fn = item.choice
|
||||||
|
battle.current = nil
|
||||||
|
fn(yes)
|
||||||
|
end))
|
||||||
|
return true
|
||||||
|
end
|
||||||
|
if not (item and item.choice)
|
||||||
|
and (input:wasPressed("a") or input:wasPressed("b")) then
|
||||||
self.current = nil
|
self.current = nil
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
@@ -1060,11 +1177,14 @@ function BattleState:update(dt)
|
|||||||
self:updateFx()
|
self:updateFx()
|
||||||
local input = self.game.input
|
local input = self.game.input
|
||||||
|
|
||||||
-- safety net: HP changed outside a queued drain (level-up heals,
|
-- safety net: HP/status changed outside a queued drain (level-up heals,
|
||||||
-- field effects) snaps once the queue is idle
|
-- field effects, bag cures) snaps once the queue is idle
|
||||||
if self.phase == "menu" then
|
if self.phase == "menu" then
|
||||||
for _, b in ipairs({ self.player, self.enemy }) do
|
for _, b in ipairs({ self.player, self.enemy }) do
|
||||||
if b and b.shownHP then b.shownHP = b.mon.hp end
|
if b then
|
||||||
|
if b.shownHP then b.shownHP = b.mon.hp end
|
||||||
|
b.shownStatus = b.mon.status
|
||||||
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -2145,90 +2265,105 @@ end
|
|||||||
-- move execution pipeline
|
-- move execution pipeline
|
||||||
-- ---------------------------------------------------------------------
|
-- ---------------------------------------------------------------------
|
||||||
|
|
||||||
|
-- Mirror DrawHUDsAndHPBars: reveal mon.status on the HUD only after the
|
||||||
|
-- current action's queued anim/text have played (PoisonEffect sets the
|
||||||
|
-- bit before PlayCurrentMoveAnimation2 + PrintText, but the HUD redraw
|
||||||
|
-- waits until after Execute*Move returns).
|
||||||
|
function BattleState:syncShownStatus()
|
||||||
|
for _, b in ipairs({ self.player, self.enemy }) do
|
||||||
|
if b then b.shownStatus = b.mon.status end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
function BattleState:executeAction(user, target, action)
|
function BattleState:executeAction(user, target, action)
|
||||||
if user.mon.hp <= 0 or target.mon.hp <= 0 then return end
|
if user.mon.hp <= 0 or target.mon.hp <= 0 then return end
|
||||||
if not action then return end
|
if not action then return end
|
||||||
|
|
||||||
-- ghost battles: the ghost never attacks; its whole turn is the
|
local function run()
|
||||||
-- GetOutText (ExecuteEnemyMove -> PrintGhostText, core.asm:5462-5463)
|
-- ghost battles: the ghost never attacks; its whole turn is the
|
||||||
if self.ghost and not user.isPlayer then
|
-- GetOutText (ExecuteEnemyMove -> PrintGhostText, core.asm:5462-5463)
|
||||||
self:sayNext(self.data.text._GetOutText or "GHOST: Get out...\nGet out...")
|
if self.ghost and not user.isPlayer then
|
||||||
return
|
self:sayNext(self.data.text._GetOutText or "GHOST: Get out...\nGet out...")
|
||||||
end
|
|
||||||
|
|
||||||
-- refresh the held-in-place mirror before the status checks (see
|
|
||||||
-- lockedAction): the victim is held exactly while the opponent's
|
|
||||||
-- trapping bit is set -- including a counter sitting at 0 until the
|
|
||||||
-- end-of-turn CheckNumAttacksLeft clear
|
|
||||||
user.boundTurns = target.trappingTurns
|
|
||||||
and math.max(1, target.trappingTurns) or nil
|
|
||||||
|
|
||||||
-- trainer class AI actions (engine/battle/trainer_ai.asm)
|
|
||||||
if action.special == "aiItem" then
|
|
||||||
self.aiUses = (self.aiUses or 1) - 1
|
|
||||||
for _, m in ipairs(TrainerAI.useItem(self, action.item)) do
|
|
||||||
self:sayNext(prefixEnemy(m, self.enemy))
|
|
||||||
end
|
|
||||||
self:drainNext()
|
|
||||||
require("src.core.Sound").play(self.data, "Heal_Ailment")
|
|
||||||
return
|
|
||||||
end
|
|
||||||
if action.special == "aiSwitch" then
|
|
||||||
self.aiUses = (self.aiUses or 1) - 1
|
|
||||||
local previous = self.enemy
|
|
||||||
local oldName = self.enemy.name
|
|
||||||
self.enemyIndex = action.index
|
|
||||||
self.enemy = makeBattler(self.data, self.enemyParty[action.index], false)
|
|
||||||
-- EnemySendOutFirstMon (core.asm:1314-1315): clears player's trap
|
|
||||||
clearTrapping(self.player)
|
|
||||||
self:syncSides()
|
|
||||||
Runtime.emit("battle.battler_switched", {
|
|
||||||
battle = self, side = self.sides[2], battler = self.enemy,
|
|
||||||
previous = previous,
|
|
||||||
})
|
|
||||||
self.aiUses = self:aiUsesFor()
|
|
||||||
markSeen(self.game, self.enemy.mon.species)
|
|
||||||
-- _AIBattleWithdrawText: "X with-/drew Y!"
|
|
||||||
self:sayNext(("%s with-\ndrew %s!"):format(self.trainer.name, oldName))
|
|
||||||
self:sayNext(("%s sent\nout %s!"):format(self.trainer.name, self.enemy.name))
|
|
||||||
return
|
|
||||||
end
|
|
||||||
|
|
||||||
-- special locked actions. All of them still run the status gauntlet:
|
|
||||||
-- CheckPlayerStatusConditions (core.asm:3328-3583) evaluates sleep ->
|
|
||||||
-- freeze -> held-in-place -> flinch -> recharge -> disable tick ->
|
|
||||||
-- confusion -> paralysis BEFORE the bide/thrash/trapping handling.
|
|
||||||
if action.special == "recharge" then
|
|
||||||
-- only reaching .HyperBeamCheck consumes the flag (core.asm:3384-
|
|
||||||
-- 3392): sleep/freeze/held/flinch keep the mon recharging next turn
|
|
||||||
if self:preRechargeChecks(user, target) then return end
|
|
||||||
user.mustRecharge = nil
|
|
||||||
self:sayNext(("%s\nmust recharge!"):format(displayName(user)))
|
|
||||||
return
|
|
||||||
end
|
|
||||||
if action.special == "bound" then
|
|
||||||
if not target.trappingTurns then
|
|
||||||
-- the trap ended earlier this turn: the CANNOT_MOVE selection is
|
|
||||||
-- simply lost (ExecutePlayerMove returns immediately on $ff)
|
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
-- sleep/freeze take precedence over the held-in-place message
|
|
||||||
if self:statusInterrupt(user, target) then return end
|
|
||||||
return
|
|
||||||
end
|
|
||||||
if action.special == "trapping" then
|
|
||||||
if self:statusInterrupt(user, target) then return end
|
|
||||||
self:continueTrapping(user, target)
|
|
||||||
return
|
|
||||||
end
|
|
||||||
if action.special == "bide" then
|
|
||||||
if self:statusInterrupt(user, target) then return end
|
|
||||||
self:continueBide(user, target)
|
|
||||||
return
|
|
||||||
end
|
|
||||||
|
|
||||||
if self:statusInterrupt(user, target) then return end
|
-- refresh the held-in-place mirror before the status checks (see
|
||||||
self:performMove(user, target, action, false)
|
-- lockedAction): the victim is held exactly while the opponent's
|
||||||
|
-- trapping bit is set -- including a counter sitting at 0 until the
|
||||||
|
-- end-of-turn CheckNumAttacksLeft clear
|
||||||
|
user.boundTurns = target.trappingTurns
|
||||||
|
and math.max(1, target.trappingTurns) or nil
|
||||||
|
|
||||||
|
-- trainer class AI actions (engine/battle/trainer_ai.asm)
|
||||||
|
if action.special == "aiItem" then
|
||||||
|
self.aiUses = (self.aiUses or 1) - 1
|
||||||
|
for _, m in ipairs(TrainerAI.useItem(self, action.item)) do
|
||||||
|
self:sayNext(prefixEnemy(m, self.enemy))
|
||||||
|
end
|
||||||
|
self:drainNext()
|
||||||
|
require("src.core.Sound").play(self.data, "Heal_Ailment")
|
||||||
|
return
|
||||||
|
end
|
||||||
|
if action.special == "aiSwitch" then
|
||||||
|
self.aiUses = (self.aiUses or 1) - 1
|
||||||
|
local previous = self.enemy
|
||||||
|
local oldName = self.enemy.name
|
||||||
|
self.enemyIndex = action.index
|
||||||
|
self.enemy = makeBattler(self.data, self.enemyParty[action.index], false)
|
||||||
|
-- EnemySendOutFirstMon (core.asm:1314-1315): clears player's trap
|
||||||
|
clearTrapping(self.player)
|
||||||
|
self:syncSides()
|
||||||
|
Runtime.emit("battle.battler_switched", {
|
||||||
|
battle = self, side = self.sides[2], battler = self.enemy,
|
||||||
|
previous = previous,
|
||||||
|
})
|
||||||
|
self.aiUses = self:aiUsesFor()
|
||||||
|
markSeen(self.game, self.enemy.mon.species)
|
||||||
|
-- _AIBattleWithdrawText: "X with-/drew Y!"
|
||||||
|
self:sayNext(("%s with-\ndrew %s!"):format(self.trainer.name, oldName))
|
||||||
|
self:sayNext(("%s sent\nout %s!"):format(self.trainer.name, self.enemy.name))
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
-- special locked actions. All of them still run the status gauntlet:
|
||||||
|
-- CheckPlayerStatusConditions (core.asm:3328-3583) evaluates sleep ->
|
||||||
|
-- freeze -> held-in-place -> flinch -> recharge -> disable tick ->
|
||||||
|
-- confusion -> paralysis BEFORE the bide/thrash/trapping handling.
|
||||||
|
if action.special == "recharge" then
|
||||||
|
-- only reaching .HyperBeamCheck consumes the flag (core.asm:3384-
|
||||||
|
-- 3392): sleep/freeze/held/flinch keep the mon recharging next turn
|
||||||
|
if self:preRechargeChecks(user, target) then return end
|
||||||
|
user.mustRecharge = nil
|
||||||
|
self:sayNext(("%s\nmust recharge!"):format(displayName(user)))
|
||||||
|
return
|
||||||
|
end
|
||||||
|
if action.special == "bound" then
|
||||||
|
if not target.trappingTurns then
|
||||||
|
-- the trap ended earlier this turn: the CANNOT_MOVE selection is
|
||||||
|
-- simply lost (ExecutePlayerMove returns immediately on $ff)
|
||||||
|
return
|
||||||
|
end
|
||||||
|
-- sleep/freeze take precedence over the held-in-place message
|
||||||
|
if self:statusInterrupt(user, target) then return end
|
||||||
|
return
|
||||||
|
end
|
||||||
|
if action.special == "trapping" then
|
||||||
|
if self:statusInterrupt(user, target) then return end
|
||||||
|
self:continueTrapping(user, target)
|
||||||
|
return
|
||||||
|
end
|
||||||
|
if action.special == "bide" then
|
||||||
|
if self:statusInterrupt(user, target) then return end
|
||||||
|
self:continueBide(user, target)
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
if self:statusInterrupt(user, target) then return end
|
||||||
|
self:performMove(user, target, action, false)
|
||||||
|
end
|
||||||
|
run()
|
||||||
|
-- after announce/anim/effect text (pokered DrawHUDsAndHPBars)
|
||||||
|
self:actNext(function() self:syncShownStatus() end)
|
||||||
end
|
end
|
||||||
|
|
||||||
-- Sleep / confusion onomatopoeia from Check*StatusConditions
|
-- Sleep / confusion onomatopoeia from Check*StatusConditions
|
||||||
@@ -2393,10 +2528,17 @@ function BattleState:performMove(user, target, moveInst, isCalled)
|
|||||||
-- PP: not for continuations, struggle, called moves, or (under
|
-- PP: not for continuations, struggle, called moves, or (under
|
||||||
-- gen1_faithful) wild/trainer enemies — pokered DecrementPP only ever
|
-- gen1_faithful) wild/trainer enemies — pokered DecrementPP only ever
|
||||||
-- mutates wBattleMonPP / party PP (engine/battle/decrement_pp.asm).
|
-- mutates wBattleMonPP / party PP (engine/battle/decrement_pp.asm).
|
||||||
|
-- That rule doesn't apply in a link battle: "the enemy" there is a real
|
||||||
|
-- human peer independently tracking their own PP the normal way, not an
|
||||||
|
-- AI Gen 1 never bothered to decrement -- applying it made each side
|
||||||
|
-- silently skip decrementing the OTHER side's PP for the move it just
|
||||||
|
-- used, so both simulations' party state diverged by exactly 1 PP on
|
||||||
|
-- the very first move either side made, failing the lockstep hash
|
||||||
|
-- check turn 1 of literally every link battle.
|
||||||
local isContinuation = releasing
|
local isContinuation = releasing
|
||||||
or (user.thrashTurns and user.thrashTurns > 0 and moveInst == user.thrashMove)
|
or (user.thrashTurns and user.thrashTurns > 0 and moveInst == user.thrashMove)
|
||||||
or moveInst == user.rageMove
|
or moveInst == user.rageMove
|
||||||
local enemyUnlimited = not user.isPlayer
|
local enemyUnlimited = not user.isPlayer and self.kind ~= "link"
|
||||||
and self.ruleset and self.ruleset.enemyUnlimitedPP
|
and self.ruleset and self.ruleset.enemyUnlimitedPP
|
||||||
if not isContinuation and not moveInst.struggle and not isCalled
|
if not isContinuation and not moveInst.struggle and not isCalled
|
||||||
and not enemyUnlimited then
|
and not enemyUnlimited then
|
||||||
@@ -2647,6 +2789,13 @@ function BattleState:enemyMonFainted()
|
|||||||
local levels, gained = Experience.apply(self.data, mon, self.enemy.def,
|
local levels, gained = Experience.apply(self.data, mon, self.enemy.def,
|
||||||
self.enemy.mon.level, self.kind == "trainer",
|
self.enemy.mon.level, self.kind == "trainer",
|
||||||
split, mon.traded)
|
split, mon.traded)
|
||||||
|
-- Track level-ups for EvolveAfterBattle (OverworldState:afterBattle ->
|
||||||
|
-- Evolution.checkParty). B-cancel leaves the mon at/above threshold;
|
||||||
|
-- without this gate it re-triggers after every later fight (#213).
|
||||||
|
if #levels > 0 then
|
||||||
|
self.leveledUp = self.leveledUp or {}
|
||||||
|
self.leveledUp[mon] = true
|
||||||
|
end
|
||||||
Runtime.emit("battle.exp_gained", {
|
Runtime.emit("battle.exp_gained", {
|
||||||
battle = self, mon = mon, gained = gained, levels = levels,
|
battle = self, mon = mon, gained = gained, levels = levels,
|
||||||
})
|
})
|
||||||
@@ -2656,12 +2805,15 @@ function BattleState:enemyMonFainted()
|
|||||||
-- _WithExpAllText / _BoostedText / _ExpPointsText; the EXP.ALL
|
-- _WithExpAllText / _BoostedText / _ExpPointsText; the EXP.ALL
|
||||||
-- pass beats the traded boost (wBoostExpByExpAll checks first),
|
-- pass beats the traded boost (wBoostExpByExpAll checks first),
|
||||||
-- and _ExpPointsText prints wExpAmountGained -- the raw share,
|
-- and _ExpPointsText prints wExpAmountGained -- the raw share,
|
||||||
-- captured before the max-level cap (experience.asm:92-100)
|
-- captured before the max-level cap (experience.asm:92-100).
|
||||||
|
-- _BoostedText / _WithExpAllText end in the CONT code (\v, "...\011"
|
||||||
|
-- in data/generated/text.lua): the box waits for A/B + ▼ then scrolls
|
||||||
|
-- the amount line in, so it stays on-screen instead of at y=144 (#216).
|
||||||
local tail = "%d EXP. Points!"
|
local tail = "%d EXP. Points!"
|
||||||
if announce == "expAll" then
|
if announce == "expAll" then
|
||||||
tail = "with EXP.ALL,\n" .. tail
|
tail = "with EXP.ALL,\v" .. tail
|
||||||
elseif mon.traded then
|
elseif mon.traded then
|
||||||
tail = "a boosted\n" .. tail
|
tail = "a boosted\v" .. tail
|
||||||
end
|
end
|
||||||
self:sayNext(("%s gained\n" .. tail):format(name, gained))
|
self:sayNext(("%s gained\n" .. tail):format(name, gained))
|
||||||
end
|
end
|
||||||
@@ -2674,6 +2826,17 @@ function BattleState:enemyMonFainted()
|
|||||||
require("src.core.Sound").play(game.data, "Level_Up")
|
require("src.core.Sound").play(game.data, "Level_Up")
|
||||||
return StatBox.new(game, mon)
|
return StatBox.new(game, mon)
|
||||||
end)
|
end)
|
||||||
|
-- After PrintStatsBox, experience.asm reloads the active battler's
|
||||||
|
-- wBattleMon and runs DrawHUDsAndHPBars, so its HP bar reflects the
|
||||||
|
-- higher current HP. Experience.lua:84 already raised mon.hp by
|
||||||
|
-- (newMaxHP - oldMaxHP); the party mon and the battler share one table
|
||||||
|
-- (makeBattler), so mon.stats.hp (the bar's denominator) jumps to the
|
||||||
|
-- new max instantly while the battler's shownHP numerator lags at the
|
||||||
|
-- old current HP -- the bar SHRINKS (#224). Animate shownHP up to the
|
||||||
|
-- new current HP (house convention: potions drain the bar too, see
|
||||||
|
-- itemUsed) so the bar grows instead. Only the active player battler
|
||||||
|
-- shares its table with the HUD; other party mons (EXP.ALL) have no bar.
|
||||||
|
if mon == self.player.mon then self:drainNext() end
|
||||||
for _, moveId in ipairs(Experience.movesLearnedAt(
|
for _, moveId in ipairs(Experience.movesLearnedAt(
|
||||||
self.data.pokemon[mon.species], lv)) do
|
self.data.pokemon[mon.species], lv)) do
|
||||||
self:learnMove(mon, moveId)
|
self:learnMove(mon, moveId)
|
||||||
@@ -2717,51 +2880,37 @@ function BattleState:enemyMonFainted()
|
|||||||
end
|
end
|
||||||
if nextIndex then
|
if nextIndex then
|
||||||
self.enemyIndex = nextIndex
|
self.enemyIndex = nextIndex
|
||||||
-- SHIFT battle style (the default): announce the next mon and
|
-- EnemySendOutFirstMon (core.asm:1366-1443): SHIFT offers a free
|
||||||
-- offer a free switch (SET skips the prompt)
|
-- switch when party count > 1, the active mon is alive, and the
|
||||||
|
-- battle-style bit is clear. pokered counts party slots (not
|
||||||
|
-- remaining HP); SET / single-mon / fainted active skip the prompt.
|
||||||
local nextMon = self.enemyParty[self.enemyIndex]
|
local nextMon = self.enemyParty[self.enemyIndex]
|
||||||
local nextName = nextMon.nickname or self.data.pokemon[nextMon.species].name
|
local nextName = nextMon.nickname or self.data.pokemon[nextMon.species].name
|
||||||
local style = (self.game.save.options or {}).battleStyle or "shift"
|
local style = tostring((self.game.save.options or {}).battleStyle or "shift")
|
||||||
local healthy = 0
|
:lower()
|
||||||
for _, mon in ipairs(self.game.save.party) do
|
local partyCount = #self.game.save.party
|
||||||
if mon.hp > 0 then healthy = healthy + 1 end
|
-- SwitchPlayerMon runs AFTER TrainerSentOutText (core.asm:1436-1443)
|
||||||
end
|
local shiftSwitchMon = nil
|
||||||
if style ~= "set" and healthy > 1 and self.player.mon.hp > 0 then
|
if style ~= "set" and partyCount > 1 and self.player.mon.hp > 0 then
|
||||||
self:say(("%s is\nabout to use\n%s!"):format(self.trainer.name, nextName))
|
-- _TrainerAboutToUseText: "X is" / "about to use" then cont nick,
|
||||||
self:say(("Will %s\nchange POKéMON?"):format(self.game.save.player.name))
|
-- then para "Will PLAYER" / "change POKéMON?" with YES/NO.
|
||||||
local game = self.game
|
self:say(("%s is\nabout to use"):format(self.trainer.name))
|
||||||
self:ui(function()
|
self:say(("%s!"):format(nextName))
|
||||||
local ChoiceBox = require("src.ui.ChoiceBox")
|
self:sayChoice(
|
||||||
return ChoiceBox.new(game, function(yes)
|
("Will %s\nchange POKéMON?"):format(self.game.save.player.name),
|
||||||
|
function(yes)
|
||||||
if not yes then return end
|
if not yes then return end
|
||||||
|
local game = self.game
|
||||||
Screens.push(game, "PartyMenu", {
|
Screens.push(game, "PartyMenu", {
|
||||||
battle = self,
|
battle = self,
|
||||||
|
forceSwitch = true,
|
||||||
onSwitch = function(mon)
|
onSwitch = function(mon)
|
||||||
if mon ~= self.player.mon and mon.hp > 0 then
|
if mon ~= self.player.mon and mon.hp > 0 then
|
||||||
local previous = self.player
|
shiftSwitchMon = mon
|
||||||
self.player = makeBattler(self.data, mon, true, game.save)
|
|
||||||
clearTrapping(self.enemy) -- SendOutMon clears foe trap
|
|
||||||
self:syncSides()
|
|
||||||
Runtime.emit("battle.battler_switched", {
|
|
||||||
battle = self, side = self.sides[1],
|
|
||||||
battler = self.player, previous = previous,
|
|
||||||
})
|
|
||||||
self:markParticipant()
|
|
||||||
self.nextInsert = 0
|
|
||||||
self.sendingOut = true
|
|
||||||
self:sayNext(self:sendOutText(self.player.name))
|
|
||||||
self:animNext("POOF_ANIM", false)
|
|
||||||
self:actNext(function()
|
|
||||||
self.sendingOut = false
|
|
||||||
-- SendOutMon (core.asm:1757-1762): poof, then the grow-in
|
|
||||||
self:startGrowIn(self.player)
|
|
||||||
require("src.core.Sound").playCry(self.data, self.player.mon.species)
|
|
||||||
end)
|
|
||||||
end
|
end
|
||||||
end,
|
end,
|
||||||
})
|
})
|
||||||
end)
|
end)
|
||||||
end)
|
|
||||||
end
|
end
|
||||||
self:act(function()
|
self:act(function()
|
||||||
local previous = self.enemy
|
local previous = self.enemy
|
||||||
@@ -2790,6 +2939,29 @@ function BattleState:enemyMonFainted()
|
|||||||
end)
|
end)
|
||||||
end)
|
end)
|
||||||
end)
|
end)
|
||||||
|
-- SwitchPlayerMon after the enemy is out (core.asm:1436-1443)
|
||||||
|
self:act(function()
|
||||||
|
local mon = shiftSwitchMon
|
||||||
|
if not mon then return end
|
||||||
|
local previous = self.player
|
||||||
|
self.player = makeBattler(self.data, mon, true, self.game.save)
|
||||||
|
clearTrapping(self.enemy)
|
||||||
|
self:syncSides()
|
||||||
|
Runtime.emit("battle.battler_switched", {
|
||||||
|
battle = self, side = self.sides[1],
|
||||||
|
battler = self.player, previous = previous,
|
||||||
|
})
|
||||||
|
self:markParticipant()
|
||||||
|
self.nextInsert = 0
|
||||||
|
self.sendingOut = true
|
||||||
|
self:sayNext(self:sendOutText(self.player.name))
|
||||||
|
self:animNext("POOF_ANIM", false)
|
||||||
|
self:actNext(function()
|
||||||
|
self.sendingOut = false
|
||||||
|
self:startGrowIn(self.player)
|
||||||
|
require("src.core.Sound").playCry(self.data, self.player.mon.species)
|
||||||
|
end)
|
||||||
|
end)
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
local prize = (self.trainer.baseMoney or 0) * self.enemy.mon.level
|
local prize = (self.trainer.baseMoney or 0) * self.enemy.mon.level
|
||||||
@@ -2913,6 +3085,8 @@ function BattleState:openReplacementMenu()
|
|||||||
self:ui(function()
|
self:ui(function()
|
||||||
return self:buildScreen("PartyMenu", {
|
return self:buildScreen("PartyMenu", {
|
||||||
battle = self,
|
battle = self,
|
||||||
|
-- ChooseNextMon: pick immediately (no SWITCH/STATS/CANCEL)
|
||||||
|
forceSwitch = true,
|
||||||
onSwitch = function(mon)
|
onSwitch = function(mon)
|
||||||
if mon.hp <= 0 then
|
if mon.hp <= 0 then
|
||||||
self:say("There's no will\nto fight!")
|
self:say("There's no will\nto fight!")
|
||||||
@@ -3123,6 +3297,9 @@ end
|
|||||||
|
|
||||||
-- called by BagMenu after an item is used in battle (consumes the turn)
|
-- called by BagMenu after an item is used in battle (consumes the turn)
|
||||||
function BattleState:itemUsed(messages)
|
function BattleState:itemUsed(messages)
|
||||||
|
-- bag cures clear mon.status before the message UI; refresh the HUD
|
||||||
|
-- once control returns (pokered DrawHUDsAndHPBars after item use)
|
||||||
|
self:syncShownStatus()
|
||||||
for _, m in ipairs(messages or {}) do self:say(m) end
|
for _, m in ipairs(messages or {}) do self:say(m) end
|
||||||
table.insert(self.queue, { drain = true }) -- potions animate the bar
|
table.insert(self.queue, { drain = true }) -- potions animate the bar
|
||||||
self:act(function()
|
self:act(function()
|
||||||
@@ -3144,10 +3321,39 @@ function BattleState:ballMissMessage(shakes)
|
|||||||
return t._ItemUseBallText04 or "Shoot! It was so\nclose too!"
|
return t._ItemUseBallText04 or "Shoot! It was so\nclose too!"
|
||||||
end
|
end
|
||||||
|
|
||||||
|
-- AskName (engine/menus/naming_screen.asm): ClearSprites, wild field blank,
|
||||||
|
-- PrintText, YES/NO while text stays (TextBox opts.choice). Shared by party
|
||||||
|
-- AddPartyMon and SendNewMonToBox (#172).
|
||||||
|
function BattleState:askNicknameUI(mon, displayName)
|
||||||
|
local game = self.game
|
||||||
|
self.lockedBall = nil
|
||||||
|
self.blankForAskName = true
|
||||||
|
local TextBox = require("src.render.TextBox")
|
||||||
|
local text = ("Do you want to\ngive a nickname\nto %s?"):format(displayName)
|
||||||
|
local label = game.data.text and game.data.text._DoYouWantToNicknameText
|
||||||
|
if label then
|
||||||
|
-- extractor CONT is \t; TextBox scrolls on \n/\v
|
||||||
|
text = label:gsub("\t", "\n"):gsub("{RAM:?[%w_]*}", displayName)
|
||||||
|
end
|
||||||
|
return TextBox.new(game, text, nil, {
|
||||||
|
choice = function(yes)
|
||||||
|
self.blankForAskName = false
|
||||||
|
if not yes then return end
|
||||||
|
pcall(Screens.push, game, "NamingScreen", {
|
||||||
|
title = "NICKNAME?", maxLen = 10,
|
||||||
|
onDone = function(name)
|
||||||
|
if name and #name > 0 then mon.nickname = name end
|
||||||
|
end,
|
||||||
|
})
|
||||||
|
end,
|
||||||
|
})
|
||||||
|
end
|
||||||
|
|
||||||
-- The caught mon joins the party or a PC box (ItemUseBall .captured,
|
-- The caught mon joins the party or a PC box (ItemUseBall .captured,
|
||||||
-- item_effects.asm:518-566): the caught text, then for a NEW species
|
-- item_effects.asm:518-566): the caught text, then for a NEW species
|
||||||
-- "New POKéDEX data will be added" + the dex entry page, then the
|
-- "New POKéDEX data will be added" + the dex entry page, then
|
||||||
-- party add (with the nickname ask) or the PC transfer text.
|
-- AddPartyMon or SendNewMonToBox (both call AskName), then the PC
|
||||||
|
-- transfer text when the party was full.
|
||||||
function BattleState:storeCaughtMon()
|
function BattleState:storeCaughtMon()
|
||||||
-- ItemUseBall reloads the caught mon via LoadEnemyMonData
|
-- ItemUseBall reloads the caught mon via LoadEnemyMonData
|
||||||
-- (item_effects.asm:472-501), regenerating its move list from the
|
-- (item_effects.asm:472-501), regenerating its move list from the
|
||||||
@@ -3167,31 +3373,20 @@ function BattleState:storeCaughtMon()
|
|||||||
return self:buildScreen("DexEntryMenu", species)
|
return self:buildScreen("DexEntryMenu", species)
|
||||||
end)
|
end)
|
||||||
end
|
end
|
||||||
if Party.add(game.save.party, self.enemy.mon) then
|
local function askCaughtNickname()
|
||||||
-- nickname prompt (AskName runs inside AddPartyMon; box mons are
|
|
||||||
-- never offered a nickname)
|
|
||||||
local caught = self.enemy.mon
|
local caught = self.enemy.mon
|
||||||
local enemyName = self.enemy.name
|
local enemyName = self.enemy.name
|
||||||
self:uiNext(function()
|
self:uiNext(function()
|
||||||
local ChoiceBox = require("src.ui.ChoiceBox")
|
return self:askNicknameUI(caught, enemyName)
|
||||||
local TextBox = require("src.render.TextBox")
|
|
||||||
return TextBox.new(game, ("Do you want to\ngive a nickname\nto %s?")
|
|
||||||
:format(enemyName), function()
|
|
||||||
game.stack:push(ChoiceBox.new(game, function(yes)
|
|
||||||
if not yes then return end
|
|
||||||
pcall(Screens.push, game, "NamingScreen", {
|
|
||||||
title = "NICKNAME?", maxLen = 10,
|
|
||||||
onDone = function(name)
|
|
||||||
if name and #name > 0 then caught.nickname = name end
|
|
||||||
end,
|
|
||||||
})
|
|
||||||
end))
|
|
||||||
end)
|
|
||||||
end)
|
end)
|
||||||
|
end
|
||||||
|
if Party.add(game.save.party, self.enemy.mon) then
|
||||||
|
askCaughtNickname()
|
||||||
else
|
else
|
||||||
destination = "box"
|
destination = "box"
|
||||||
local boxNum = require("src.pokemon.Boxes").deposit(game.save, self.enemy.mon)
|
local boxNum = require("src.pokemon.Boxes").deposit(game.save, self.enemy.mon)
|
||||||
if boxNum then
|
if boxNum then
|
||||||
|
askCaughtNickname()
|
||||||
-- _ItemUseBallText07/08 keyed on EVENT_MET_BILL
|
-- _ItemUseBallText07/08 keyed on EVENT_MET_BILL
|
||||||
local pc = (game.save.flags and game.save.flags.EVENT_MET_BILL)
|
local pc = (game.save.flags and game.save.flags.EVENT_MET_BILL)
|
||||||
and "BILL's PC" or "someone's PC"
|
and "BILL's PC" or "someone's PC"
|
||||||
@@ -3442,12 +3637,15 @@ function BattleState:fxFaintActive(battler)
|
|||||||
and fx.faint.frames > 0 or false
|
and fx.faint.frames > 0 or false
|
||||||
end
|
end
|
||||||
|
|
||||||
-- vertical slide offset for a fainting battler (the player's pic is
|
-- vertical slide offset for a fainting battler. The offset is in screen
|
||||||
-- drawn 2x, so it slides 2x as fast to sink at the same visual rate)
|
-- pixels, so it scales with the pic's draw scale (the player's default 2x
|
||||||
function BattleState:fxFaintOffset(battler)
|
-- sinks 2x as fast to sink at the same visual rate); a mod scale composes
|
||||||
|
-- the same way. scale defaults to the vanilla side scale when unknown.
|
||||||
|
function BattleState:fxFaintOffset(battler, scale)
|
||||||
local fx = self.fx
|
local fx = self.fx
|
||||||
if self:fxFaintActive(battler) then
|
if self:fxFaintActive(battler) then
|
||||||
return (30 - fx.faint.frames) * 2 * (battler.isPlayer and 2 or 1)
|
scale = scale or (battler.isPlayer and 2 or 1)
|
||||||
|
return (30 - fx.faint.frames) * 2 * scale
|
||||||
end
|
end
|
||||||
return 0
|
return 0
|
||||||
end
|
end
|
||||||
@@ -3543,7 +3741,7 @@ function BattleState:drawBattlerPic(battler, x, y, scale)
|
|||||||
return
|
return
|
||||||
end
|
end
|
||||||
if self:fxFaintActive(battler) then
|
if self:fxFaintActive(battler) then
|
||||||
local off = self:fxFaintOffset(battler)
|
local off = self:fxFaintOffset(battler, scale)
|
||||||
local visible = img:getHeight() - math.floor(off / scale)
|
local visible = img:getHeight() - math.floor(off / scale)
|
||||||
if visible > 0 then
|
if visible > 0 then
|
||||||
local quad = love.graphics.newQuad(0, 0, img:getWidth(), visible,
|
local quad = love.graphics.newQuad(0, 0, img:getWidth(), visible,
|
||||||
@@ -3678,8 +3876,11 @@ function BattleState:colorMode()
|
|||||||
if g and g.newCanvas and g.setScissor and g.setShader and g.getCanvas
|
if g and g.newCanvas and g.setScissor and g.setShader and g.getCanvas
|
||||||
and love.image and PaletteFX.pack(self.data)
|
and love.image and PaletteFX.pack(self.data)
|
||||||
and PaletteFX.shader() then
|
and PaletteFX.shader() then
|
||||||
local ok1, bg = pcall(g.newCanvas, 160, 144)
|
-- 160x144 real pixels, not DPI units, or the colored battle background
|
||||||
local ok2, wv = pcall(g.newCanvas, 160, 144)
|
-- resamples against the UI canvas on mobile (#208; PixelCanvas.lua)
|
||||||
|
local PixelCanvas = require("src.render.PixelCanvas")
|
||||||
|
local ok1, bg = pcall(PixelCanvas.new, 160, 144)
|
||||||
|
local ok2, wv = pcall(PixelCanvas.new, 160, 144)
|
||||||
if ok1 and ok2 and bg and wv then
|
if ok1 and ok2 and bg and wv then
|
||||||
self.bgCanvas, self.waveCanvas = bg, wv
|
self.bgCanvas, self.waveCanvas = bg, wv
|
||||||
ready = true
|
ready = true
|
||||||
@@ -3840,6 +4041,63 @@ function BattleState:drawAnimLayer(colorized)
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
-- ------------------------------------------------------------------
|
||||||
|
-- Mod-facing battle sprite scaling. The enemy front pic draws at 1x and
|
||||||
|
-- the player back pic at 2x on the GB; a mod can override either per
|
||||||
|
-- species (pokemon.battleScaleFront / battleScaleBack) or per image path
|
||||||
|
-- (the battle_sprite_scales registry, which is the only handle on the
|
||||||
|
-- non-species pics like the trainer back). These resolvers and the
|
||||||
|
-- placement math are pure (no love.*) so the grounding contract -- feet
|
||||||
|
-- pinned at any scale -- is unit-tested directly.
|
||||||
|
-- ------------------------------------------------------------------
|
||||||
|
|
||||||
|
-- the vanilla scale for a side: enemy front 1x, player back 2x
|
||||||
|
BattleState.BATTLE_SCALE_DEFAULT = { front = 1, back = 2 }
|
||||||
|
|
||||||
|
-- image-level override for an asset path, or nil. scales is the merged
|
||||||
|
-- data.battle_sprite_scales table (record id -> { path, scale }).
|
||||||
|
function BattleState.imageBattleScale(scales, path)
|
||||||
|
if not scales or not path then return nil end
|
||||||
|
for id, rec in pairs(scales) do
|
||||||
|
if id ~= "_owners" and type(rec) == "table" and rec.path == path then
|
||||||
|
return rec.scale
|
||||||
|
end
|
||||||
|
end
|
||||||
|
return nil
|
||||||
|
end
|
||||||
|
|
||||||
|
-- effective battle scale for a pic: image-level override, else the
|
||||||
|
-- species-level override for the side, else the side default. side is
|
||||||
|
-- "front" (enemy) or "back" (player); species may be nil (a non-species
|
||||||
|
-- pic like the trainer back, which only image-level scaling reaches).
|
||||||
|
function BattleState.resolveBattleScale(data, side, path, species)
|
||||||
|
local img = data and BattleState.imageBattleScale(data.battle_sprite_scales, path)
|
||||||
|
if img then return img end
|
||||||
|
local def = species and data and data.pokemon and data.pokemon[species]
|
||||||
|
local field = side == "back" and "battleScaleBack" or "battleScaleFront"
|
||||||
|
local override = def and def[field]
|
||||||
|
if override then return override end
|
||||||
|
return BattleState.BATTLE_SCALE_DEFAULT[side] or 1
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Player (back) placement: feet flush on the text-box top (y=96) at any
|
||||||
|
-- scale, with the left transparent columns pulled back so opaque pixels
|
||||||
|
-- land where hardware's white-on-white columns left them. Returns the
|
||||||
|
-- top-left x, y and the scale (slide/shake offsets are added by the
|
||||||
|
-- caller). Feet stay at 96 for every scale: y + (h - pad) * scale == 96.
|
||||||
|
function BattleState.backPlacement(w, h, pad, padL, scale)
|
||||||
|
return 8 - padL * scale, 96 - (h - pad) * scale, scale
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Enemy (front) placement: given the s=1 slot origin (ex, ey) from the
|
||||||
|
-- 7x7 tile layout, keep the bottom edge and horizontal centre pinned as
|
||||||
|
-- the pic scales -- the same compensation AnimateSendingOutMon's grow
|
||||||
|
-- uses. Returns top-left x, y and the scale. The bottom edge stays put
|
||||||
|
-- for every scale: y + h * scale == ey + h.
|
||||||
|
function BattleState.frontPlacement(ex, ey, w, h, scale)
|
||||||
|
return ex + w * (1 - scale) / 2, ey + h * (1 - scale), scale
|
||||||
|
end
|
||||||
|
|
||||||
-- Front/trainer pics: LoadUncompressedSpriteData centers the sprite in
|
-- Front/trainer pics: LoadUncompressedSpriteData centers the sprite in
|
||||||
-- a 7x7 tile buffer, then CopyUncompressedPicToTilemap places that
|
-- a 7x7 tile buffer, then CopyUncompressedPicToTilemap places that
|
||||||
-- buffer at hlcoord 12,0. Horizontal pad is floor((8-w)/2) tiles;
|
-- buffer at hlcoord 12,0. Horizontal pad is floor((8-w)/2) tiles;
|
||||||
@@ -3883,16 +4141,23 @@ function BattleState:drawPicsLayer(slide, sx, sy)
|
|||||||
local img = self:picImage(self.enemy.sprite)
|
local img = self:picImage(self.enemy.sprite)
|
||||||
love.graphics.setColor(1, 1, 1, 1)
|
love.graphics.setColor(1, 1, 1, 1)
|
||||||
local ex, ey = enemyPicXY(img, slide, sx, sy)
|
local ex, ey = enemyPicXY(img, slide, sx, sy)
|
||||||
|
local s = BattleState.resolveBattleScale(self.data, "front",
|
||||||
|
imagePathOf(img), self.enemy.mon and self.enemy.mon.species)
|
||||||
local gs = self:growInScale(self.enemy)
|
local gs = self:growInScale(self.enemy)
|
||||||
if gs then
|
if gs then
|
||||||
-- AnimateSendingOutMon: the downscaled pic keeps its bottom edge
|
-- AnimateSendingOutMon: the downscaled pic keeps its bottom edge
|
||||||
-- and horizontal center pinned to the mon's slot while it grows
|
-- and horizontal center pinned to the mon's slot while it grows --
|
||||||
if gs > 0 then
|
-- the mod scale composes multiplicatively with the grow stage
|
||||||
love.graphics.draw(img, ex + img:getWidth() * (1 - gs) / 2,
|
local eff = s * gs
|
||||||
ey + img:getHeight() * (1 - gs), 0, gs, gs)
|
if eff > 0 then
|
||||||
|
local dx, dy = BattleState.frontPlacement(ex, ey,
|
||||||
|
img:getWidth(), img:getHeight(), eff)
|
||||||
|
love.graphics.draw(img, dx, dy, 0, eff, eff)
|
||||||
end
|
end
|
||||||
else
|
else
|
||||||
self:drawBattlerPic(self.enemy, ex, ey, 1)
|
local dx, dy = BattleState.frontPlacement(ex, ey,
|
||||||
|
img:getWidth(), img:getHeight(), s)
|
||||||
|
self:drawBattlerPic(self.enemy, dx, dy, s)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -3906,9 +4171,14 @@ function BattleState:drawPicsLayer(slide, sx, sy)
|
|||||||
local img = self:picImage(self.playerBackPic)
|
local img = self:picImage(self.playerBackPic)
|
||||||
local pad = imagePadBottom[self.playerBackPic] or 0
|
local pad = imagePadBottom[self.playerBackPic] or 0
|
||||||
local padL = imagePadLeft[self.playerBackPic] or 0
|
local padL = imagePadLeft[self.playerBackPic] or 0
|
||||||
|
-- the trainer back is a bare pic, not species-keyed, so only an
|
||||||
|
-- image-level battle_sprite_scales entry can rescale it
|
||||||
|
local s = BattleState.resolveBattleScale(self.data, "back",
|
||||||
|
imagePathOf(self.playerBackPic), nil)
|
||||||
love.graphics.setColor(1, 1, 1, 1)
|
love.graphics.setColor(1, 1, 1, 1)
|
||||||
love.graphics.draw(img, 8 - padL * 2 + slide + sx,
|
local dx, dy = BattleState.backPlacement(img:getWidth(), img:getHeight(),
|
||||||
96 - (img:getHeight() - pad) * 2 + sy, 0, 2, 2)
|
pad, padL, s)
|
||||||
|
love.graphics.draw(img, dx + slide + sx, dy + sy, 0, s, s)
|
||||||
elseif self.player and self.player.sprite and not hidePlayer
|
elseif self.player and self.player.sprite and not hidePlayer
|
||||||
and not self.sendingOut and not self:fxHidden(self.player) then
|
and not self.sendingOut and not self:fxHidden(self.player) then
|
||||||
local img = self:picImage(self.player.sprite)
|
local img = self:picImage(self.player.sprite)
|
||||||
@@ -3916,19 +4186,24 @@ function BattleState:drawPicsLayer(slide, sx, sy)
|
|||||||
-- feet flush on the text box top (y=96), ignoring baked-in padding
|
-- feet flush on the text box top (y=96), ignoring baked-in padding
|
||||||
local pad = imagePadBottom[self.player.sprite] or 0
|
local pad = imagePadBottom[self.player.sprite] or 0
|
||||||
local padL = imagePadLeft[self.player.sprite] or 0
|
local padL = imagePadLeft[self.player.sprite] or 0
|
||||||
local px = 8 - padL * 2 + sx
|
local s = BattleState.resolveBattleScale(self.data, "back",
|
||||||
|
imagePathOf(self.player.sprite),
|
||||||
|
self.player.mon and self.player.mon.species)
|
||||||
local gs = self:growInScale(self.player)
|
local gs = self:growInScale(self.player)
|
||||||
if gs then
|
if gs then
|
||||||
-- the player-side AnimateSendingOutMon grow (after the poof,
|
-- the player-side AnimateSendingOutMon grow (after the poof,
|
||||||
-- core.asm:1757-1762): feet pinned at y=96, center at x=8+w
|
-- core.asm:1757-1762): feet pinned at y=96, horizontal centre
|
||||||
if gs > 0 then
|
-- pinned, mod scale composed with the grow stage
|
||||||
love.graphics.draw(img, px + img:getWidth() * (1 - gs),
|
local eff = s * gs
|
||||||
96 - (img:getHeight() - pad) * 2 * gs + sy,
|
if eff > 0 then
|
||||||
0, 2 * gs, 2 * gs)
|
love.graphics.draw(img,
|
||||||
|
8 - padL * s + img:getWidth() * s * (1 - gs) / 2 + sx,
|
||||||
|
96 - (img:getHeight() - pad) * eff + sy, 0, eff, eff)
|
||||||
end
|
end
|
||||||
else
|
else
|
||||||
self:drawBattlerPic(self.player, px,
|
local dx, dy = BattleState.backPlacement(img:getWidth(),
|
||||||
96 - (img:getHeight() - pad) * 2 + sy, 2)
|
img:getHeight(), pad, padL, s)
|
||||||
|
self:drawBattlerPic(self.player, dx + sx, dy + sy, s)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
if clipped then
|
if clipped then
|
||||||
@@ -3946,11 +4221,19 @@ function BattleState:drawHUDs(slide)
|
|||||||
-- the HUD clears with the send-out text (ClearScreenArea,
|
-- the HUD clears with the send-out text (ClearScreenArea,
|
||||||
-- core.asm:1414-1417) and DrawEnemyHUDAndHPBar (1435) only redraws
|
-- core.asm:1414-1417) and DrawEnemyHUDAndHPBar (1435) only redraws
|
||||||
-- it after the grow-in + cry
|
-- it after the grow-in + cry
|
||||||
local barData = self:colorMode() and {} or self.data -- gray fill when zoned
|
-- In colorized modes the zone pass (drawZonePass over BATTLE_ZONES pal 0/1)
|
||||||
|
-- recolors the bar's DMG gray fill by region, so drawHPBar must skip its
|
||||||
|
-- per-pixel tint (grayFill) -- otherwise GREENBAR's red-channel-0 fill
|
||||||
|
-- double-applies and the zone shade shader maps the whole bar to black (#229).
|
||||||
|
local grayFill = self:colorMode()
|
||||||
|
local barData = self.data
|
||||||
local fx = self.fx
|
local fx = self.fx
|
||||||
local hudShake = (fx and fx.hudShakeX) or 0
|
local hudShake = (fx and fx.hudShakeX) or 0
|
||||||
|
-- FaintEnemyPokemon clears the enemy HUD area; it stays blank through
|
||||||
|
-- TrainerAboutToUseText until DrawEnemyHUDAndHPBar after the next send-out
|
||||||
if self.enemy and not self.showEnemyTrainer and not self.enemySendingOut
|
if self.enemy and not self.showEnemyTrainer and not self.enemySendingOut
|
||||||
and not self:growInScale(self.enemy) and slide == 0 then
|
and not self:growInScale(self.enemy) and slide == 0
|
||||||
|
and not self.enemy.fainted then
|
||||||
-- enemy HUD (DrawEnemyHUDAndHPBar): name row 0, <LV>+level (4,1),
|
-- enemy HUD (DrawEnemyHUDAndHPBar): name row 0, <LV>+level (4,1),
|
||||||
-- HP bar (2,2) with the vertical tick at (1,2), underline row 3;
|
-- HP bar (2,2) with the vertical tick at (1,2), underline row 3;
|
||||||
-- AnimationShakeEnemyHUD nudges just this block via SCX
|
-- AnimationShakeEnemyHUD nudges just this block via SCX
|
||||||
@@ -3960,15 +4243,16 @@ function BattleState:drawHUDs(slide)
|
|||||||
end
|
end
|
||||||
love.graphics.setColor(0, 0, 0, 1)
|
love.graphics.setColor(0, 0, 0, 1)
|
||||||
Font.draw(self.enemy.name, nameX(1, self.enemy.name), 0)
|
Font.draw(self.enemy.name, nameX(1, self.enemy.name), 0)
|
||||||
if self.enemy.mon.status then
|
if self.enemy.shownStatus then
|
||||||
Font.draw(self:statusLabel(self.enemy.mon), 40, 8)
|
Font.draw(self:statusLabel({ status = self.enemy.shownStatus }), 40, 8)
|
||||||
else
|
else
|
||||||
hudTile(0x6E, 32, 8) -- <LV>
|
hudTile(0x6E, 32, 8) -- <LV>
|
||||||
Font.draw(tostring(self.enemy.mon.level), 40, 8)
|
Font.draw(tostring(self.enemy.mon.level), 40, 8)
|
||||||
end
|
end
|
||||||
hudTile(0x73, 8, 16)
|
hudTile(0x73, 8, 16)
|
||||||
drawHPBar(barData, 2, 2,
|
drawHPBar(barData, 2, 2,
|
||||||
{ hp = shownHP(self.enemy), stats = self.enemy.mon.stats })
|
{ hp = shownHP(self.enemy), stats = self.enemy.mon.stats },
|
||||||
|
nil, grayFill)
|
||||||
hudTile(0x74, 8, 24)
|
hudTile(0x74, 8, 24)
|
||||||
for i = 2, 9 do hudTile(0x76, i * 8, 24) end
|
for i = 2, 9 do hudTile(0x76, i * 8, 24) end
|
||||||
hudTile(0x78, 80, 24)
|
hudTile(0x78, 80, 24)
|
||||||
@@ -4008,15 +4292,15 @@ function BattleState:drawHUDs(slide)
|
|||||||
-- the tick at (18,10) and the triangle at (9,11)
|
-- the tick at (18,10) and the triangle at (9,11)
|
||||||
love.graphics.setColor(0, 0, 0, 1)
|
love.graphics.setColor(0, 0, 0, 1)
|
||||||
Font.draw(self.player.name, nameX(10, self.player.name), 56)
|
Font.draw(self.player.name, nameX(10, self.player.name), 56)
|
||||||
if self.player.mon.status then
|
if self.player.shownStatus then
|
||||||
Font.draw(self:statusLabel(self.player.mon), 120, 64)
|
Font.draw(self:statusLabel({ status = self.player.shownStatus }), 120, 64)
|
||||||
else
|
else
|
||||||
hudTile(0x6E, 112, 64) -- <LV>
|
hudTile(0x6E, 112, 64) -- <LV>
|
||||||
Font.draw(tostring(self.player.mon.level), 120, 64)
|
Font.draw(tostring(self.player.mon.level), 120, 64)
|
||||||
end
|
end
|
||||||
drawHPBar(barData, 10, 9,
|
drawHPBar(barData, 10, 9,
|
||||||
{ hp = shownHP(self.player), stats = self.player.mon.stats },
|
{ hp = shownHP(self.player), stats = self.player.mon.stats },
|
||||||
1) -- wHPBarType 1: the $6D cap
|
1, grayFill) -- wHPBarType 1: the $6D cap
|
||||||
Font.draw(("%3d/%3d"):format(shownHP(self.player), self.player.mon.stats.hp), 88, 80)
|
Font.draw(("%3d/%3d"):format(shownHP(self.player), self.player.mon.stats.hp), 88, 80)
|
||||||
hudTile(0x73, 144, 80)
|
hudTile(0x73, 144, 80)
|
||||||
hudTile(0x77, 144, 88)
|
hudTile(0x77, 144, 88)
|
||||||
@@ -4029,16 +4313,27 @@ function BattleState:drawTextArea()
|
|||||||
Font.drawBox(0, 12, 20, 6)
|
Font.drawBox(0, 12, 20, 6)
|
||||||
love.graphics.setColor(0, 0, 0, 1)
|
love.graphics.setColor(0, 0, 0, 1)
|
||||||
if self.phase == "messages" and self.current then
|
if self.phase == "messages" and self.current then
|
||||||
local shown = 0
|
-- rolling 2-line window: shown[1] at row y=112, shown[2] at y=128 (battle
|
||||||
for li, codes in ipairs(self.lines) do
|
-- text uses every other tile row, hlcoord *,14 / *,16). scrollPx animates
|
||||||
-- battle text uses every other tile row (hlcoord *,14 / *,16)
|
-- the lines up one row (ScrollTextUpOneLine) so a 3rd line scrolls into
|
||||||
local y = 112 + (li - 1) * 16
|
-- view instead of drawing off-screen at y=144 (#216).
|
||||||
for i = 1, #codes do
|
if self.scrollPx and self.scrollPx > 0 then
|
||||||
if shown >= self.charIndex then break end
|
self.scrollPx = self.scrollPx - 2
|
||||||
Font.drawCode(codes[i], 8 + (i - 1) * 8, y)
|
if self.scrollPx <= 0 then self.scrollPx = nil end
|
||||||
shown = shown + 1
|
end
|
||||||
|
local off = self.scrollPx or 0
|
||||||
|
local ys = { 112, 128 }
|
||||||
|
for li, line in ipairs(self.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
|
||||||
end
|
end
|
||||||
|
-- the blinking down arrow ('▼', glyph $EE) while a \v CONT wait holds the
|
||||||
|
-- box, bottom-right of the box like TextBox / home/text.asm
|
||||||
|
if self.msgWaiting and self.frame % 60 < 30 then
|
||||||
|
Font.drawCode(0xEE, (0 + 20 - 2) * 8, (12 + 6 - 1) * 8 - 4)
|
||||||
|
end
|
||||||
elseif self.phase == "menu" and self.demo then
|
elseif self.phase == "menu" and self.demo then
|
||||||
-- the old-man script (DisplayBattleMenu, core.asm:2038-2049): the
|
-- the old-man script (DisplayBattleMenu, core.asm:2038-2049): the
|
||||||
-- standard menu, with the '▶' hand drawn by the scripted keystrokes
|
-- standard menu, with the '▶' hand drawn by the scripted keystrokes
|
||||||
@@ -4115,6 +4410,13 @@ function BattleState:drawTextArea()
|
|||||||
end
|
end
|
||||||
|
|
||||||
function BattleState:draw()
|
function BattleState:draw()
|
||||||
|
-- AskName: ClearSprites + wild ClearScreenArea — white field under the
|
||||||
|
-- nickname TextBox / YES/NO (naming_screen.asm); overlays draw on top.
|
||||||
|
if self.blankForAskName then
|
||||||
|
love.graphics.setColor(1, 1, 1, 1)
|
||||||
|
love.graphics.rectangle("fill", 0, 0, 160, 144)
|
||||||
|
return
|
||||||
|
end
|
||||||
local fx = self.fx
|
local fx = self.fx
|
||||||
-- window shakes (SE_SHAKE_SCREEN / the enemy-hit vertical shake);
|
-- window shakes (SE_SHAKE_SCREEN / the enemy-hit vertical shake);
|
||||||
-- the animations-off fallback keeps the old +-2 alternation
|
-- the animations-off fallback keeps the old +-2 alternation
|
||||||
@@ -4182,6 +4484,11 @@ function BattleState:draw()
|
|||||||
love.graphics.rectangle("fill", 0, 0, 160, 144)
|
love.graphics.rectangle("fill", 0, 0, 160, 144)
|
||||||
end
|
end
|
||||||
love.graphics.setColor(1, 1, 1, 1)
|
love.graphics.setColor(1, 1, 1, 1)
|
||||||
|
-- battle.overlay: shiny sparkles, custom HUD chrome, etc. Draw-only;
|
||||||
|
-- the vanilla link is a no-op so an empty chain costs nothing.
|
||||||
|
if Runtime.wantsHook("battle.overlay") then
|
||||||
|
Runtime.call("battle.overlay", function() end, self)
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
return BattleState
|
return BattleState
|
||||||
|
|||||||
@@ -581,8 +581,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,
|
||||||
|
|||||||
@@ -16,4 +16,7 @@ return {
|
|||||||
-- Wild/trainer enemies never spend PP (DecrementPP only touches the
|
-- Wild/trainer enemies never spend PP (DecrementPP only touches the
|
||||||
-- player side in pokered). They therefore never Struggle from empty PP.
|
-- player side in pokered). They therefore never Struggle from empty PP.
|
||||||
enemyUnlimitedPP = true,
|
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,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,4 +11,6 @@ return {
|
|||||||
focusEnergyBug = false,
|
focusEnergyBug = false,
|
||||||
-- Gen 2+ style: AI opponents deplete PP and can Struggle when empty.
|
-- Gen 2+ style: AI opponents deplete PP and can Struggle when empty.
|
||||||
enemyUnlimitedPP = false,
|
enemyUnlimitedPP = false,
|
||||||
|
-- Gen 2+: Hyper Beam always forces a recharge turn, even on a KO.
|
||||||
|
hyperBeamSkipRechargeOnKO = false,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -38,8 +38,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 +388,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)
|
||||||
@@ -427,7 +446,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 +455,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)
|
||||||
@@ -567,6 +601,96 @@ local function subscribe(game)
|
|||||||
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 +703,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 +747,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 +776,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
|
||||||
|
|||||||
@@ -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()
|
||||||
@@ -93,6 +96,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
|
||||||
@@ -177,9 +194,6 @@ function Game:logicSpeed()
|
|||||||
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 +212,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
|
||||||
@@ -240,6 +257,8 @@ 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
|
||||||
@@ -341,6 +360,17 @@ function Game:keypressed(key)
|
|||||||
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
|
||||||
|
|
||||||
@@ -358,6 +388,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
|
||||||
@@ -372,6 +405,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
|
||||||
|
|
||||||
@@ -381,12 +416,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
|
||||||
@@ -394,19 +429,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
|
||||||
@@ -457,6 +492,9 @@ 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)
|
||||||
|
-- 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.Zoom").applyOptions(opts)
|
||||||
require("src.render.TileRenderer").applyOptions(opts)
|
require("src.render.TileRenderer").applyOptions(opts)
|
||||||
-- returns true when a persisted GBC FX level was cleared on mobile
|
-- returns true when a persisted GBC FX level was cleared on mobile
|
||||||
|
|||||||
@@ -154,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
|
||||||
@@ -408,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.
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ 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 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 +32,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 +93,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
|
||||||
|
|
||||||
@@ -198,6 +212,11 @@ function SaveData.defaultOptions()
|
|||||||
videoMode = "windowed",
|
videoMode = "windowed",
|
||||||
-- hard render frame-rate cap; render-only pacing (issue #88, FrameCap.lua)
|
-- hard render frame-rate cap; render-only pacing (issue #88, FrameCap.lua)
|
||||||
fpsCap = 60,
|
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 = {},
|
||||||
@@ -283,6 +302,302 @@ 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
|
||||||
|
|
||||||
|
-- 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 }
|
||||||
|
end
|
||||||
|
return out
|
||||||
|
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.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
|
||||||
@@ -525,6 +840,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
|
||||||
|
|||||||
@@ -0,0 +1,287 @@
|
|||||||
|
-- On-screen touch controls: a visible d-pad, A, B, START and SELECT drawn
|
||||||
|
-- over the finished frame (art: Xelu's CC0 controller prompts, see
|
||||||
|
-- assets/touch/README.md). Replaces the old touch gesture recognizer:
|
||||||
|
-- every control is a real button under the thumb, so there is no
|
||||||
|
-- tap-vs-swipe classification, no deferred-A double-tap window, and no
|
||||||
|
-- added latency.
|
||||||
|
--
|
||||||
|
-- Mobile only, and only while no controller is being used: the overlay
|
||||||
|
-- shows on Android/iOS, disappears the moment a gamepad button or stick
|
||||||
|
-- is used, and comes back on the next screen touch (Game routes both
|
||||||
|
-- events here). POKEPORT_TOUCH=1 forces it on for desktop testing
|
||||||
|
-- (main.lua then drives it with the mouse); POKEPORT_TOUCH=0 forces it
|
||||||
|
-- off everywhere.
|
||||||
|
--
|
||||||
|
-- Controls press GB buttons through Input:overlayPressed/Released -- their
|
||||||
|
-- own input source, not a keyboard alias -- so a held overlay direction
|
||||||
|
-- merges cleanly with a keyboard key or stick holding the same button,
|
||||||
|
-- and a player rebind can never detach the overlay.
|
||||||
|
|
||||||
|
local Input = require("src.core.Input")
|
||||||
|
|
||||||
|
local TouchControls = {}
|
||||||
|
|
||||||
|
-- idle vs pressed overlay opacity
|
||||||
|
local ALPHA = 0.65
|
||||||
|
local ALPHA_PRESSED = 0.95
|
||||||
|
-- translucent backing disc behind each control: the prompt art is dark
|
||||||
|
-- gray, so without it the controls melt into dark map areas
|
||||||
|
local BACK = 0.24
|
||||||
|
local BACK_PRESSED = 0.38
|
||||||
|
|
||||||
|
-- neutral zone at the d-pad center, as a fraction of the d-pad width;
|
||||||
|
-- inside it no direction is held (keeps a resting thumb from jittering)
|
||||||
|
local DPAD_DEAD = 0.16
|
||||||
|
|
||||||
|
-- hit slop: how far past the visible edge a press still counts, as a
|
||||||
|
-- multiplier on the control's half-width. START/SELECT get more because
|
||||||
|
-- the glyphs are small.
|
||||||
|
local SLOP = { a = 1.3, b = 1.3, start = 1.4, select = 1.4 }
|
||||||
|
|
||||||
|
local BUTTONS = { "a", "b", "start", "select" }
|
||||||
|
|
||||||
|
local IMAGES = {
|
||||||
|
dpad = "assets/touch/dpad.png",
|
||||||
|
dpad_up = "assets/touch/dpad_up.png",
|
||||||
|
dpad_down = "assets/touch/dpad_down.png",
|
||||||
|
dpad_left = "assets/touch/dpad_left.png",
|
||||||
|
dpad_right = "assets/touch/dpad_right.png",
|
||||||
|
a = "assets/touch/a.png",
|
||||||
|
b = "assets/touch/b.png",
|
||||||
|
start = "assets/touch/start.png",
|
||||||
|
select = "assets/touch/select.png",
|
||||||
|
}
|
||||||
|
|
||||||
|
local function wantsOverlay()
|
||||||
|
local env = os.getenv("POKEPORT_TOUCH")
|
||||||
|
if env == "1" then return true end
|
||||||
|
if env == "0" then return false end
|
||||||
|
local osName = love.system and love.system.getOS and love.system.getOS()
|
||||||
|
return osName == "Android" or osName == "iOS"
|
||||||
|
end
|
||||||
|
|
||||||
|
function TouchControls:init()
|
||||||
|
self.active = wantsOverlay()
|
||||||
|
self.controllerHidden = false
|
||||||
|
self.touches = {}
|
||||||
|
-- per-GB-button owner count: two fingers on A must not double-press it,
|
||||||
|
-- and lifting one of them must not release the other's hold
|
||||||
|
self.held = {}
|
||||||
|
self.dpadTouch = nil
|
||||||
|
self.layoutW, self.layoutH = nil, nil
|
||||||
|
self.img = nil
|
||||||
|
if not self.active then return end
|
||||||
|
-- soft-fail: a missing/corrupt PNG must never block boot; the overlay
|
||||||
|
-- stays off and keyboard/controller play still works
|
||||||
|
local img = {}
|
||||||
|
for name, path in pairs(IMAGES) do
|
||||||
|
local ok, im = pcall(love.graphics.newImage, path)
|
||||||
|
if not ok then
|
||||||
|
img = nil
|
||||||
|
break
|
||||||
|
end
|
||||||
|
-- smooth UI icons; the global default filter is nearest for GB pixels
|
||||||
|
im:setFilter("linear", "linear")
|
||||||
|
img[name] = im
|
||||||
|
end
|
||||||
|
self.img = img
|
||||||
|
end
|
||||||
|
|
||||||
|
function TouchControls:visible()
|
||||||
|
return self.active and self.img ~= nil and not self.controllerHidden
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Layout in LOVE units (density-independent on mobile), recomputed when
|
||||||
|
-- the window size changes (rotation, resize). D-pad bottom-left, B/A
|
||||||
|
-- bottom-right with A above B (the Game Boy diagonal), START/SELECT
|
||||||
|
-- flanking the bottom center.
|
||||||
|
function TouchControls:layout()
|
||||||
|
local ww, wh = love.graphics.getDimensions()
|
||||||
|
if self.layoutW == ww and self.layoutH == wh then return self.L end
|
||||||
|
self.layoutW, self.layoutH = ww, wh
|
||||||
|
local short = math.min(ww, wh)
|
||||||
|
-- ~a third of the short edge, capped so tablets don't get a dinner plate
|
||||||
|
local dpadW = math.min(180, short * 0.34)
|
||||||
|
local abW = dpadW * 0.46
|
||||||
|
local ssW = dpadW * 0.30
|
||||||
|
local margin = dpadW * 0.12
|
||||||
|
-- START/SELECT hug the bottom center: on a narrow portrait phone the
|
||||||
|
-- d-pad and B leave little room, so a tight pair is what keeps them off
|
||||||
|
-- the neighboring controls
|
||||||
|
self.L = {
|
||||||
|
dpad = { cx = margin + dpadW / 2, cy = wh - margin - dpadW / 2, w = dpadW },
|
||||||
|
a = { cx = ww - margin - abW * 0.55, cy = wh - margin - abW * 1.75, w = abW },
|
||||||
|
b = { cx = ww - margin - abW * 1.60, cy = wh - margin - abW * 0.55, w = abW },
|
||||||
|
start = { cx = ww / 2 + ssW * 0.60, cy = wh - margin - ssW * 0.95, w = ssW },
|
||||||
|
select = { cx = ww / 2 - ssW * 0.60, cy = wh - margin - ssW * 0.95, w = ssW },
|
||||||
|
}
|
||||||
|
local fontSize = math.max(8, math.floor(ssW * 0.26))
|
||||||
|
if not self.labelFont or self.fontSize ~= fontSize then
|
||||||
|
self.fontSize = fontSize
|
||||||
|
self.labelFont = love.graphics.newFont(fontSize)
|
||||||
|
end
|
||||||
|
return self.L
|
||||||
|
end
|
||||||
|
|
||||||
|
local function inCircle(zone, x, y, slop)
|
||||||
|
local r = zone.w * 0.5 * slop
|
||||||
|
local dx, dy = x - zone.cx, y - zone.cy
|
||||||
|
return dx * dx + dy * dy <= r * r
|
||||||
|
end
|
||||||
|
|
||||||
|
local function dpadDir(zone, x, y)
|
||||||
|
local dx, dy = x - zone.cx, y - zone.cy
|
||||||
|
local dead = zone.w * DPAD_DEAD
|
||||||
|
if math.abs(dx) < dead and math.abs(dy) < dead then return nil end
|
||||||
|
if math.abs(dx) >= math.abs(dy) then
|
||||||
|
return dx > 0 and "right" or "left"
|
||||||
|
end
|
||||||
|
return dy > 0 and "down" or "up"
|
||||||
|
end
|
||||||
|
|
||||||
|
local function pressBtn(self, btn)
|
||||||
|
local n = (self.held[btn] or 0) + 1
|
||||||
|
self.held[btn] = n
|
||||||
|
if n == 1 then Input:overlayPressed(btn) end
|
||||||
|
end
|
||||||
|
|
||||||
|
local function releaseBtn(self, btn)
|
||||||
|
local n = self.held[btn]
|
||||||
|
if not n then return end
|
||||||
|
if n > 1 then
|
||||||
|
self.held[btn] = n - 1
|
||||||
|
else
|
||||||
|
self.held[btn] = nil
|
||||||
|
Input:overlayReleased(btn)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
-- the d-pad touch's held direction changed (or ended): swap the GB hold
|
||||||
|
local function setDpad(self, touch, dir)
|
||||||
|
if touch.dir == dir then return end
|
||||||
|
if touch.dir then releaseBtn(self, touch.dir) end
|
||||||
|
touch.dir = dir
|
||||||
|
if dir then pressBtn(self, dir) end
|
||||||
|
end
|
||||||
|
|
||||||
|
function TouchControls:touchpressed(id, x, y)
|
||||||
|
if not (self.active and self.img) then return end
|
||||||
|
-- a controller hid the overlay; the first touch only brings it back
|
||||||
|
if self.controllerHidden then
|
||||||
|
self.controllerHidden = false
|
||||||
|
return
|
||||||
|
end
|
||||||
|
local L = self:layout()
|
||||||
|
for _, btn in ipairs(BUTTONS) do
|
||||||
|
if inCircle(L[btn], x, y, SLOP[btn]) then
|
||||||
|
self.touches[id] = { control = btn }
|
||||||
|
pressBtn(self, btn)
|
||||||
|
return
|
||||||
|
end
|
||||||
|
end
|
||||||
|
-- square hit zone a bit past the cross art; one owning finger at a time
|
||||||
|
local dz = L.dpad
|
||||||
|
local half = dz.w * 0.65
|
||||||
|
if not self.dpadTouch
|
||||||
|
and math.abs(x - dz.cx) <= half and math.abs(y - dz.cy) <= half then
|
||||||
|
self.dpadTouch = id
|
||||||
|
local touch = { control = "dpad", dir = nil }
|
||||||
|
self.touches[id] = touch
|
||||||
|
setDpad(self, touch, dpadDir(dz, x, y))
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
function TouchControls:touchmoved(id, x, y)
|
||||||
|
local touch = self.touches[id]
|
||||||
|
-- only the d-pad tracks movement (slide between directions without
|
||||||
|
-- lifting); buttons hold until release wherever the finger wanders
|
||||||
|
if not touch or touch.control ~= "dpad" then return end
|
||||||
|
setDpad(self, touch, dpadDir(self:layout().dpad, x, y))
|
||||||
|
end
|
||||||
|
|
||||||
|
function TouchControls:touchreleased(id, x, y)
|
||||||
|
local touch = self.touches[id]
|
||||||
|
if not touch then return end
|
||||||
|
self.touches[id] = nil
|
||||||
|
if touch.control == "dpad" then
|
||||||
|
setDpad(self, touch, nil)
|
||||||
|
self.dpadTouch = nil
|
||||||
|
else
|
||||||
|
releaseBtn(self, touch.control)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
-- LÖVE has no touchcancelled: a touch interrupted by the OS (app
|
||||||
|
-- backgrounded, a system gesture stealing the finger) never fires
|
||||||
|
-- touchreleased and would strand its button held forever. Called from
|
||||||
|
-- Game alongside Input:reset() on focus/visibility loss.
|
||||||
|
function TouchControls:reset()
|
||||||
|
for btn in pairs(self.held) do
|
||||||
|
Input:overlayReleased(btn)
|
||||||
|
end
|
||||||
|
self.held = {}
|
||||||
|
self.touches = {}
|
||||||
|
self.dpadTouch = nil
|
||||||
|
end
|
||||||
|
|
||||||
|
-- a gamepad is being used: hide the overlay (dropping anything it held)
|
||||||
|
-- until the next screen touch asks for it back
|
||||||
|
function TouchControls:noteGamepad()
|
||||||
|
if not self.active or self.controllerHidden then return end
|
||||||
|
self.controllerHidden = true
|
||||||
|
self:reset()
|
||||||
|
end
|
||||||
|
|
||||||
|
-- last controller unplugged: show the overlay again immediately instead
|
||||||
|
-- of requiring a blind first tap
|
||||||
|
function TouchControls:joystickremoved()
|
||||||
|
self:reset()
|
||||||
|
if love.joystick and love.joystick.getJoystickCount
|
||||||
|
and love.joystick.getJoystickCount() == 0 then
|
||||||
|
self.controllerHidden = false
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
local function drawIcon(img, zone, pressed)
|
||||||
|
love.graphics.setColor(1, 1, 1, pressed and BACK_PRESSED or BACK)
|
||||||
|
love.graphics.circle("fill", zone.cx, zone.cy, zone.w * 0.58)
|
||||||
|
local scale = zone.w / img:getWidth()
|
||||||
|
love.graphics.setColor(1, 1, 1, pressed and ALPHA_PRESSED or ALPHA)
|
||||||
|
love.graphics.draw(img, zone.cx - zone.w / 2,
|
||||||
|
zone.cy - img:getHeight() * scale / 2, 0, scale, scale)
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Screen-space, called by Game:draw after Renderer:endFrame so the
|
||||||
|
-- overlay rides on top of everything (world, UI, CRT/GBC FX included).
|
||||||
|
function TouchControls:draw()
|
||||||
|
if not self:visible() then return end
|
||||||
|
local L = self:layout()
|
||||||
|
love.graphics.push("all")
|
||||||
|
love.graphics.origin()
|
||||||
|
|
||||||
|
local dpadTouch = self.dpadTouch and self.touches[self.dpadTouch]
|
||||||
|
local dir = dpadTouch and dpadTouch.dir
|
||||||
|
drawIcon(dir and self.img["dpad_" .. dir] or self.img.dpad, L.dpad,
|
||||||
|
dir ~= nil)
|
||||||
|
for _, btn in ipairs(BUTTONS) do
|
||||||
|
drawIcon(self.img[btn], L[btn], self.held[btn] ~= nil)
|
||||||
|
end
|
||||||
|
|
||||||
|
-- the +/- glyphs alone don't say which is which; shadowed so the text
|
||||||
|
-- reads on both the black letterbox and battle's white one
|
||||||
|
love.graphics.setFont(self.labelFont)
|
||||||
|
local ly = L.start.cy + L.start.w * 0.66
|
||||||
|
local function label(text, zone)
|
||||||
|
local w = self.labelFont:getWidth(text)
|
||||||
|
love.graphics.setColor(0, 0, 0, 0.6)
|
||||||
|
love.graphics.print(text, zone.cx - w / 2 + 1, ly + 1)
|
||||||
|
love.graphics.setColor(1, 1, 1, ALPHA + 0.2)
|
||||||
|
love.graphics.print(text, zone.cx - w / 2, ly)
|
||||||
|
end
|
||||||
|
label("START", L.start)
|
||||||
|
label("SELECT", L.select)
|
||||||
|
|
||||||
|
love.graphics.pop()
|
||||||
|
end
|
||||||
|
|
||||||
|
return TouchControls
|
||||||
@@ -1,297 +0,0 @@
|
|||||||
-- Touch gesture recognizer → virtual keyboard keys for Input.lua.
|
|
||||||
--
|
|
||||||
-- Deferred-tap tradeoff: A fires only after DOUBLE_TAP_MS with no second
|
|
||||||
-- tap. That adds ~280ms latency to every A press so a double-tap can be
|
|
||||||
-- remapped to START instead of A-then-START. Gen 1 has no frame-perfect
|
|
||||||
-- input needs, so the latency is acceptable.
|
|
||||||
--
|
|
||||||
-- Select = two-finger tap (open Q1 in docs/mobile-plan.md): when a second distinct
|
|
||||||
-- touch ID lands while another short, low-movement touch is active, fire
|
|
||||||
-- SELECT (press + one-frame auto-release).
|
|
||||||
|
|
||||||
local Input = require("src.core.Input")
|
|
||||||
|
|
||||||
local TouchInput = {}
|
|
||||||
|
|
||||||
local function dpiScale()
|
|
||||||
if love and love.window then
|
|
||||||
if love.window.getDPIScale then
|
|
||||||
return love.window.getDPIScale()
|
|
||||||
end
|
|
||||||
if love.window.toPixels then
|
|
||||||
return love.window.toPixels(1)
|
|
||||||
end
|
|
||||||
end
|
|
||||||
return 1
|
|
||||||
end
|
|
||||||
|
|
||||||
-- Tunables (device-DPI-scaled where noted). Adjust after on-device testing.
|
|
||||||
local SWIPE_THRESHOLD_PX = 14
|
|
||||||
local EDGE_PX = 24
|
|
||||||
local EDGE_SWIPE_PX = 24
|
|
||||||
local DOUBLE_TAP_MS = 280
|
|
||||||
local TAP_MAX_MS = 320
|
|
||||||
local TAP_MAX_MOVE_PX = 12
|
|
||||||
|
|
||||||
local function scaled(px)
|
|
||||||
return px * dpiScale()
|
|
||||||
end
|
|
||||||
|
|
||||||
local DIRS = { up = true, down = true, left = true, right = true }
|
|
||||||
|
|
||||||
-- Virtual keys Input:keypressed looks up in KEYBOARD bindings (not button names).
|
|
||||||
local KEY = {
|
|
||||||
up = "up",
|
|
||||||
down = "down",
|
|
||||||
left = "left",
|
|
||||||
right = "right",
|
|
||||||
a = "z",
|
|
||||||
b = "x",
|
|
||||||
start = "escape",
|
|
||||||
select = "tab",
|
|
||||||
}
|
|
||||||
|
|
||||||
local function nowMs()
|
|
||||||
return love.timer.getTime() * 1000
|
|
||||||
end
|
|
||||||
|
|
||||||
local function dominantDir(dx, dy)
|
|
||||||
if math.abs(dx) >= math.abs(dy) then
|
|
||||||
return dx > 0 and "right" or "left"
|
|
||||||
end
|
|
||||||
return dy > 0 and "down" or "up"
|
|
||||||
end
|
|
||||||
|
|
||||||
function TouchInput:init()
|
|
||||||
self.touches = {}
|
|
||||||
self.pendingA = nil -- { deadlineMs = number }
|
|
||||||
-- Edge pulses (B / START / SELECT / deferred-A): press now, release on a
|
|
||||||
-- later update so FixedStep can consume wasPressed first.
|
|
||||||
-- `armed` = pressed during events since last update; promoted to
|
|
||||||
-- `autoRelease` at the start of update (released on the *following* update).
|
|
||||||
-- Deferred-A fired inside update goes straight into `autoRelease`.
|
|
||||||
self.armed = {}
|
|
||||||
self.autoRelease = {}
|
|
||||||
self.selectFired = false -- one SELECT per two-finger gesture cluster
|
|
||||||
end
|
|
||||||
|
|
||||||
-- LÖVE has no touchcancelled event, so a touch interrupted by the OS (app
|
|
||||||
-- backgrounded mid-touch, a system gesture stealing the finger) never fires
|
|
||||||
-- touchreleased and would otherwise strand its direction held forever.
|
|
||||||
-- Called from Game alongside Input:reset() on focus/visibility loss.
|
|
||||||
function TouchInput:reset()
|
|
||||||
for _, touch in pairs(self.touches) do
|
|
||||||
releaseDir(self, touch)
|
|
||||||
end
|
|
||||||
self.touches = {}
|
|
||||||
self.pendingA = nil
|
|
||||||
self.armed = {}
|
|
||||||
self.autoRelease = {}
|
|
||||||
self.selectFired = false
|
|
||||||
end
|
|
||||||
|
|
||||||
local function pulse(self, key)
|
|
||||||
Input:keypressed(key)
|
|
||||||
self.armed[#self.armed + 1] = key
|
|
||||||
end
|
|
||||||
|
|
||||||
local function pulseInUpdate(self, key)
|
|
||||||
Input:keypressed(key)
|
|
||||||
self.autoRelease[#self.autoRelease + 1] = key
|
|
||||||
end
|
|
||||||
|
|
||||||
local function releaseDir(self, touch)
|
|
||||||
if touch.dir and DIRS[touch.dir] then
|
|
||||||
Input:keyreleased(KEY[touch.dir])
|
|
||||||
touch.dir = nil
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
local function pressDir(self, touch, dir)
|
|
||||||
if touch.dir == dir then return end
|
|
||||||
releaseDir(self, touch)
|
|
||||||
touch.dir = dir
|
|
||||||
Input:keypressed(KEY[dir])
|
|
||||||
end
|
|
||||||
|
|
||||||
local function totalMove(touch, x, y)
|
|
||||||
local dx = x - touch.x0
|
|
||||||
local dy = y - touch.y0
|
|
||||||
return math.abs(dx), math.abs(dy), dx, dy
|
|
||||||
end
|
|
||||||
|
|
||||||
local function isTapLike(touch, x, y, tMs)
|
|
||||||
local ax, ay = totalMove(touch, x, y)
|
|
||||||
local elapsed = tMs - touch.t0
|
|
||||||
return elapsed <= TAP_MAX_MS
|
|
||||||
and ax <= scaled(TAP_MAX_MOVE_PX)
|
|
||||||
and ay <= scaled(TAP_MAX_MOVE_PX)
|
|
||||||
and not touch.classified
|
|
||||||
end
|
|
||||||
|
|
||||||
local function countActive(self)
|
|
||||||
local n = 0
|
|
||||||
for _ in pairs(self.touches) do n = n + 1 end
|
|
||||||
return n
|
|
||||||
end
|
|
||||||
|
|
||||||
local function tryTwoFingerSelect(self, tMs)
|
|
||||||
if self.selectFired then return false end
|
|
||||||
local ids = {}
|
|
||||||
for id, touch in pairs(self.touches) do
|
|
||||||
if isTapLike(touch, touch.x, touch.y, tMs) then
|
|
||||||
ids[#ids + 1] = id
|
|
||||||
end
|
|
||||||
end
|
|
||||||
if #ids < 2 then return false end
|
|
||||||
|
|
||||||
self.selectFired = true
|
|
||||||
self.pendingA = nil
|
|
||||||
for _, id in ipairs(ids) do
|
|
||||||
local touch = self.touches[id]
|
|
||||||
touch.classified = true
|
|
||||||
touch.consumed = true
|
|
||||||
releaseDir(self, touch)
|
|
||||||
end
|
|
||||||
pulse(self, KEY.select)
|
|
||||||
return true
|
|
||||||
end
|
|
||||||
|
|
||||||
function TouchInput:update(dt)
|
|
||||||
-- Releases armed on a prior update (FixedStep already saw wasPressed).
|
|
||||||
for i = 1, #self.autoRelease do
|
|
||||||
Input:keyreleased(self.autoRelease[i])
|
|
||||||
end
|
|
||||||
-- Promote event-phase pulses from since the last update; they release next time.
|
|
||||||
self.autoRelease = self.armed
|
|
||||||
self.armed = {}
|
|
||||||
|
|
||||||
local tMs = nowMs()
|
|
||||||
|
|
||||||
-- Deferred A: fire once the double-tap window closes with no second tap.
|
|
||||||
-- Queued into autoRelease so the next update clears hold after this FixedStep.
|
|
||||||
if self.pendingA and tMs >= self.pendingA.deadlineMs then
|
|
||||||
self.pendingA = nil
|
|
||||||
pulseInUpdate(self, KEY.a)
|
|
||||||
end
|
|
||||||
|
|
||||||
-- Keep two-finger SELECT detection live while both fingers stay down.
|
|
||||||
if countActive(self) >= 2 then
|
|
||||||
tryTwoFingerSelect(self, tMs)
|
|
||||||
elseif countActive(self) == 0 then
|
|
||||||
self.selectFired = false
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
function TouchInput:touchpressed(id, x, y)
|
|
||||||
local tMs = nowMs()
|
|
||||||
|
|
||||||
-- Second tap inside the deferred-A window → START instead of A.
|
|
||||||
if self.pendingA and tMs < self.pendingA.deadlineMs then
|
|
||||||
self.pendingA = nil
|
|
||||||
pulse(self, KEY.start)
|
|
||||||
-- Still record this touch so a lingering finger doesn't become a stray swipe.
|
|
||||||
self.touches[id] = {
|
|
||||||
x0 = x, y0 = y, x = x, y = y, t0 = tMs,
|
|
||||||
edge = x < scaled(EDGE_PX),
|
|
||||||
classified = true,
|
|
||||||
consumed = true,
|
|
||||||
dir = nil,
|
|
||||||
}
|
|
||||||
return
|
|
||||||
end
|
|
||||||
|
|
||||||
self.touches[id] = {
|
|
||||||
x0 = x, y0 = y, x = x, y = y, t0 = tMs,
|
|
||||||
edge = x < scaled(EDGE_PX),
|
|
||||||
classified = false,
|
|
||||||
consumed = false,
|
|
||||||
dir = nil,
|
|
||||||
}
|
|
||||||
|
|
||||||
if countActive(self) >= 2 then
|
|
||||||
tryTwoFingerSelect(self, tMs)
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
function TouchInput:touchmoved(id, x, y)
|
|
||||||
local touch = self.touches[id]
|
|
||||||
if not touch or touch.consumed then return end
|
|
||||||
|
|
||||||
touch.x, touch.y = x, y
|
|
||||||
local ax, ay, dx, dy = totalMove(touch, x, y)
|
|
||||||
local swipeTh = scaled(SWIPE_THRESHOLD_PX)
|
|
||||||
|
|
||||||
-- Edge-origin swipes become B on release; never promote to d-pad.
|
|
||||||
if touch.edge then
|
|
||||||
if ax >= scaled(EDGE_SWIPE_PX) or ay >= scaled(EDGE_SWIPE_PX) then
|
|
||||||
touch.classified = true
|
|
||||||
end
|
|
||||||
return
|
|
||||||
end
|
|
||||||
|
|
||||||
if not touch.classified then
|
|
||||||
if ax < swipeTh and ay < swipeTh then return end
|
|
||||||
touch.classified = true
|
|
||||||
pressDir(self, touch, dominantDir(dx, dy))
|
|
||||||
return
|
|
||||||
end
|
|
||||||
|
|
||||||
-- Mid-hold direction change: release old, press new (dominant axis).
|
|
||||||
if touch.dir then
|
|
||||||
local fromLastX = x - touch.x0
|
|
||||||
local fromLastY = y - touch.y0
|
|
||||||
-- Re-evaluate from origin so small jitter doesn't flip; require threshold
|
|
||||||
-- distance from origin along the new dominant axis.
|
|
||||||
if math.abs(fromLastX) >= swipeTh or math.abs(fromLastY) >= swipeTh then
|
|
||||||
local newDir = dominantDir(fromLastX, fromLastY)
|
|
||||||
if newDir ~= touch.dir then
|
|
||||||
pressDir(self, touch, newDir)
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
function TouchInput:touchreleased(id, x, y)
|
|
||||||
local touch = self.touches[id]
|
|
||||||
if not touch then return end
|
|
||||||
|
|
||||||
local tMs = nowMs()
|
|
||||||
touch.x, touch.y = x, y
|
|
||||||
local ax, ay = totalMove(touch, x, y)
|
|
||||||
|
|
||||||
if touch.dir then
|
|
||||||
releaseDir(self, touch)
|
|
||||||
self.touches[id] = nil
|
|
||||||
if countActive(self) == 0 then self.selectFired = false end
|
|
||||||
return
|
|
||||||
end
|
|
||||||
|
|
||||||
if touch.consumed then
|
|
||||||
self.touches[id] = nil
|
|
||||||
if countActive(self) == 0 then self.selectFired = false end
|
|
||||||
return
|
|
||||||
end
|
|
||||||
|
|
||||||
-- Left-edge B: origin in EDGE_PX strip and movement past EDGE_SWIPE_PX
|
|
||||||
-- (or already marked classified while moving). Prefer over d-pad / tap.
|
|
||||||
if touch.edge then
|
|
||||||
local edgeTh = scaled(EDGE_SWIPE_PX)
|
|
||||||
if touch.classified or ax >= edgeTh or ay >= edgeTh then
|
|
||||||
pulse(self, KEY.b)
|
|
||||||
self.touches[id] = nil
|
|
||||||
if countActive(self) == 0 then self.selectFired = false end
|
|
||||||
return
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
-- Plain tap → defer A (or it was already classified as swipe without dir, ignore).
|
|
||||||
if isTapLike(touch, x, y, tMs) then
|
|
||||||
self.pendingA = { deadlineMs = tMs + DOUBLE_TAP_MS }
|
|
||||||
end
|
|
||||||
|
|
||||||
self.touches[id] = nil
|
|
||||||
if countActive(self) == 0 then self.selectFired = false end
|
|
||||||
end
|
|
||||||
|
|
||||||
return TouchInput
|
|
||||||
@@ -4,14 +4,22 @@
|
|||||||
-- tests.
|
-- tests.
|
||||||
|
|
||||||
local Version = {
|
local Version = {
|
||||||
engine = "1.0.0", -- game/engine release (semver triple)
|
engine = "0.0.0-dev", -- game/engine release (semver). Repo default is the
|
||||||
|
-- "-dev" placeholder; CI stamps the real X.Y.Z into
|
||||||
|
-- the packed game.love only, never the working tree.
|
||||||
|
shell = 1, -- native-shell contract this build implements
|
||||||
|
minShell = 1, -- lowest shell contract that can RUN this payload.
|
||||||
|
-- Bump only when a payload needs a newer native
|
||||||
|
-- binary (e.g. a LOVE version bump); an older shell
|
||||||
|
-- refuses to chainload a payload whose minShell
|
||||||
|
-- exceeds the shell it provides.
|
||||||
modApi = 2, -- mod API major (manifest `api`)
|
modApi = 2, -- mod API major (manifest `api`)
|
||||||
linkProtocol = 2, -- link handshake wire version (Handshake.PROTOCOL)
|
linkProtocol = 2, -- link handshake wire version (Handshake.PROTOCOL)
|
||||||
saveFormat = 4, -- save.meta.format
|
saveFormat = 4, -- save.meta.format
|
||||||
cache = "rom-cache-v5", -- ROM import cache generation (RomImporter marker)
|
cache = "rom-cache-v5", -- ROM import cache generation (RomImporter marker)
|
||||||
}
|
}
|
||||||
|
|
||||||
-- "gen1recomp v1.0.0"
|
-- "gen1recomp v0.0.0-dev" (or the stamped release version in shipped builds)
|
||||||
function Version.title(base)
|
function Version.title(base)
|
||||||
return (base or "gen1recomp")
|
return (base or "gen1recomp")
|
||||||
.. " v" .. Version.engine
|
.. " v" .. Version.engine
|
||||||
|
|||||||
@@ -375,6 +375,8 @@ function RomExtractor:extractMaps()
|
|||||||
}
|
}
|
||||||
self:tick("Maps", mapIndex, #keys)
|
self:tick("Maps", mapIndex, #keys)
|
||||||
end
|
end
|
||||||
|
-- keep the extract ROM-faithful; Data:seedDefaults applies the #189
|
||||||
|
-- S.S. Anne 1F cabin reorder at load time
|
||||||
self:write("maps", out)
|
self:write("maps", out)
|
||||||
return out
|
return out
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -0,0 +1,120 @@
|
|||||||
|
-- SaveFileIO -- the launcher's glue between a raw Gen1 .sav battery image and
|
||||||
|
-- this project's save slots. Keeps RomImporter lean: the SAVE FILES card just
|
||||||
|
-- calls importToSlot / exportActiveSlot and renders the {ok, result} outcome.
|
||||||
|
--
|
||||||
|
-- Import reads bytes (an absolute picker path, a dropped LOVE file, or raw
|
||||||
|
-- bytes), runs them through SaveConvert.importSav (32768-byte + checksum
|
||||||
|
-- validated), then registers a fresh slot, writes it, and makes it active.
|
||||||
|
-- Export loads the active slot, encodes it back to a 32768-byte SRAM image, and
|
||||||
|
-- drops it in the save directory's exports/ folder, returning the absolute path
|
||||||
|
-- so the launcher can offer an "open folder" affordance.
|
||||||
|
--
|
||||||
|
-- Every failure returns false + a friendly one-line message (never raises), so
|
||||||
|
-- the card can surface it as a red notice line rather than crashing.
|
||||||
|
|
||||||
|
local SaveConvert = require("src.save_convert.SaveConvert")
|
||||||
|
local SaveData = require("src.core.SaveData")
|
||||||
|
local GameVersion = require("src.core.GameVersion")
|
||||||
|
|
||||||
|
local SaveFileIO = {}
|
||||||
|
|
||||||
|
local SAVE_SIZE = SaveConvert.SAVE_SIZE
|
||||||
|
|
||||||
|
-- Resolve raw save bytes from whatever the launcher hands us:
|
||||||
|
-- * a LOVE DroppedFile (a table/userdata with :open/:read/:getSize), read the
|
||||||
|
-- way RomImporter reads a dropped ROM;
|
||||||
|
-- * a raw 32768-byte string (the tests and the in-memory path) used as-is;
|
||||||
|
-- * any other string treated as an absolute picker path opened with io.open.
|
||||||
|
-- A picker path is never 32768 bytes long, so the length test disambiguates it
|
||||||
|
-- from a raw image cleanly. Returns bytes, or nil + an error string.
|
||||||
|
local function readSource(source)
|
||||||
|
local t = type(source)
|
||||||
|
if t == "table" or t == "userdata" then
|
||||||
|
if type(source.read) ~= "function" then
|
||||||
|
return nil, "that file could not be read"
|
||||||
|
end
|
||||||
|
local ok, openErr = source:open("r")
|
||||||
|
if not ok then return nil, "could not open the dropped file: " .. tostring(openErr) end
|
||||||
|
local data, readErr = source:read(source:getSize())
|
||||||
|
source:close()
|
||||||
|
if not data then return nil, "could not read the dropped file: " .. tostring(readErr) end
|
||||||
|
return data
|
||||||
|
end
|
||||||
|
if t ~= "string" then
|
||||||
|
return nil, "no save file was provided"
|
||||||
|
end
|
||||||
|
if #source == SAVE_SIZE then
|
||||||
|
return source
|
||||||
|
end
|
||||||
|
local f, openErr = io.open(source, "rb")
|
||||||
|
if f then
|
||||||
|
local data = f:read("*a")
|
||||||
|
f:close()
|
||||||
|
if type(data) ~= "string" then return nil, "the save file was empty" end
|
||||||
|
return data
|
||||||
|
end
|
||||||
|
-- Android SAF drops (picked_save.sav) and USB copies land in the LOVE save
|
||||||
|
-- directory; io.open cannot see them, so fall back to love.filesystem.
|
||||||
|
if love and love.filesystem and love.filesystem.read then
|
||||||
|
local data = love.filesystem.read(source)
|
||||||
|
if type(data) == "string" then return data end
|
||||||
|
end
|
||||||
|
return nil, "could not read the save file: " .. tostring(openErr)
|
||||||
|
end
|
||||||
|
|
||||||
|
-- importToSlot(source, version) -> ok, slotIdOrErr
|
||||||
|
-- source: an absolute path, a LOVE DroppedFile, or raw 32768 bytes. On success
|
||||||
|
-- registers a new slot for the version, writes the imported save into it, makes
|
||||||
|
-- it the active slot, and returns true + the new slot id. On any failure
|
||||||
|
-- returns false + a friendly message.
|
||||||
|
function SaveFileIO.importToSlot(source, version)
|
||||||
|
version = version or GameVersion.get()
|
||||||
|
local bytes, readErr = readSource(source)
|
||||||
|
if not bytes then return false, readErr end
|
||||||
|
if #bytes ~= SAVE_SIZE then
|
||||||
|
return false, ("A save file must be %d bytes (32 KB); this one is %d.")
|
||||||
|
:format(SAVE_SIZE, #bytes)
|
||||||
|
end
|
||||||
|
local save, convertErr = SaveConvert.importSav(bytes, version)
|
||||||
|
if not save then return false, convertErr end
|
||||||
|
-- Tag the game version and normalize the meta stamp: SaveConvert leaves
|
||||||
|
-- meta.format = "gen1_import", but SaveData.load's migration pass compares
|
||||||
|
-- the format numerically, so re-stamp it to the current format (the imported
|
||||||
|
-- table is already current-shaped, so no migration is skipped by doing so).
|
||||||
|
save.version = version
|
||||||
|
save.meta = SaveData.buildMeta(nil, save.meta)
|
||||||
|
local slotId = SaveData.createSlot(version)
|
||||||
|
if not slotId then return false, "this game has no save slots to import into" end
|
||||||
|
local ok, writeErr = SaveData.writeSlot(version, slotId, save)
|
||||||
|
if not ok then
|
||||||
|
return false, "could not write the imported save: " .. tostring(writeErr)
|
||||||
|
end
|
||||||
|
SaveData.setActiveSlot(version, slotId)
|
||||||
|
return true, slotId
|
||||||
|
end
|
||||||
|
|
||||||
|
-- exportActiveSlot(version) -> ok, pathOrErr
|
||||||
|
-- Loads the version's active slot save (SaveData.load semantics), encodes it
|
||||||
|
-- back to a 32768-byte SRAM image, and writes it to
|
||||||
|
-- exports/gen1recomp-<version>-<slotId>.sav in the save directory (created if
|
||||||
|
-- absent). Returns true + the absolute path on success, false + a friendly
|
||||||
|
-- message otherwise.
|
||||||
|
function SaveFileIO.exportActiveSlot(version)
|
||||||
|
version = version or GameVersion.get()
|
||||||
|
local save = SaveData.load(version)
|
||||||
|
if not save then return false, "this game has no save to export yet" end
|
||||||
|
local bytes, exportErr = SaveConvert.exportSav(save)
|
||||||
|
if not bytes then return false, exportErr end
|
||||||
|
local slotId = SaveData.activeSlot(version) or "save"
|
||||||
|
local fs = love and love.filesystem
|
||||||
|
if not (fs and fs.write) then return false, "no filesystem available to export to" end
|
||||||
|
if fs.createDirectory then fs.createDirectory("exports") end
|
||||||
|
local rel = ("exports/gen1recomp-%s-%s.sav"):format(version, slotId)
|
||||||
|
local ok, writeErr = fs.write(rel, bytes)
|
||||||
|
if not ok then return false, "could not write the export: " .. tostring(writeErr) end
|
||||||
|
local base = fs.getSaveDirectory and fs.getSaveDirectory() or ""
|
||||||
|
if base ~= "" then return true, base .. "/" .. rel end
|
||||||
|
return true, rel
|
||||||
|
end
|
||||||
|
|
||||||
|
return SaveFileIO
|
||||||
@@ -114,10 +114,11 @@ function ItemEffects.use(data, save, itemId, target, battle, moveIndex, ow)
|
|||||||
local itemDef = data.items[itemId]
|
local itemDef = data.items[itemId]
|
||||||
local name = itemDef and itemDef.name or itemId
|
local name = itemDef and itemDef.name or itemId
|
||||||
|
|
||||||
-- ItemUseVitamin / ItemUsePPUp / ItemUseEvoStone / ItemUseCoinCase
|
-- ItemUseVitamin / ItemUsePPUp / ItemUseEvoStone / ItemUseCoinCase /
|
||||||
-- all refuse mid-battle (jp nz, ItemUseNotTime)
|
-- ItemUseTMHM all refuse mid-battle (jp nz, ItemUseNotTime)
|
||||||
if battle and (VITAMINS[itemId] or STONES[itemId] or itemId == "PP_UP"
|
if battle and (VITAMINS[itemId] or STONES[itemId] or itemId == "PP_UP"
|
||||||
or itemId == "RARE_CANDY" or itemId == "COIN_CASE") then
|
or itemId == "RARE_CANDY" or itemId == "COIN_CASE"
|
||||||
|
or (itemDef and itemDef.machine)) then
|
||||||
return "failed", { "OAK: " .. save.player.name
|
return "failed", { "OAK: " .. save.player.name
|
||||||
.. "!\nThis isn't the\ntime to use that!" }
|
.. "!\nThis isn't the\ntime to use that!" }
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -182,7 +182,7 @@ function LinkBattle.new(game, net, opts)
|
|||||||
|
|
||||||
-- both parties pass through the same pack->unpack clamp on both
|
-- both parties pass through the same pack->unpack clamp on both
|
||||||
-- machines, so the copies are identical everywhere
|
-- machines, so the copies are identical everywhere
|
||||||
local unpackOpts = { strict = opts.strict or false }
|
local unpackOpts = { strict = opts.strict or false, forceLevel = opts.forceLevel }
|
||||||
local myParty, myErr = unpackParty(game, opts.myParty, unpackOpts, function(p)
|
local myParty, myErr = unpackParty(game, opts.myParty, unpackOpts, function(p)
|
||||||
return ("Your %s can't\nbattle on the\nother game."):format(tostring(p.species))
|
return ("Your %s can't\nbattle on the\nother game."):format(tostring(p.species))
|
||||||
end)
|
end)
|
||||||
@@ -646,7 +646,7 @@ function LinkBattle.newSpectator(game, net, opts)
|
|||||||
return nil, "Link battle needs\nthe same mods on\nboth games."
|
return nil, "Link battle needs\nthe same mods on\nboth games."
|
||||||
end
|
end
|
||||||
|
|
||||||
local unpackOpts = { strict = opts.strict or false }
|
local unpackOpts = { strict = opts.strict or false, forceLevel = opts.forceLevel }
|
||||||
local hostParty, hostErr = unpackParty(game, opts.hostParty, unpackOpts, function(p)
|
local hostParty, hostErr = unpackParty(game, opts.hostParty, unpackOpts, function(p)
|
||||||
return ("%s's %s can't\nbattle on this\ngame."):format(hostName, tostring(p.species))
|
return ("%s's %s can't\nbattle on this\ngame."):format(hostName, tostring(p.species))
|
||||||
end)
|
end)
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
-- lua-enet (bundled with LÖVE), no relay server.
|
-- lua-enet (bundled with LÖVE), no relay server.
|
||||||
|
|
||||||
local CodeEntry = require("src.link.CodeEntry")
|
local CodeEntry = require("src.link.CodeEntry")
|
||||||
|
local DiscordPresence = require("src.core.DiscordPresence")
|
||||||
local Font = require("src.render.Font")
|
local Font = require("src.render.Font")
|
||||||
local Handshake = require("src.link.Handshake")
|
local Handshake = require("src.link.Handshake")
|
||||||
local Net = require("src.link.Net")
|
local Net = require("src.link.Net")
|
||||||
@@ -16,6 +17,31 @@ LinkState.__index = LinkState
|
|||||||
LinkState.isOpaque = true
|
LinkState.isOpaque = true
|
||||||
|
|
||||||
local CURSOR = 0xED
|
local CURSOR = 0xED
|
||||||
|
local ANY = "ANY" -- sentinel: a leading nil array entry breaks ipairs under
|
||||||
|
-- LuaJIT even though # still reports the full size, so
|
||||||
|
-- the level picker cycles this string instead of nil,
|
||||||
|
-- converted to nil only on the wire (see levelForWire)
|
||||||
|
local FORCE_LEVEL_STEPS = { ANY, 50, 100 }
|
||||||
|
|
||||||
|
local function indexOf(list, value)
|
||||||
|
for i, v in ipairs(list) do
|
||||||
|
if v == value then return i end
|
||||||
|
end
|
||||||
|
return 1
|
||||||
|
end
|
||||||
|
|
||||||
|
local function levelForWire(v)
|
||||||
|
-- ANY ("use each mon's real level") goes on the wire as nil (no forced
|
||||||
|
-- level). An explicit guard, not `v == ANY and nil or v`: that idiom's
|
||||||
|
-- true branch is nil, so it falls through to `or v` and returned the
|
||||||
|
-- literal "ANY" string, which then crashed math.floor in unpackMon (#204).
|
||||||
|
if v == ANY then return nil end
|
||||||
|
return v
|
||||||
|
end
|
||||||
|
|
||||||
|
local function forceLevelLabel(v)
|
||||||
|
return (v == ANY or v == nil) and "ANY" or ("AUTO " .. tostring(v))
|
||||||
|
end
|
||||||
|
|
||||||
-- stages before any Net object is meaningfully "this session's link" --
|
-- stages before any Net object is meaningfully "this session's link" --
|
||||||
-- .net can still be a leftover failed attempt sitting on self, so error/
|
-- .net can still be a leftover failed attempt sitting on self, so error/
|
||||||
@@ -54,7 +80,23 @@ function LinkState.new(game)
|
|||||||
return self
|
return self
|
||||||
end
|
end
|
||||||
|
|
||||||
|
-- entry point for a Discord "Ask to Join" click (see DiscordPresence.lua):
|
||||||
|
-- skips the whole LAN/ONLINE/TOURNAMENT menu and jumps straight to
|
||||||
|
-- "connecting with this code", same as if the player had typed it in
|
||||||
|
function LinkState.newJoinOnline(game, code)
|
||||||
|
local self = LinkState.new(game)
|
||||||
|
self.net = Net.new()
|
||||||
|
if self.net:joinOnline(nil, code) then
|
||||||
|
self.stage = "onlineJoining"
|
||||||
|
else
|
||||||
|
self.stage = "menu" -- exitWith below needs a real stage to unwind from
|
||||||
|
self:exitWith("Link error:\n" .. (self.net.error or "?"))
|
||||||
|
end
|
||||||
|
return self
|
||||||
|
end
|
||||||
|
|
||||||
function LinkState:exitWith(message, reason)
|
function LinkState:exitWith(message, reason)
|
||||||
|
DiscordPresence.setJoinCode(nil)
|
||||||
Runtime.emit("link.ended", { reason = reason or (message and "error" or "bye") })
|
Runtime.emit("link.ended", { reason = reason or (message and "error" or "bye") })
|
||||||
if self.net then self.net:close() end
|
if self.net then self.net:close() end
|
||||||
self.game.stack:pop()
|
self.game.stack:pop()
|
||||||
@@ -108,7 +150,12 @@ function LinkState:decideCompat(mode, isHost)
|
|||||||
mods = peer and peer.mods, fingerprint = peer and peer.fingerprint },
|
mods = peer and peer.mods, fingerprint = peer and peer.fingerprint },
|
||||||
})
|
})
|
||||||
if self.verdict == "full" or self.verdict == "vanilla_peer" then
|
if self.verdict == "full" or self.verdict == "vanilla_peer" then
|
||||||
self:startMode(mode, isHost)
|
if isHost and mode == "battle" then
|
||||||
|
-- host picks the level rule now, before the parties are exchanged
|
||||||
|
self.stage = "battleOptions"
|
||||||
|
else
|
||||||
|
self:startMode(mode, isHost)
|
||||||
|
end
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
-- naming the difference up front is the whole point: the old behaviour
|
-- naming the difference up front is the whole point: the old behaviour
|
||||||
@@ -208,8 +255,13 @@ function LinkState:update(dt)
|
|||||||
end
|
end
|
||||||
|
|
||||||
elseif self.stage == "onlineHosting" then
|
elseif self.stage == "onlineHosting" then
|
||||||
|
if not self.discordCodeSet and self.net.code then
|
||||||
|
DiscordPresence.setJoinCode(self.net.code)
|
||||||
|
self.discordCodeSet = true
|
||||||
|
end
|
||||||
if input:wasPressed("b") then self:exitWith(nil) return end
|
if input:wasPressed("b") then self:exitWith(nil) return end
|
||||||
if self.net.paired then
|
if self.net.paired then
|
||||||
|
DiscordPresence.setJoinCode(nil) -- someone's here now; stop advertising
|
||||||
self.stage = "modeSelect"
|
self.stage = "modeSelect"
|
||||||
self.index = 1
|
self.index = 1
|
||||||
end
|
end
|
||||||
@@ -300,6 +352,23 @@ function LinkState:update(dt)
|
|||||||
self:exitWith(nil)
|
self:exitWith(nil)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
elseif self.stage == "battleOptions" then -- host picks the level rule,
|
||||||
|
-- once compat is confirmed and
|
||||||
|
-- before parties are exchanged
|
||||||
|
self.levelChoice = self.levelChoice or ANY
|
||||||
|
if input:wasPressed("b") then
|
||||||
|
self:exitWith(nil)
|
||||||
|
elseif input:wasPressed("up") or input:wasPressed("down")
|
||||||
|
or input:wasPressed("left") or input:wasPressed("right") then
|
||||||
|
local delta = (input:wasPressed("down") or input:wasPressed("left")) and -1 or 1
|
||||||
|
local i = indexOf(FORCE_LEVEL_STEPS, self.levelChoice)
|
||||||
|
i = ((i - 1 + delta) % #FORCE_LEVEL_STEPS) + 1
|
||||||
|
self.levelChoice = FORCE_LEVEL_STEPS[i]
|
||||||
|
elseif input:wasPressed("a") then
|
||||||
|
self.forceLevel = levelForWire(self.levelChoice)
|
||||||
|
self:startMode(self.pendingMode, true)
|
||||||
|
end
|
||||||
|
|
||||||
elseif self.stage == "waitHello" then -- host waits for the peer's hello
|
elseif self.stage == "waitHello" then -- host waits for the peer's hello
|
||||||
if input:wasPressed("b") then self:exitWith(nil) return end
|
if input:wasPressed("b") then self:exitWith(nil) return end
|
||||||
local got, other = self:pollHello()
|
local got, other = self:pollHello()
|
||||||
@@ -343,6 +412,9 @@ function LinkState:update(dt)
|
|||||||
local msgs = self.net:poll()
|
local msgs = self.net:poll()
|
||||||
for i, msg in ipairs(msgs) do
|
for i, msg in ipairs(msgs) do
|
||||||
if msg.type == "party" then
|
if msg.type == "party" then
|
||||||
|
-- the host owns this rule (same as mode); the guest only learns
|
||||||
|
-- it here, off the host's own party message
|
||||||
|
if not self.isHost then self.forceLevel = msg.forceLevel end
|
||||||
local LinkBattle = require("src.link.LinkBattle")
|
local LinkBattle = require("src.link.LinkBattle")
|
||||||
local opts = {
|
local opts = {
|
||||||
myParty = Protocol.packParty(self.game.save.party),
|
myParty = Protocol.packParty(self.game.save.party),
|
||||||
@@ -351,6 +423,7 @@ function LinkState:update(dt)
|
|||||||
seed = self.isHost and self.linkSeed or msg.seed,
|
seed = self.isHost and self.linkSeed or msg.seed,
|
||||||
verdict = self.verdict,
|
verdict = self.verdict,
|
||||||
strict = Handshake.strict(self.verdict),
|
strict = Handshake.strict(self.verdict),
|
||||||
|
forceLevel = self.forceLevel,
|
||||||
}
|
}
|
||||||
local battle, why
|
local battle, why
|
||||||
if self.isHost then
|
if self.isHost then
|
||||||
@@ -401,7 +474,8 @@ function LinkState:startMode(mode, isHost)
|
|||||||
end
|
end
|
||||||
self.net:send({ type = "party",
|
self.net:send({ type = "party",
|
||||||
mons = Protocol.packParty(self.game.save.party),
|
mons = Protocol.packParty(self.game.save.party),
|
||||||
seed = self.linkSeed })
|
seed = self.linkSeed,
|
||||||
|
forceLevel = isHost and self.forceLevel or nil })
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -424,6 +498,14 @@ function LinkState:updateTrade(input)
|
|||||||
if t.stage == "done" then
|
if t.stage == "done" then
|
||||||
local sent = t.party[t.myPick]
|
local sent = t.party[t.myPick]
|
||||||
local received, evoTo = t:apply(self.game)
|
local received, evoTo = t:apply(self.game)
|
||||||
|
-- Autosave the instant the swap commits into game.save.party, matching the
|
||||||
|
-- Cable Club: pokered engine/link/cable_club.asm calls SaveSAVtoSRAM
|
||||||
|
-- (engine/menus/save.asm) right after every trade so the trade is on the
|
||||||
|
-- cartridge before the animation runs. Without this the received mon
|
||||||
|
-- lives only in memory until a manual START-menu save, so a force-quit
|
||||||
|
-- would lose it and a reset would clone the sent mon (#222). Guarded so
|
||||||
|
-- headless LinkBattle-style fake games with no writeSave are unaffected.
|
||||||
|
if self.game.writeSave then self.game:writeSave() end
|
||||||
local name = received.nickname or self.game.data.pokemon[received.species].name
|
local name = received.nickname or self.game.data.pokemon[received.species].name
|
||||||
Runtime.emit("link.ended", { reason = "done" })
|
Runtime.emit("link.ended", { reason = "done" })
|
||||||
self.net:close()
|
self.net:close()
|
||||||
@@ -432,12 +514,22 @@ function LinkState:updateTrade(input)
|
|||||||
require("src.core.Sound").play(game.data, "Trade_Machine")
|
require("src.core.Sound").play(game.data, "Trade_Machine")
|
||||||
Screens.push(game, "TradeAnim", {
|
Screens.push(game, "TradeAnim", {
|
||||||
sent = sent, received = received,
|
sent = sent, received = received,
|
||||||
|
enemyName = (self.peerName or "TRAINER"),
|
||||||
|
playerOt = game.save.player.name,
|
||||||
|
playerOtId = sent.otId or game.save.player.id,
|
||||||
|
enemyOtId = received.otId,
|
||||||
onDone = function()
|
onDone = function()
|
||||||
game.stack:push(TextBox.new(game,
|
game.stack:push(TextBox.new(game,
|
||||||
("Trade completed!\f%s received\n%s!"):format(game.save.player.name, name),
|
("Trade completed!\f%s received\n%s!"):format(game.save.player.name, name),
|
||||||
function()
|
function()
|
||||||
if evoTo then
|
if evoTo then
|
||||||
require("src.pokemon.Evolution").evolve(game, received, evoTo)
|
-- via="TRADE": a trade evolution cannot be B-cancelled
|
||||||
|
-- (pokered LINK_STATE_TRADING skips the flash B-poll) (#213).
|
||||||
|
-- Re-save once the evolution movie finishes so the evolved
|
||||||
|
-- species (not the pre-evo landed by t:apply) is what persists,
|
||||||
|
-- keeping disk in step with the autosave above (#222).
|
||||||
|
require("src.pokemon.Evolution").evolve(game, received, evoTo,
|
||||||
|
function() if game.writeSave then game:writeSave() end end, "TRADE")
|
||||||
end
|
end
|
||||||
end))
|
end))
|
||||||
end,
|
end,
|
||||||
@@ -557,6 +649,12 @@ function LinkState:draw()
|
|||||||
Font.draw("BATTLE", 32, 68)
|
Font.draw("BATTLE", 32, 68)
|
||||||
Font.drawCode(CURSOR, 24, self.index == 1 and 48 or 68)
|
Font.drawCode(CURSOR, 24, self.index == 1 and 48 or 68)
|
||||||
|
|
||||||
|
elseif self.stage == "battleOptions" then
|
||||||
|
drawTitle("BATTLE OPTIONS")
|
||||||
|
Font.draw("LEVELS:", 16, 56)
|
||||||
|
Font.draw(forceLevelLabel(self.levelChoice or ANY), 88, 56)
|
||||||
|
Font.draw("A: continue B: back", 8, 128)
|
||||||
|
|
||||||
elseif self.stage == "waitMode" or self.stage == "waitHello" then
|
elseif self.stage == "waitMode" or self.stage == "waitHello" then
|
||||||
drawTitle("CONNECTED!")
|
drawTitle("CONNECTED!")
|
||||||
if self.stage == "waitHello" then
|
if self.stage == "waitHello" then
|
||||||
|
|||||||
@@ -43,7 +43,12 @@ Protocol.plainCopy = plainCopy
|
|||||||
|
|
||||||
-- serialize a mon instance for the wire (plain data only). ppUps rides
|
-- serialize a mon instance for the wire (plain data only). ppUps rides
|
||||||
-- along because the real cable transmitted it and its absence silently
|
-- along because the real cable transmitted it and its absence silently
|
||||||
-- capped a PP-Upped move at base PP on the receiving side.
|
-- capped a PP-Upped move at base PP on the receiving side. ot/otId ride
|
||||||
|
-- along too: pokered's trade sends the whole party block including each
|
||||||
|
-- mon's OT ID (party_struct MON_OTID) and the OT-names block (wPartyMonOT),
|
||||||
|
-- and the receiver keeps them verbatim -- a differing OT/ID is what marks a
|
||||||
|
-- mon as traded (boosted EXP, high-level disobedience). Omitting them made
|
||||||
|
-- a received mon show the receiver as its OT (#215).
|
||||||
function Protocol.packMon(mon)
|
function Protocol.packMon(mon)
|
||||||
local moves = {}
|
local moves = {}
|
||||||
for _, mv in ipairs(mon.moves) do
|
for _, mv in ipairs(mon.moves) do
|
||||||
@@ -59,6 +64,8 @@ function Protocol.packMon(mon)
|
|||||||
dvs = mon.dvs,
|
dvs = mon.dvs,
|
||||||
statExp = mon.statExp,
|
statExp = mon.statExp,
|
||||||
moves = moves,
|
moves = moves,
|
||||||
|
ot = mon.ot,
|
||||||
|
otId = mon.otId,
|
||||||
extra = plainCopy(mon.extra),
|
extra = plainCopy(mon.extra),
|
||||||
}
|
}
|
||||||
end
|
end
|
||||||
@@ -71,12 +78,27 @@ function Protocol.unpackMon(data, packed, opts)
|
|||||||
local Stats = require("src.pokemon.Stats")
|
local Stats = require("src.pokemon.Stats")
|
||||||
local Growth = require("src.pokemon.Growth")
|
local Growth = require("src.pokemon.Growth")
|
||||||
local strict = opts and opts.strict
|
local strict = opts and opts.strict
|
||||||
|
-- forceLevel comes from an "auto-level" ruling. The picker's ANY choice
|
||||||
|
-- ("use each mon's real level", Gen1's only mode) is a string sentinel on
|
||||||
|
-- the LinkState/Tournament side (see levelForWire) that must mean "no
|
||||||
|
-- forced level" here. Coerce once so a non-numeric level string -- the ANY
|
||||||
|
-- sentinel, an old peer, or a mod (#204) -- can never reach math.floor
|
||||||
|
-- below: tonumber("ANY") == nil, i.e. keep the packed real level, while
|
||||||
|
-- tonumber(50)/tonumber("50") both give 50.
|
||||||
|
local forceLevel = opts and tonumber(opts.forceLevel) or nil
|
||||||
local def = data.pokemon[packed.species]
|
local def = data.pokemon[packed.species]
|
||||||
if not def then
|
if not def then
|
||||||
if strict then return nil, "unknown POKéMON" end
|
if strict then return nil, "unknown POKéMON" end
|
||||||
return nil
|
return nil
|
||||||
end
|
end
|
||||||
local level = math.max(2, math.min(100, math.floor(packed.level or 5)))
|
local level = math.max(2, math.min(100, math.floor(packed.level or 5)))
|
||||||
|
-- "auto-level" tournaments/matches: every participant's real level is
|
||||||
|
-- ignored and everyone rebuilds at the same fixed level instead, so a
|
||||||
|
-- Lv12 and a Lv100 party can battle on equal footing. Both sides pass
|
||||||
|
-- the identical forceLevel for a given match, so this stays symmetric.
|
||||||
|
if forceLevel then
|
||||||
|
level = math.max(2, math.min(100, math.floor(forceLevel)))
|
||||||
|
end
|
||||||
local dvs = {}
|
local dvs = {}
|
||||||
for _, k in ipairs({ "hp", "attack", "defense", "speed", "special" }) do
|
for _, k in ipairs({ "hp", "attack", "defense", "speed", "special" }) do
|
||||||
dvs[k] = math.max(0, math.min(15, math.floor((packed.dvs or {})[k] or 0)))
|
dvs[k] = math.max(0, math.min(15, math.floor((packed.dvs or {})[k] or 0)))
|
||||||
@@ -104,6 +126,23 @@ function Protocol.unpackMon(data, packed, opts)
|
|||||||
if strict then return nil, "no shared moves" end
|
if strict then return nil, "no shared moves" end
|
||||||
moves = { { id = "TACKLE", pp = 35 } }
|
moves = { { id = "TACKLE", pp = 35 } }
|
||||||
end
|
end
|
||||||
|
-- a forced level scales every stat including max HP, so the real party's
|
||||||
|
-- current HP/status (a different level's numbers, possibly mid-fight)
|
||||||
|
-- isn't meaningful anymore -- auto-level starts everyone full and fresh,
|
||||||
|
-- same as a standardized tournament format would
|
||||||
|
local forced = forceLevel
|
||||||
|
local hp = forced and stats.hp
|
||||||
|
or math.max(0, math.min(stats.hp, math.floor(packed.hp or stats.hp)))
|
||||||
|
local status = forced and nil or packed.status
|
||||||
|
-- preserve the sender's original-trainer identity (party_struct MON_OTID +
|
||||||
|
-- wPartyMonOT on a real cable), clamped/typed like every other field so a
|
||||||
|
-- tampered packet can't inject a bad ID or a huge name. Left nil when the
|
||||||
|
-- packet omits them (a v1/old peer) -- no worse than before for that legacy
|
||||||
|
-- path, and once ot is set the load-time stampOT backfill (mon.ot or ...)
|
||||||
|
-- becomes a no-op so the sender's identity survives save/reload (#215).
|
||||||
|
local otId = packed.otId
|
||||||
|
and math.max(0, math.min(65535, math.floor(packed.otId))) or nil
|
||||||
|
local ot = type(packed.ot) == "string" and packed.ot:sub(1, 10) or nil
|
||||||
return {
|
return {
|
||||||
species = packed.species,
|
species = packed.species,
|
||||||
level = level,
|
level = level,
|
||||||
@@ -111,9 +150,11 @@ function Protocol.unpackMon(data, packed, opts)
|
|||||||
dvs = dvs,
|
dvs = dvs,
|
||||||
statExp = statExp,
|
statExp = statExp,
|
||||||
stats = stats,
|
stats = stats,
|
||||||
hp = math.max(0, math.min(stats.hp, math.floor(packed.hp or stats.hp))),
|
hp = hp,
|
||||||
status = packed.status,
|
status = status,
|
||||||
nickname = packed.nickname,
|
nickname = packed.nickname,
|
||||||
|
ot = ot,
|
||||||
|
otId = otId,
|
||||||
moves = moves,
|
moves = moves,
|
||||||
-- a namespace whose mod this install lacks survives untouched, so the
|
-- a namespace whose mod this install lacks survives untouched, so the
|
||||||
-- mon keeps it for the trip home
|
-- mon keeps it for the trip home
|
||||||
|
|||||||
@@ -9,6 +9,7 @@
|
|||||||
-- time, uninterrupted by individual matches.
|
-- time, uninterrupted by individual matches.
|
||||||
|
|
||||||
local CodeEntry = require("src.link.CodeEntry")
|
local CodeEntry = require("src.link.CodeEntry")
|
||||||
|
local DiscordPresence = require("src.core.DiscordPresence")
|
||||||
local Font = require("src.render.Font")
|
local Font = require("src.render.Font")
|
||||||
local Handshake = require("src.link.Handshake")
|
local Handshake = require("src.link.Handshake")
|
||||||
local LinkBattle = require("src.link.LinkBattle")
|
local LinkBattle = require("src.link.LinkBattle")
|
||||||
@@ -33,7 +34,14 @@ local ANY = "ANY" -- sentinel: a leading *nil* array entry breaks ipairs
|
|||||||
-- to nil only on the wire
|
-- to nil only on the wire
|
||||||
local LEVEL_STEPS = { ANY, 5, 10, 15, 20, 25, 30, 35, 40, 45, 50, 55, 60, 65,
|
local LEVEL_STEPS = { ANY, 5, 10, 15, 20, 25, 30, 35, 40, 45, 50, 55, 60, 65,
|
||||||
70, 75, 80, 85, 90, 95, 100 }
|
70, 75, 80, 85, 90, 95, 100 }
|
||||||
local SETTINGS_ROWS = 5 -- POKEMON, MIN LV, MAX LV, TIMER, PLAYING
|
local SETTINGS_ROWS = 6 -- POKEMON, MIN LV, MAX LV, TIMER, LEVELS, PLAYING
|
||||||
|
-- LEVELS cycles through this: ANY (real levels) or a fixed level every
|
||||||
|
-- match normalizes to, regardless of each side's real party
|
||||||
|
local FORCE_LEVEL_STEPS = { ANY, 50, 100 }
|
||||||
|
-- tournaments have no real participant cap (any number can join before
|
||||||
|
-- start); this is just generous headroom so Discord's party.size never
|
||||||
|
-- reads as "full" and blocks a real invite click
|
||||||
|
local TOURNAMENT_PARTY_MAX = 16
|
||||||
|
|
||||||
local function indexOf(list, value)
|
local function indexOf(list, value)
|
||||||
for i, v in ipairs(list) do
|
for i, v in ipairs(list) do
|
||||||
@@ -49,7 +57,16 @@ local function levelLabel(v)
|
|||||||
end
|
end
|
||||||
|
|
||||||
local function levelForWire(v)
|
local function levelForWire(v)
|
||||||
return v == ANY and nil or v
|
-- ANY ("use each mon's real level") goes on the wire as nil (no forced
|
||||||
|
-- level). An explicit guard, not `v == ANY and nil or v`: that idiom's
|
||||||
|
-- true branch is nil, so it falls through to `or v` and returned the
|
||||||
|
-- literal "ANY" string, which then crashed math.floor in unpackMon (#204).
|
||||||
|
if v == ANY then return nil end
|
||||||
|
return v
|
||||||
|
end
|
||||||
|
|
||||||
|
local function forceLevelLabel(v)
|
||||||
|
return (v == ANY or v == nil) and "ANY" or ("AUTO " .. tostring(v))
|
||||||
end
|
end
|
||||||
|
|
||||||
local function partyStats(party)
|
local function partyStats(party)
|
||||||
@@ -69,7 +86,7 @@ function Tournament.new(game)
|
|||||||
self.stage = "menu"
|
self.stage = "menu"
|
||||||
self.index = 1
|
self.index = 1
|
||||||
self.settings = { turnLimit = 6, requiredPartySize = 3, minLevel = ANY, maxLevel = ANY,
|
self.settings = { turnLimit = 6, requiredPartySize = 3, minLevel = ANY, maxLevel = ANY,
|
||||||
participating = true }
|
forceLevel = ANY, participating = true }
|
||||||
self.settingsIndex = 1
|
self.settingsIndex = 1
|
||||||
self.roster = {}
|
self.roster = {}
|
||||||
self.spectatorRoster = {}
|
self.spectatorRoster = {}
|
||||||
@@ -77,7 +94,24 @@ function Tournament.new(game)
|
|||||||
return self
|
return self
|
||||||
end
|
end
|
||||||
|
|
||||||
|
-- entry point for a Discord "Ask to Join" click on a tournament invite
|
||||||
|
-- (see DiscordPresence.lua): skips the HOST/JOIN menu and code entry,
|
||||||
|
-- straight to "connecting with this code"
|
||||||
|
function Tournament.newJoinOnline(game, code)
|
||||||
|
local self = Tournament.new(game)
|
||||||
|
self:startJoining(code)
|
||||||
|
return self
|
||||||
|
end
|
||||||
|
|
||||||
|
-- headcount for Discord's party.size: the roster only ever lists
|
||||||
|
-- competing players, so a non-participating (organizer-only) host isn't
|
||||||
|
-- in it even though they're right here running the thing
|
||||||
|
function Tournament:discordPartySize()
|
||||||
|
return #self.roster + (self.participating == false and 1 or 0)
|
||||||
|
end
|
||||||
|
|
||||||
function Tournament:exitWith(message)
|
function Tournament:exitWith(message)
|
||||||
|
DiscordPresence.setJoinCode(nil)
|
||||||
Sound.stopLoop(MUSIC)
|
Sound.stopLoop(MUSIC)
|
||||||
Runtime.emit("link.ended", { reason = message and "error" or "bye" })
|
Runtime.emit("link.ended", { reason = message and "error" or "bye" })
|
||||||
if self.net then self.net:close() end
|
if self.net then self.net:close() end
|
||||||
@@ -106,6 +140,7 @@ function Tournament:startHosting()
|
|||||||
requiredPartySize = self.settings.requiredPartySize,
|
requiredPartySize = self.settings.requiredPartySize,
|
||||||
minLevel = levelForWire(self.settings.minLevel),
|
minLevel = levelForWire(self.settings.minLevel),
|
||||||
maxLevel = levelForWire(self.settings.maxLevel),
|
maxLevel = levelForWire(self.settings.maxLevel),
|
||||||
|
forceLevel = levelForWire(self.settings.forceLevel),
|
||||||
participating = self.settings.participating,
|
participating = self.settings.participating,
|
||||||
name = self.game.save.player.name,
|
name = self.game.save.player.name,
|
||||||
partySize = size, partyMinLevel = minL, partyMaxLevel = maxL,
|
partySize = size, partyMinLevel = minL, partyMaxLevel = maxL,
|
||||||
@@ -145,8 +180,22 @@ function Tournament:handleMessage(msg)
|
|||||||
if msg.type == "tournament_hosted" then
|
if msg.type == "tournament_hosted" then
|
||||||
self.code = msg.code
|
self.code = msg.code
|
||||||
self.settings.turnLimit = msg.turnLimit
|
self.settings.turnLimit = msg.turnLimit
|
||||||
|
self.settings.forceLevel = msg.forceLevel == nil and ANY or msg.forceLevel
|
||||||
self.participating = msg.participating
|
self.participating = msg.participating
|
||||||
self.stage = "bracket"
|
self.stage = "bracket"
|
||||||
|
-- the server already seeded t.players/spectators with the creator at
|
||||||
|
-- creation time; mirror that here so the Discord party size (and the
|
||||||
|
-- "waiting for players" list) show the host from the very first frame,
|
||||||
|
-- not just once someone else joins and a real roster broadcast arrives
|
||||||
|
if self.participating then
|
||||||
|
self.roster = { self.game.save.player.name }
|
||||||
|
else
|
||||||
|
self.spectatorRoster = { self.game.save.player.name }
|
||||||
|
end
|
||||||
|
if self.isCreator then
|
||||||
|
DiscordPresence.setJoinCode(self.code, "tournament",
|
||||||
|
self:discordPartySize(), TOURNAMENT_PARTY_MAX)
|
||||||
|
end
|
||||||
elseif msg.type == "tournament_host_error" then
|
elseif msg.type == "tournament_host_error" then
|
||||||
if msg.reason == "party_ineligible" then
|
if msg.reason == "party_ineligible" then
|
||||||
self:exitWith(("Can't host:\nneed %d Pokemon\nLv %s-%s."):format(
|
self:exitWith(("Can't host:\nneed %d Pokemon\nLv %s-%s."):format(
|
||||||
@@ -168,7 +217,12 @@ function Tournament:handleMessage(msg)
|
|||||||
self.settings.requiredPartySize = msg.requiredPartySize
|
self.settings.requiredPartySize = msg.requiredPartySize
|
||||||
self.settings.minLevel = msg.minLevel == nil and ANY or msg.minLevel
|
self.settings.minLevel = msg.minLevel == nil and ANY or msg.minLevel
|
||||||
self.settings.maxLevel = msg.maxLevel == nil and ANY or msg.maxLevel
|
self.settings.maxLevel = msg.maxLevel == nil and ANY or msg.maxLevel
|
||||||
|
self.settings.forceLevel = msg.forceLevel == nil and ANY or msg.forceLevel
|
||||||
self.stage = "bracket"
|
self.stage = "bracket"
|
||||||
|
if self.isCreator and self.code then
|
||||||
|
DiscordPresence.setJoinCode(self.code, "tournament",
|
||||||
|
self:discordPartySize(), TOURNAMENT_PARTY_MAX)
|
||||||
|
end
|
||||||
elseif msg.type == "bracket_update" then
|
elseif msg.type == "bracket_update" then
|
||||||
self.bracket = msg.tournament
|
self.bracket = msg.tournament
|
||||||
self.code = self.bracket.code
|
self.code = self.bracket.code
|
||||||
@@ -238,6 +292,7 @@ function Tournament:beginMatchBattle()
|
|||||||
verdict = verdict,
|
verdict = verdict,
|
||||||
strict = Handshake.strict(verdict),
|
strict = Handshake.strict(verdict),
|
||||||
turnLimit = self.matchTurnLimit,
|
turnLimit = self.matchTurnLimit,
|
||||||
|
forceLevel = levelForWire(self.settings.forceLevel),
|
||||||
keepNetOpen = true, -- this is the tournament's own connection, not a
|
keepNetOpen = true, -- this is the tournament's own connection, not a
|
||||||
-- dedicated match socket -- don't let finish() close it
|
-- dedicated match socket -- don't let finish() close it
|
||||||
}
|
}
|
||||||
@@ -360,6 +415,7 @@ function Tournament:update(dt)
|
|||||||
hostParty = self.spectate.hostParty, guestParty = self.spectate.guestParty,
|
hostParty = self.spectate.hostParty, guestParty = self.spectate.guestParty,
|
||||||
hostName = self.spectate.hostName, guestName = self.spectate.guestName,
|
hostName = self.spectate.hostName, guestName = self.spectate.guestName,
|
||||||
seed = self.spectate.seed,
|
seed = self.spectate.seed,
|
||||||
|
forceLevel = levelForWire(self.settings.forceLevel),
|
||||||
})
|
})
|
||||||
if not battle then
|
if not battle then
|
||||||
self:exitWith(why or "Can't watch this\nmatch.")
|
self:exitWith(why or "Can't watch this\nmatch.")
|
||||||
@@ -426,6 +482,10 @@ function Tournament:update(dt)
|
|||||||
i = ((i - 1 + delta) % #TURN_LIMITS) + 1
|
i = ((i - 1 + delta) % #TURN_LIMITS) + 1
|
||||||
self.settings.turnLimit = TURN_LIMITS[i]
|
self.settings.turnLimit = TURN_LIMITS[i]
|
||||||
elseif self.settingsIndex == 5 then
|
elseif self.settingsIndex == 5 then
|
||||||
|
local i = indexOf(FORCE_LEVEL_STEPS, self.settings.forceLevel)
|
||||||
|
i = ((i - 1 + delta) % #FORCE_LEVEL_STEPS) + 1
|
||||||
|
self.settings.forceLevel = FORCE_LEVEL_STEPS[i]
|
||||||
|
elseif self.settingsIndex == 6 then
|
||||||
self.settings.participating = not self.settings.participating
|
self.settings.participating = not self.settings.participating
|
||||||
end
|
end
|
||||||
elseif input:wasPressed("a") or input:wasPressed("start") then
|
elseif input:wasPressed("a") or input:wasPressed("start") then
|
||||||
@@ -454,6 +514,7 @@ function Tournament:update(dt)
|
|||||||
elseif self.stage == "bracket" then
|
elseif self.stage == "bracket" then
|
||||||
if input:wasPressed("b") then self:exitWith(nil) return end
|
if input:wasPressed("b") then self:exitWith(nil) return end
|
||||||
if input:wasPressed("a") and self.isCreator and #self.roster >= 2 then
|
if input:wasPressed("a") and self.isCreator and #self.roster >= 2 then
|
||||||
|
DiscordPresence.setJoinCode(nil) -- roster's locking in; stop advertising
|
||||||
self.net:send({ type = "start_tournament" })
|
self.net:send({ type = "start_tournament" })
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -475,7 +536,7 @@ local function drawTitle(text)
|
|||||||
Font.draw(text, 8, 6)
|
Font.draw(text, 8, 6)
|
||||||
end
|
end
|
||||||
|
|
||||||
local SETTINGS_LABELS = { "POKEMON", "MIN LV", "MAX LV", "TIMER", "PLAYING" }
|
local SETTINGS_LABELS = { "POKEMON", "MIN LV", "MAX LV", "TIMER", "LEVELS", "PLAYING" }
|
||||||
|
|
||||||
function Tournament:draw()
|
function Tournament:draw()
|
||||||
if self.stage == "menu" then
|
if self.stage == "menu" then
|
||||||
@@ -491,6 +552,7 @@ function Tournament:draw()
|
|||||||
levelLabel(self.settings.minLevel),
|
levelLabel(self.settings.minLevel),
|
||||||
levelLabel(self.settings.maxLevel),
|
levelLabel(self.settings.maxLevel),
|
||||||
self.settings.turnLimit .. "s",
|
self.settings.turnLimit .. "s",
|
||||||
|
forceLevelLabel(self.settings.forceLevel),
|
||||||
self.settings.participating and "YES" or "NO",
|
self.settings.participating and "YES" or "NO",
|
||||||
}
|
}
|
||||||
for i, label in ipairs(SETTINGS_LABELS) do
|
for i, label in ipairs(SETTINGS_LABELS) do
|
||||||
|
|||||||
@@ -0,0 +1,366 @@
|
|||||||
|
-- Launcher-side mod surface (18/launcher redesign): the mods panel runs
|
||||||
|
-- BEFORE Game:load, so this NEVER loads a mod entry chunk -- it scans
|
||||||
|
-- manifests only. The full loader (src/mods/Loader.lua) still owns the real
|
||||||
|
-- load at boot; this reads the same options.mods enable-state the loader
|
||||||
|
-- writes, derives per-mod status with the pure ManagerState.resolveToggle,
|
||||||
|
-- installs a dropped/chosen .zip into the save-dir "mods/<id>/" tree, and
|
||||||
|
-- uninstalls a mod by removing that tree + clearing options.mods[id].
|
||||||
|
--
|
||||||
|
-- Split in two: the pure derivation (deriveList, locateRoot) has no love and
|
||||||
|
-- no filesystem, so the engine tier can table-drive it; the discovery,
|
||||||
|
-- install, and uninstall paths reach for love.filesystem and SaveData.
|
||||||
|
|
||||||
|
local Manifest = require("src.mods.Manifest")
|
||||||
|
local ManagerState = require("src.mods.ManagerState")
|
||||||
|
local Semver = require("src.mods.Semver")
|
||||||
|
local Version = require("src.core.Version")
|
||||||
|
local SaveData = require("src.core.SaveData")
|
||||||
|
|
||||||
|
local LauncherMods = {}
|
||||||
|
|
||||||
|
-- ------- pure status derivation
|
||||||
|
|
||||||
|
-- A hard-dependency / conflict / version verdict for one manifest. mods is
|
||||||
|
-- the id -> validated-manifest map resolveToggle reads (its dependencySpecs,
|
||||||
|
-- conflictSpecs, version and game_version are exactly the fields the loader's
|
||||||
|
-- Manifest.validate produced); enabledSet is the current desired enable-set.
|
||||||
|
local function statusFor(mods, id, enabledSet, enabled)
|
||||||
|
local m = mods[id]
|
||||||
|
-- conflict only bites an enabled mod: resolveToggle's conflict list is
|
||||||
|
-- bidirectional (this mod's conflicts spec vs an enabled other, and an
|
||||||
|
-- enabled other's spec vs this mod), which is exactly the launcher chip.
|
||||||
|
if enabled then
|
||||||
|
local r = ManagerState.resolveToggle(mods, id, true, enabledSet)
|
||||||
|
if #r.conflicts > 0 then
|
||||||
|
local otherId = r.conflicts[1]
|
||||||
|
local other = mods[otherId]
|
||||||
|
return "conflict",
|
||||||
|
"Conflicts with " .. ((other and other.name) or otherId)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
-- warn: the engine is outside the mod's game_version range
|
||||||
|
if m.game_version
|
||||||
|
and not Semver.satisfies(Version.engine, m.game_version) then
|
||||||
|
return "warn", "Needs engine " .. m.game_version
|
||||||
|
.. " (have " .. Version.engine .. ")"
|
||||||
|
end
|
||||||
|
-- warn: a hard dependency is absent, switched off, or the wrong version.
|
||||||
|
-- resolveToggle would cascade-enable a merely-disabled dep rather than flag
|
||||||
|
-- it, so the disabled case is judged straight off the manifest here.
|
||||||
|
for _, spec in ipairs(m.dependencySpecs or {}) do
|
||||||
|
local dep = mods[spec.id]
|
||||||
|
if not dep then
|
||||||
|
return "warn", "Needs " .. spec.id .. " (not installed)"
|
||||||
|
elseif not enabledSet[spec.id] then
|
||||||
|
return "warn", "Needs " .. spec.id .. " (disabled)"
|
||||||
|
elseif spec.range
|
||||||
|
and not Semver.satisfies(dep.version, spec.range) then
|
||||||
|
return "warn", "Needs " .. spec.id .. " " .. spec.range
|
||||||
|
end
|
||||||
|
end
|
||||||
|
return "ok", "Ready"
|
||||||
|
end
|
||||||
|
|
||||||
|
-- deriveList(manifests, options) -> the panel row list, pure.
|
||||||
|
-- manifests is an array of validated manifests (Manifest.validate output);
|
||||||
|
-- options is the options table (only options.mods is read). Rows come back
|
||||||
|
-- sorted by id so the panel order is stable.
|
||||||
|
function LauncherMods.deriveList(manifests, options)
|
||||||
|
local mods = options and options.mods or {}
|
||||||
|
local ordered = {}
|
||||||
|
for _, m in ipairs(manifests) do ordered[#ordered + 1] = m end
|
||||||
|
table.sort(ordered, function(a, b) return a.id < b.id end)
|
||||||
|
|
||||||
|
local byId, enabledSet = {}, {}
|
||||||
|
for _, m in ipairs(ordered) do
|
||||||
|
byId[m.id] = m
|
||||||
|
-- missing entry means enabled, matching the loader's default
|
||||||
|
if mods[m.id] ~= false then enabledSet[m.id] = true end
|
||||||
|
end
|
||||||
|
|
||||||
|
local out = {}
|
||||||
|
for _, m in ipairs(ordered) do
|
||||||
|
local enabled = enabledSet[m.id] == true
|
||||||
|
local status, detail = statusFor(byId, m.id, enabledSet, enabled)
|
||||||
|
local raw = m.raw or {}
|
||||||
|
out[#out + 1] = {
|
||||||
|
id = m.id,
|
||||||
|
name = m.name or m.id,
|
||||||
|
version = m.version,
|
||||||
|
-- category, then profile, then a generic fallback -- uppercased
|
||||||
|
badge = tostring(raw.category or m.profile or "MOD"):upper(),
|
||||||
|
description = m.description or "",
|
||||||
|
enabled = enabled,
|
||||||
|
status = status,
|
||||||
|
statusDetail = detail,
|
||||||
|
}
|
||||||
|
end
|
||||||
|
return out
|
||||||
|
end
|
||||||
|
|
||||||
|
-- locateRoot(paths) -> the mod-root prefix inside a mounted archive, pure.
|
||||||
|
-- paths is a shallow listing: top-level file names as-is, and for a top-level
|
||||||
|
-- directory a "<dir>/manifest.json" entry when it holds one. Returns "" when
|
||||||
|
-- the manifest sits at the archive root, "<dir>" when a single top-level
|
||||||
|
-- folder holds it, or nil + a user-presentable reason.
|
||||||
|
function LauncherMods.locateRoot(paths)
|
||||||
|
for _, p in ipairs(paths) do
|
||||||
|
if p == "manifest.json" then return "" end
|
||||||
|
end
|
||||||
|
local topDirs, seen, hasManifest = {}, {}, {}
|
||||||
|
for _, p in ipairs(paths) do
|
||||||
|
local top, rest = p:match("^([^/]+)/(.+)$")
|
||||||
|
if top then
|
||||||
|
if not seen[top] then
|
||||||
|
seen[top] = true
|
||||||
|
topDirs[#topDirs + 1] = top
|
||||||
|
end
|
||||||
|
if rest == "manifest.json" then hasManifest[top] = true end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
if #topDirs == 1 and hasManifest[topDirs[1]] then return topDirs[1] end
|
||||||
|
if #topDirs > 1 then
|
||||||
|
return nil, "the .zip must contain a single mod folder"
|
||||||
|
end
|
||||||
|
return nil, "no manifest.json found in the .zip"
|
||||||
|
end
|
||||||
|
|
||||||
|
-- ------- discovery (love.filesystem)
|
||||||
|
|
||||||
|
local function decodeManifest(raw, path)
|
||||||
|
local Json = require("src.link.Json")
|
||||||
|
local data, decodeErr = Json.decode(raw)
|
||||||
|
if not data then return nil, decodeErr end
|
||||||
|
local ok, manifest = pcall(Manifest.validate, data, path)
|
||||||
|
if not ok then return nil, manifest end
|
||||||
|
return manifest
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Scan "mods/" one level deep for valid manifests (mirrors Loader:_discover,
|
||||||
|
-- but validates only -- no entry chunk is ever loaded). First id wins on a
|
||||||
|
-- duplicate. Returns an array of validated manifests.
|
||||||
|
local function discover()
|
||||||
|
local fs = love and love.filesystem
|
||||||
|
local out = {}
|
||||||
|
if not (fs and fs.getInfo and fs.getDirectoryItems) then return out end
|
||||||
|
if not fs.getInfo("mods") then return out end
|
||||||
|
local seen = {}
|
||||||
|
for _, name in ipairs(fs.getDirectoryItems("mods")) do
|
||||||
|
local path = "mods/" .. name
|
||||||
|
local info = fs.getInfo(path)
|
||||||
|
if info and info.type == "directory" then
|
||||||
|
local raw = fs.read(path .. "/manifest.json")
|
||||||
|
if raw then
|
||||||
|
local manifest = decodeManifest(raw, path)
|
||||||
|
if manifest and not seen[manifest.id] then
|
||||||
|
seen[manifest.id] = true
|
||||||
|
out[#out + 1] = manifest
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
return out
|
||||||
|
end
|
||||||
|
|
||||||
|
-- list() -> the mods-panel rows for the current install. Reads the same
|
||||||
|
-- options.mods enable-state the loader persists, so a toggle here is what the
|
||||||
|
-- game sees on its next boot.
|
||||||
|
function LauncherMods.list()
|
||||||
|
local options = SaveData.loadOptions()
|
||||||
|
return LauncherMods.deriveList(discover(), options)
|
||||||
|
end
|
||||||
|
|
||||||
|
-- setEnabled(id, enabled): persist options.mods[id] in the exact shape
|
||||||
|
-- Loader:_saveState writes (a plain boolean), so the running game and the
|
||||||
|
-- in-game ManagerState pick it up unchanged.
|
||||||
|
function LauncherMods.setEnabled(id, enabled)
|
||||||
|
local options = SaveData.loadOptions()
|
||||||
|
options.mods = options.mods or {}
|
||||||
|
options.mods[id] = enabled and true or false
|
||||||
|
SaveData.saveOptions(options)
|
||||||
|
return true
|
||||||
|
end
|
||||||
|
|
||||||
|
-- ------- install (love.filesystem)
|
||||||
|
|
||||||
|
-- Read a .zip source into bytes. A string is an external absolute path (like
|
||||||
|
-- a chosen ROM) read with io.*, falling back to a save-dir-relative
|
||||||
|
-- love.filesystem read; a love DroppedFile is opened the way RomImporter
|
||||||
|
-- ingests dropped ROMs.
|
||||||
|
local function readArchive(source)
|
||||||
|
local t = type(source)
|
||||||
|
if (t == "userdata" or t == "table") and type(source.open) == "function" then
|
||||||
|
local ok = source:open("r")
|
||||||
|
if not ok then return nil, "could not open the dropped file" end
|
||||||
|
local data = source:read(source:getSize())
|
||||||
|
source:close()
|
||||||
|
if not data then return nil, "the dropped file could not be read" end
|
||||||
|
return data
|
||||||
|
end
|
||||||
|
if t == "string" then
|
||||||
|
local f = io.open(source, "rb")
|
||||||
|
if f then
|
||||||
|
local data = f:read("*a")
|
||||||
|
f:close()
|
||||||
|
if not data then return nil, "could not read " .. source end
|
||||||
|
return data
|
||||||
|
end
|
||||||
|
if love and love.filesystem then
|
||||||
|
local data = love.filesystem.read(source)
|
||||||
|
if data then return data end
|
||||||
|
end
|
||||||
|
return nil, "could not open " .. source
|
||||||
|
end
|
||||||
|
return nil, "unsupported archive source"
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Shallow listing of a mounted archive shaped for locateRoot: files by name,
|
||||||
|
-- and for each top-level directory a "<dir>/manifest.json" marker only when it
|
||||||
|
-- actually holds one (so a lone folder with no manifest still reads as empty).
|
||||||
|
local function topLevelPaths(mount)
|
||||||
|
local fs = love.filesystem
|
||||||
|
local paths = {}
|
||||||
|
for _, name in ipairs(fs.getDirectoryItems(mount)) do
|
||||||
|
local info = fs.getInfo(mount .. "/" .. name)
|
||||||
|
if info and info.type == "directory" then
|
||||||
|
if fs.getInfo(mount .. "/" .. name .. "/manifest.json", "file") then
|
||||||
|
paths[#paths + 1] = name .. "/manifest.json"
|
||||||
|
end
|
||||||
|
else
|
||||||
|
paths[#paths + 1] = name
|
||||||
|
end
|
||||||
|
end
|
||||||
|
return paths
|
||||||
|
end
|
||||||
|
|
||||||
|
local function copyTree(src, dst)
|
||||||
|
local fs = love.filesystem
|
||||||
|
if not fs.createDirectory(dst) then
|
||||||
|
return nil, "could not create " .. dst
|
||||||
|
end
|
||||||
|
for _, name in ipairs(fs.getDirectoryItems(src)) do
|
||||||
|
local s = src .. "/" .. name
|
||||||
|
local d = dst .. "/" .. name
|
||||||
|
local info = fs.getInfo(s)
|
||||||
|
if info and info.type == "directory" then
|
||||||
|
local ok, err = copyTree(s, d)
|
||||||
|
if not ok then return nil, err end
|
||||||
|
else
|
||||||
|
local data = fs.read(s)
|
||||||
|
if data == nil then return nil, "could not read " .. name end
|
||||||
|
local ok, err = fs.write(d, data)
|
||||||
|
if not ok then return nil, "could not write " .. name .. ": " .. tostring(err) end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
return true
|
||||||
|
end
|
||||||
|
|
||||||
|
local function removeTree(path)
|
||||||
|
local fs = love.filesystem
|
||||||
|
local info = fs.getInfo(path)
|
||||||
|
if not info then return end
|
||||||
|
if info.type == "directory" then
|
||||||
|
for _, child in ipairs(fs.getDirectoryItems(path)) do
|
||||||
|
removeTree(path .. "/" .. child)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
fs.remove(path)
|
||||||
|
end
|
||||||
|
|
||||||
|
-- installZip(source) -> true, id | nil, errString
|
||||||
|
-- source is an external path or a love DroppedFile. The archive is validated
|
||||||
|
-- BEFORE anything is copied; every path unmounts and clears the staged temp
|
||||||
|
-- file, and a failed copy rolls its partial tree back. A dropped file outside
|
||||||
|
-- the save dir is staged into a save-dir temp first, because
|
||||||
|
-- love.filesystem.mount only reaches a save-directory-relative path.
|
||||||
|
function LauncherMods.installZip(source)
|
||||||
|
if not (love and love.filesystem) then
|
||||||
|
return nil, "mod install needs LOVE"
|
||||||
|
end
|
||||||
|
local fs = love.filesystem
|
||||||
|
local data, readErr = readArchive(source)
|
||||||
|
if not data then return nil, readErr end
|
||||||
|
|
||||||
|
-- stage into a save-dir temp so mount can reach it
|
||||||
|
local tmp = ("mod_import_%d_%d.zip"):format(os.time(), math.random(0, 999999))
|
||||||
|
local ok, writeErr = fs.write(tmp, data)
|
||||||
|
if not ok then
|
||||||
|
return nil, "could not stage the .zip: " .. tostring(writeErr)
|
||||||
|
end
|
||||||
|
local mount = "mod_import_mount"
|
||||||
|
if not fs.mount(tmp, mount) then
|
||||||
|
fs.remove(tmp)
|
||||||
|
return nil, "that .zip could not be opened"
|
||||||
|
end
|
||||||
|
local function cleanup()
|
||||||
|
pcall(fs.unmount, tmp)
|
||||||
|
fs.remove(tmp)
|
||||||
|
end
|
||||||
|
|
||||||
|
local prefix, rootErr = LauncherMods.locateRoot(topLevelPaths(mount))
|
||||||
|
if not prefix then
|
||||||
|
cleanup()
|
||||||
|
return nil, rootErr
|
||||||
|
end
|
||||||
|
local root = prefix == "" and mount or (mount .. "/" .. prefix)
|
||||||
|
|
||||||
|
local raw = fs.read(root .. "/manifest.json")
|
||||||
|
if not raw then
|
||||||
|
cleanup()
|
||||||
|
return nil, "the .zip has no readable manifest.json"
|
||||||
|
end
|
||||||
|
local manifest, manifestErr = decodeManifest(raw, root)
|
||||||
|
if not manifest then
|
||||||
|
cleanup()
|
||||||
|
return nil, "invalid mod manifest: " .. tostring(manifestErr)
|
||||||
|
end
|
||||||
|
|
||||||
|
-- reject a duplicate before touching the mods tree
|
||||||
|
local dest = "mods/" .. manifest.id
|
||||||
|
if fs.getInfo(dest) then
|
||||||
|
cleanup()
|
||||||
|
return nil, "a mod named '" .. manifest.id .. "' is already installed"
|
||||||
|
end
|
||||||
|
|
||||||
|
fs.createDirectory("mods")
|
||||||
|
local copied, copyErr = copyTree(root, dest)
|
||||||
|
if not copied then
|
||||||
|
removeTree(dest)
|
||||||
|
cleanup()
|
||||||
|
return nil, copyErr or "could not copy the mod files"
|
||||||
|
end
|
||||||
|
cleanup()
|
||||||
|
return true, manifest.id
|
||||||
|
end
|
||||||
|
|
||||||
|
-- uninstall(id) -> true | nil, errString
|
||||||
|
-- Removes mods/<id>/ from the save directory and clears options.mods[id] so the
|
||||||
|
-- loader and in-game manager no longer see it. Rejects unknown / missing ids.
|
||||||
|
-- Does not touch other mods' enable state.
|
||||||
|
function LauncherMods.uninstall(id)
|
||||||
|
if type(id) ~= "string" or id == "" then
|
||||||
|
return nil, "missing mod id"
|
||||||
|
end
|
||||||
|
if id:find("[/\\]") or id == "." or id == ".." then
|
||||||
|
return nil, "invalid mod id"
|
||||||
|
end
|
||||||
|
if not (love and love.filesystem) then
|
||||||
|
return nil, "mod uninstall needs LOVE"
|
||||||
|
end
|
||||||
|
local fs = love.filesystem
|
||||||
|
local dest = "mods/" .. id
|
||||||
|
if not fs.getInfo(dest) then
|
||||||
|
return nil, "mod '" .. id .. "' is not installed"
|
||||||
|
end
|
||||||
|
removeTree(dest)
|
||||||
|
-- Drop the enable flag so a reinstall of the same id starts from the
|
||||||
|
-- loader's default (enabled) rather than a stale false.
|
||||||
|
local options = SaveData.loadOptions()
|
||||||
|
if options.mods and options.mods[id] ~= nil then
|
||||||
|
options.mods[id] = nil
|
||||||
|
SaveData.saveOptions(options)
|
||||||
|
end
|
||||||
|
return true
|
||||||
|
end
|
||||||
|
|
||||||
|
return LauncherMods
|
||||||
@@ -68,6 +68,7 @@ local devShim = { installed = false, permissions = {}, warned = {}, depth = 0 }
|
|||||||
local SUPPORTED_REQUIRES = {
|
local SUPPORTED_REQUIRES = {
|
||||||
["src.mods.Semver"] = true,
|
["src.mods.Semver"] = true,
|
||||||
["src.audio.ChipAsm"] = true,
|
["src.audio.ChipAsm"] = true,
|
||||||
|
["src.pokemon.Stats"] = true, -- Stats.isShiny / calc for indicator mods
|
||||||
}
|
}
|
||||||
|
|
||||||
local function scanRequire(name)
|
local function scanRequire(name)
|
||||||
|
|||||||
@@ -40,6 +40,19 @@ function f.int(min, max)
|
|||||||
end }
|
end }
|
||||||
end
|
end
|
||||||
|
|
||||||
|
-- a bounded float; like f.int but keeps the fractional part (scales,
|
||||||
|
-- gains). Rejects out-of-range with the "expected number a..b" message.
|
||||||
|
function f.numRange(min, max)
|
||||||
|
local desc = "number"
|
||||||
|
if min and max then desc = ("number %s..%s"):format(min, max)
|
||||||
|
elseif min then desc = ("number >= %s"):format(min) end
|
||||||
|
return { kind = "num", min = min, max = max, desc = desc,
|
||||||
|
check = function(v)
|
||||||
|
return type(v) == "number"
|
||||||
|
and (min == nil or v >= min) and (max == nil or v <= max)
|
||||||
|
end }
|
||||||
|
end
|
||||||
|
|
||||||
function f.enum(values)
|
function f.enum(values)
|
||||||
local set = {}
|
local set = {}
|
||||||
for _, value in ipairs(values) do set[value] = true end
|
for _, value in ipairs(values) do set[value] = true end
|
||||||
@@ -420,6 +433,12 @@ R.pokemon = {
|
|||||||
frames = f.opt(f.int(1)) } }),
|
frames = f.opt(f.int(1)) } }),
|
||||||
cry = f.opt(f.id("cries")), palette = f.opt(f.id("palettes")),
|
cry = f.opt(f.id("cries")), palette = f.opt(f.id("palettes")),
|
||||||
trueColor = f.opt(f.bool),
|
trueColor = f.opt(f.bool),
|
||||||
|
-- battle-pic scale overrides for this species' own pics: front is the
|
||||||
|
-- enemy pic (default 1x), back is the player pic (default 2x). An
|
||||||
|
-- image-level battle_sprite_scales entry for the same path beats these.
|
||||||
|
-- The pic stays grounded (feet pinned) at any scale; see docs/modding.md.
|
||||||
|
battleScaleFront = f.opt(f.numRange(0.25, 4.0)),
|
||||||
|
battleScaleBack = f.opt(f.numRange(0.25, 4.0)),
|
||||||
},
|
},
|
||||||
example = 'mod.content.pokemon:patch("MEW", { baseStats = { attack = 120 } })',
|
example = 'mod.content.pokemon:patch("MEW", { baseStats = { attack = 120 } })',
|
||||||
}
|
}
|
||||||
@@ -791,6 +810,118 @@ R.transitions = {
|
|||||||
example = 'mod.content.transitions:register("dissolve", { frames = 30, draw = fn })',
|
example = 'mod.content.transitions:register("dissolve", { frames = 30, draw = fn })',
|
||||||
}
|
}
|
||||||
|
|
||||||
|
-- ------- rendering pipelines
|
||||||
|
--
|
||||||
|
-- A pipeline is a display mode that owns part of the frame: it may replace
|
||||||
|
-- the overworld's world pass with geometry of its own (drawWorld) and/or
|
||||||
|
-- post-process the finished composite (present). Everything around that --
|
||||||
|
-- the OFF/1/2/3 ladder, its options row, its hotkey, persistence in
|
||||||
|
-- save.options.pipelines and the gating that keeps it out of battles and
|
||||||
|
-- menus -- is engine plumbing driven from this record, so a renderer mod
|
||||||
|
-- declares what it is and writes only the two draw functions.
|
||||||
|
--
|
||||||
|
-- Both callbacks are optional and independent: a present-only pipeline is a
|
||||||
|
-- post-process (bloom, tilt-shift, a CRT curve) that leaves whatever
|
||||||
|
-- rendered the frame alone, and a drawWorld-only pipeline is a world
|
||||||
|
-- renderer that composites straight. See src/render/Pipelines.lua for the
|
||||||
|
-- ctx each receives and docs/modding.md for the worked example.
|
||||||
|
R.render_pipelines = {
|
||||||
|
semantics = "record", target = "render_pipelines",
|
||||||
|
fields = {
|
||||||
|
-- shown in the options menu; the ladder labels default to OFF/ON
|
||||||
|
label = f.str,
|
||||||
|
levels = f.opt(f.list(f.str)),
|
||||||
|
-- keyboard key that cycles the ladder, checked after the engine's own
|
||||||
|
-- display hotkeys so a pipeline can never shadow one
|
||||||
|
hotkey = f.opt(f.str),
|
||||||
|
-- higher wins when two world pipelines are somehow active at once;
|
||||||
|
-- also the options-row order, so a mode and its post-process sort
|
||||||
|
-- together instead of by id
|
||||||
|
priority = f.opt(f.num),
|
||||||
|
-- hardware/driver gate, checked every frame: false keeps the vanilla
|
||||||
|
-- 2D path, which is what a headless run and a driver with no depth
|
||||||
|
-- canvas both get
|
||||||
|
available = f.opt(f.fn),
|
||||||
|
-- (top, overworld) -> boolean: whether the player may CHANGE the mode
|
||||||
|
-- right now. Defaults to the survey-zoom gate (free-roam overworld
|
||||||
|
-- only), which keeps a hotkey press from switching modes mid-warp or
|
||||||
|
-- mid-cutscene. It has no say over whether an already-on mode draws:
|
||||||
|
-- a mode that stopped rendering during a warp would flash the flat 2D
|
||||||
|
-- world every time the player walked through a door.
|
||||||
|
gate = f.opt(f.fn),
|
||||||
|
-- (dt, level): presentational tweens, ticked on real frame time
|
||||||
|
update = f.opt(f.fn),
|
||||||
|
-- (ctx) -> canvas | nil: render the world. nil falls back to the
|
||||||
|
-- vanilla flat/tilt draw for this frame.
|
||||||
|
drawWorld = f.opt(f.fn),
|
||||||
|
-- (canvas, ctx) -> canvas: post-process the WORLD image, before the UI
|
||||||
|
-- composites over it -- a depth-of-field or colour grade that must not
|
||||||
|
-- touch the dialog boxes and menus sitting on top. Only runs when some
|
||||||
|
-- pipeline rendered the world, since the vanilla world pass has no
|
||||||
|
-- single finished image to hand over.
|
||||||
|
worldPresent = f.opt(f.fn),
|
||||||
|
-- (canvas, ctx) -> canvas: post-process the whole finished composite,
|
||||||
|
-- world and UI alike (a CRT curve, a full-screen grade). Must return a
|
||||||
|
-- canvas; the input unchanged is the correct answer when the effect is
|
||||||
|
-- off.
|
||||||
|
present = f.opt(f.fn),
|
||||||
|
-- drop GPU objects (window resize, hot reload, mode switch)
|
||||||
|
invalidate = f.opt(f.fn),
|
||||||
|
},
|
||||||
|
-- a pipeline that does neither half is dead weight and would silently
|
||||||
|
-- occupy an options row and a hotkey
|
||||||
|
extra = function(_, value)
|
||||||
|
if value.drawWorld == nil and value.present == nil
|
||||||
|
and value.worldPresent == nil then
|
||||||
|
return "a render pipeline needs drawWorld, worldPresent or present"
|
||||||
|
end
|
||||||
|
end,
|
||||||
|
-- A pipeline callback fails at play time, long after the load phase has
|
||||||
|
-- handed its report to the mod manager, so the merge leaves behind who
|
||||||
|
-- wrote each record for Pipelines to name in the failure -- the same
|
||||||
|
-- provenance trick the audio registries use (Loader.stampAudioOwners).
|
||||||
|
-- Placement is otherwise the default record merge.
|
||||||
|
write = function(target, registry)
|
||||||
|
local owners, tombstones = {}, {}
|
||||||
|
for id in pairs(registry.ops) do
|
||||||
|
local value = registry:get(id)
|
||||||
|
if value == nil then
|
||||||
|
tombstones[#tombstones + 1] = id
|
||||||
|
else
|
||||||
|
target[id] = value
|
||||||
|
local owner = registry.owners[id]
|
||||||
|
if owner and owner ~= Schemas.ENGINE then owners[id] = owner end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
for _, id in ipairs(tombstones) do target[id] = nil end
|
||||||
|
target._owners = owners
|
||||||
|
end,
|
||||||
|
example = 'mod.content.render_pipelines:register("voxel", ' ..
|
||||||
|
'{ label = "VOXEL", levels = { "OFF", "15", "35", "50" }, drawWorld = fn })',
|
||||||
|
}
|
||||||
|
|
||||||
|
-- ------- battle sprite scales
|
||||||
|
--
|
||||||
|
-- Per-image battle-pic scale overrides, keyed by record id and consulted
|
||||||
|
-- by asset path at draw time. Where a species' battleScaleFront /
|
||||||
|
-- battleScaleBack scales its own front/back pic, this scales ANY battle
|
||||||
|
-- pic by the path it is drawn from -- the only handle on the non-species
|
||||||
|
-- pics like the player's trainer back sprite. Image-level beats
|
||||||
|
-- species-level; both compose with the send-out grow and keep the sprite
|
||||||
|
-- grounded (feet pinned) at whatever scale. See docs/modding.md.
|
||||||
|
R.battle_sprite_scales = {
|
||||||
|
semantics = "record", target = "battle_sprite_scales",
|
||||||
|
fields = {
|
||||||
|
-- the asset path exactly as data references it, e.g.
|
||||||
|
-- "assets/generated/battle/back/abrab.png"
|
||||||
|
path = f.path,
|
||||||
|
-- 1 = native pixels; the drawn size relative to the pic's own pixels
|
||||||
|
scale = f.numRange(0.25, 4.0),
|
||||||
|
},
|
||||||
|
example = 'mod.content.battle_sprite_scales:register("abra_back", ' ..
|
||||||
|
'{ path = "assets/generated/battle/back/abrab.png", scale = 1.5 })',
|
||||||
|
}
|
||||||
|
|
||||||
-- ------- progression
|
-- ------- progression
|
||||||
|
|
||||||
R.evolution_methods = {
|
R.evolution_methods = {
|
||||||
|
|||||||
@@ -106,11 +106,55 @@ function Evolution.apply(game, mon, newSpecies, via)
|
|||||||
})
|
})
|
||||||
end
|
end
|
||||||
|
|
||||||
|
-- After the "evolved into" text, Gen1 re-runs the level-up learn check on
|
||||||
|
-- the EVOLVED species (engine/pokemon/evos_moves.asm EvolveMon calls the
|
||||||
|
-- LearnMoveFromLevelUp predef, engine/pokemon/learn_move.asm) -- a mon
|
||||||
|
-- evolving at exactly a learnset level gains that move (GYARADOS learns
|
||||||
|
-- BITE at 20, so MAGIKARP->GYARADOS @20 learns BITE, @21 does not) (#12).
|
||||||
|
-- Mirrors the rare-candy learn loop in src/ui/BagMenu.lua so a full move
|
||||||
|
-- list opens the forget prompt. mon.species is already the new species
|
||||||
|
-- (Evolution.apply ran before the congrats text). onDone runs once the
|
||||||
|
-- learn list is exhausted, replacing the caller's direct onDone.
|
||||||
|
function Evolution.learnEvolutionMoves(game, mon, onDone)
|
||||||
|
local Experience = require("src.battle.Experience")
|
||||||
|
local def = game.data.pokemon[mon.species]
|
||||||
|
-- movesLearnedAt uses entry.level == level (exact Gen1 rule); do NOT use
|
||||||
|
-- Pokemon.movesAtLevel (<= level), which would over-grant older moves.
|
||||||
|
local moves = Experience.movesLearnedAt(def, mon.level)
|
||||||
|
local i = 0
|
||||||
|
local function nextStep()
|
||||||
|
i = i + 1
|
||||||
|
local moveId = moves[i]
|
||||||
|
if not moveId then
|
||||||
|
if onDone then onDone() end
|
||||||
|
return
|
||||||
|
end
|
||||||
|
for _, mv in ipairs(mon.moves) do
|
||||||
|
if mv.id == moveId then return nextStep() end
|
||||||
|
end
|
||||||
|
local mdef = game.data.moves[moveId]
|
||||||
|
if not mdef then return nextStep() end
|
||||||
|
local name = mon.nickname or def.name
|
||||||
|
if #mon.moves < 4 then
|
||||||
|
table.insert(mon.moves, { id = moveId, pp = mdef.pp })
|
||||||
|
Runtime.emit("pokemon.move_learned", { mon = mon, moveId = moveId })
|
||||||
|
game.stack:push(TextBox.new(game,
|
||||||
|
("%s learned\n%s!"):format(name, mdef.name), nextStep))
|
||||||
|
else
|
||||||
|
-- LearnMoveFromLevelUp with a full moveset: the forget UI
|
||||||
|
Screens.push(game, "MoveLearnMenu", mon, moveId, nextStep)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
nextStep()
|
||||||
|
end
|
||||||
|
|
||||||
-- Play the evolution movie (flashing forms), then apply + text.
|
-- Play the evolution movie (flashing forms), then apply + text.
|
||||||
-- Headless (no real graphics) falls back to the plain text flow.
|
-- Headless (no real graphics) falls back to the plain text flow.
|
||||||
function Evolution.evolve(game, mon, newSpecies, onDone, via)
|
function Evolution.evolve(game, mon, newSpecies, onDone, via)
|
||||||
if love.image and love.image.newImageData then
|
if love.image and love.image.newImageData then
|
||||||
Screens.push(game, "EvolutionState", mon, newSpecies, onDone)
|
-- forward `via` so EvolutionState can keep trade evolutions
|
||||||
|
-- non-cancelable (LINK_STATE_TRADING) while others accept B (#213)
|
||||||
|
Screens.push(game, "EvolutionState", mon, newSpecies, onDone, via)
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
Music.play(game.data, Music.special(game.data, "evolution"))
|
Music.play(game.data, Music.special(game.data, "evolution"))
|
||||||
@@ -120,7 +164,9 @@ function Evolution.evolve(game, mon, newSpecies, onDone, via)
|
|||||||
:format(oldName, oldName, game.data.pokemon[newSpecies].name)
|
:format(oldName, oldName, game.data.pokemon[newSpecies].name)
|
||||||
game.stack:push(TextBox.new(game, msg, function()
|
game.stack:push(TextBox.new(game, msg, function()
|
||||||
Music.restoreMap(game.data)
|
Music.restoreMap(game.data)
|
||||||
if onDone then onDone() end
|
-- re-run the evolved species' level-up learn check before onDone
|
||||||
|
-- (evos_moves.asm EvolveMon -> learn_move.asm LearnMoveFromLevelUp, #12)
|
||||||
|
Evolution.learnEvolutionMoves(game, mon, onDone)
|
||||||
end))
|
end))
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -138,13 +184,22 @@ function Evolution.request(game, mon, trigger, onDone)
|
|||||||
return species
|
return species
|
||||||
end
|
end
|
||||||
|
|
||||||
-- After-battle hook: evolve everyone who qualifies (queued one at a time).
|
-- After-battle hook: evolve mons that leveled this battle and still
|
||||||
function Evolution.checkParty(game, onDone)
|
-- qualify (queued one at a time, party order). Gen1 EvolveAfterBattle
|
||||||
|
-- only considers mons that gained a level during the fight — a B-cancel
|
||||||
|
-- means "not this time", and the next offer waits for the next level-up
|
||||||
|
-- (or Rare Candy / stone, which call Evolution.evolve directly).
|
||||||
|
-- leveledUp is a set of party mon tables; nil/empty yields no evolutions.
|
||||||
|
function Evolution.checkParty(game, onDone, leveledUp)
|
||||||
local pending = {}
|
local pending = {}
|
||||||
for _, mon in ipairs(game.save.party) do
|
if leveledUp then
|
||||||
local target, evo = Evolution.pendingFor(game, mon, { kind = "levelup" })
|
for _, mon in ipairs(game.save.party) do
|
||||||
if target then
|
if leveledUp[mon] then
|
||||||
table.insert(pending, { mon = mon, to = target, via = evo and evo.method })
|
local target, evo = Evolution.pendingFor(game, mon, { kind = "levelup" })
|
||||||
|
if target then
|
||||||
|
table.insert(pending, { mon = mon, to = target, via = evo and evo.method })
|
||||||
|
end
|
||||||
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
local i = 0
|
local i = 0
|
||||||
|
|||||||
@@ -29,6 +29,34 @@ function Pokemon.movesAtLevel(speciesDef, level)
|
|||||||
return moves
|
return moves
|
||||||
end
|
end
|
||||||
|
|
||||||
|
-- Day Care retrieve (pokered WriteMonMoves + wLearningMovesFromDayCare):
|
||||||
|
-- grant learnset moves with startLevel < moveLevel <= newLevel, shifting
|
||||||
|
-- the oldest slot out when full. Silent — no LearnMove prompts.
|
||||||
|
function Pokemon.learnMovesFromDayCare(data, mon, speciesDef, startLevel, newLevel)
|
||||||
|
if not (speciesDef and speciesDef.learnset and mon) then return end
|
||||||
|
mon.moves = mon.moves or {}
|
||||||
|
for _, entry in ipairs(speciesDef.learnset) do
|
||||||
|
local moveLevel = entry.level
|
||||||
|
if moveLevel > newLevel then break end
|
||||||
|
if moveLevel > startLevel then
|
||||||
|
local known = false
|
||||||
|
for _, mv in ipairs(mon.moves) do
|
||||||
|
if mv.id == entry.move then known = true break end
|
||||||
|
end
|
||||||
|
if not known then
|
||||||
|
local mdef = data.moves[entry.move]
|
||||||
|
local slot = { id = entry.move, pp = mdef and mdef.pp or 0 }
|
||||||
|
if #mon.moves < 4 then
|
||||||
|
mon.moves[#mon.moves + 1] = slot
|
||||||
|
else
|
||||||
|
table.remove(mon.moves, 1)
|
||||||
|
mon.moves[#mon.moves + 1] = slot
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
function Pokemon.new(data, species, level, rng)
|
function Pokemon.new(data, species, level, rng)
|
||||||
local def = data.pokemon[species]
|
local def = data.pokemon[species]
|
||||||
assert(def, "unknown species " .. tostring(species))
|
assert(def, "unknown species " .. tostring(species))
|
||||||
|
|||||||
@@ -0,0 +1,63 @@
|
|||||||
|
-- Runtime Pokémon art resolution. Content registries freeze after load,
|
||||||
|
-- so a mod that lets the player pick an alternate skin mid-session cannot
|
||||||
|
-- patch pokemon.spriteFront / icons.bySpecies. These helpers are the
|
||||||
|
-- sanctioned seam: every battle pic and party icon load goes through
|
||||||
|
-- pokemon.sprite / pokemon.icon, which stay live for the whole process.
|
||||||
|
|
||||||
|
local Runtime = require("src.mods.Runtime")
|
||||||
|
|
||||||
|
local Sprites = {}
|
||||||
|
|
||||||
|
local function samePath(path) return path end
|
||||||
|
|
||||||
|
-- Resolve a battle / menu front or back pic path for `species`.
|
||||||
|
-- side: "front" | "back"
|
||||||
|
-- opts.mon: the live mon when available (per-instance skins)
|
||||||
|
-- opts.kind: "battle" | "summary" | "dex" | "evolution" | "hof" | "trade"
|
||||||
|
-- | "title" | "oak" | "credits" (informational for wrappers)
|
||||||
|
-- Returns path, trueColor.
|
||||||
|
function Sprites.path(data, species, side, opts)
|
||||||
|
opts = opts or {}
|
||||||
|
local def = data and data.pokemon and data.pokemon[species]
|
||||||
|
if not def then return nil, false end
|
||||||
|
local path = side == "back" and def.spriteBack or def.spriteFront
|
||||||
|
local ctx = {
|
||||||
|
species = species,
|
||||||
|
side = side == "back" and "back" or "front",
|
||||||
|
kind = opts.kind or "battle",
|
||||||
|
mon = opts.mon,
|
||||||
|
trueColor = def.trueColor and true or false,
|
||||||
|
data = data,
|
||||||
|
}
|
||||||
|
if path and Runtime.wantsHook("pokemon.sprite") then
|
||||||
|
local hooked = Runtime.call("pokemon.sprite", samePath, path, ctx)
|
||||||
|
if type(hooked) == "string" and hooked ~= "" then path = hooked end
|
||||||
|
end
|
||||||
|
return path, ctx.trueColor and true or false
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Resolve a party-menu icon image path for `mon`.
|
||||||
|
-- vanillaPath is the path PartyMenu already picked from icons.bySpecies /
|
||||||
|
-- def.icon / icons.byDex; the hook may replace it.
|
||||||
|
-- Returns path (possibly nil).
|
||||||
|
function Sprites.iconPath(data, mon, vanillaPath, opts)
|
||||||
|
opts = opts or {}
|
||||||
|
if not vanillaPath and not Runtime.wantsHook("pokemon.icon") then
|
||||||
|
return vanillaPath
|
||||||
|
end
|
||||||
|
local species = mon and mon.species
|
||||||
|
local ctx = {
|
||||||
|
species = species,
|
||||||
|
mon = mon,
|
||||||
|
name = opts.name,
|
||||||
|
data = data,
|
||||||
|
kind = "icon",
|
||||||
|
}
|
||||||
|
if not Runtime.wantsHook("pokemon.icon") then return vanillaPath end
|
||||||
|
local hooked = Runtime.call("pokemon.icon", samePath, vanillaPath, ctx)
|
||||||
|
if type(hooked) == "string" and hooked ~= "" then return hooked end
|
||||||
|
if hooked == nil or hooked == false then return nil end
|
||||||
|
return vanillaPath
|
||||||
|
end
|
||||||
|
|
||||||
|
return Sprites
|
||||||
@@ -57,4 +57,20 @@ function Stats.applyStage(value, stage)
|
|||||||
return math.max(1, math.min(999, v))
|
return math.max(1, math.min(999, v))
|
||||||
end
|
end
|
||||||
|
|
||||||
|
-- Gen 2 shiny formula applied to Gen 1 DVs (the RBY "virtual shiny"):
|
||||||
|
-- Defense/Speed/Special DV == 10 and Attack DV is even-high
|
||||||
|
-- (2, 3, 6, 7, 10, 11, 14, or 15). Used by shiny-indicator mods.
|
||||||
|
local SHINY_ATK = {
|
||||||
|
[2] = true, [3] = true, [6] = true, [7] = true,
|
||||||
|
[10] = true, [11] = true, [14] = true, [15] = true,
|
||||||
|
}
|
||||||
|
|
||||||
|
function Stats.isShiny(dvs)
|
||||||
|
if type(dvs) ~= "table" then return false end
|
||||||
|
return (dvs.defense or 0) == 10
|
||||||
|
and (dvs.speed or 0) == 10
|
||||||
|
and (dvs.special or 0) == 10
|
||||||
|
and SHINY_ATK[dvs.attack or 0] == true
|
||||||
|
end
|
||||||
|
|
||||||
return Stats
|
return Stats
|
||||||
|
|||||||
@@ -77,7 +77,17 @@ end
|
|||||||
-- one-pixel sliver. The fill is tinted with the SGB bar palettes at
|
-- one-pixel sliver. The fill is tinted with the SGB bar palettes at
|
||||||
-- GetHealthBarColor's thresholds (>= 27 px green, >= 10 yellow, else
|
-- GetHealthBarColor's thresholds (>= 27 px green, >= 10 yellow, else
|
||||||
-- red).
|
-- red).
|
||||||
function HudTiles.drawHPBar(data, tx, ty, mon, barType)
|
--
|
||||||
|
-- grayFill (#229): when the caller will colorize this bar with an SGB
|
||||||
|
-- region palette (BattleState's zone pass, BATTLE_ZONES pal 0/1 =
|
||||||
|
-- GetHealthBarColor), leave the fill as its raw DMG shade-2 gray and skip
|
||||||
|
-- the per-pixel tint -- the DMG hardware bar is ONE gray shade recolored by
|
||||||
|
-- the region palette (engine/gfx/palettes.asm SetPal_Battle,
|
||||||
|
-- data/sgb/sgb_packets.asm BlkPacket_Battle), never a per-pixel repaint.
|
||||||
|
-- Tinting first would double-apply the color: GREENBAR's fill {0,189,0} has
|
||||||
|
-- red channel 0, so the tint zeroes the whole bar's red and the zone's
|
||||||
|
-- red-channel-keyed shade shader then maps every pixel to color 3 = black.
|
||||||
|
function HudTiles.drawHPBar(data, tx, ty, mon, barType, grayFill)
|
||||||
local x, y = tx * 8, ty * 8
|
local x, y = tx * 8, ty * 8
|
||||||
HudTiles.tile(0x71, x, y)
|
HudTiles.tile(0x71, x, y)
|
||||||
HudTiles.tile(0x62, x + 8, y)
|
HudTiles.tile(0x62, x + 8, y)
|
||||||
@@ -86,15 +96,17 @@ function HudTiles.drawHPBar(data, tx, ty, mon, barType)
|
|||||||
px = math.max(1, math.floor(mon.hp * 48 / mon.stats.hp))
|
px = math.max(1, math.floor(mon.hp * 48 / mon.stats.hp))
|
||||||
end
|
end
|
||||||
local tint
|
local tint
|
||||||
local PaletteFX = require("src.render.PaletteFX")
|
if not grayFill then
|
||||||
local name = px >= 27 and "GREENBAR" or px >= 10 and "YELLOWBAR" or "REDBAR"
|
local PaletteFX = require("src.render.PaletteFX")
|
||||||
local colors = PaletteFX.pal(data, name)
|
local name = px >= 27 and "GREENBAR" or px >= 10 and "YELLOWBAR" or "REDBAR"
|
||||||
if colors then
|
local colors = PaletteFX.pal(data, name)
|
||||||
local c = colors[3] -- GB color 2 is the fill shade
|
if colors then
|
||||||
-- the fill pixels are the 2/3-gray shade; divide so they land on
|
local c = colors[3] -- GB color 2 is the fill shade
|
||||||
-- the palette color exactly (the black outline stays black)
|
-- the fill pixels are the 2/3-gray shade; divide so they land on
|
||||||
tint = { math.min(1, c[1] / 170), math.min(1, c[2] / 170),
|
-- the palette color exactly (the black outline stays black)
|
||||||
math.min(1, c[3] / 170), 1 }
|
tint = { math.min(1, c[1] / 170), math.min(1, c[2] / 170),
|
||||||
|
math.min(1, c[3] / 170), 1 }
|
||||||
|
end
|
||||||
end
|
end
|
||||||
for i = 0, 5 do
|
for i = 0, 5 do
|
||||||
local seg = math.min(8, math.max(0, px - i * 8))
|
local seg = math.min(8, math.max(0, px - i * 8))
|
||||||
|
|||||||
@@ -50,14 +50,23 @@ PaletteFX.GBC_OBJ = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
-- OG BLUE: Pokemon Blue's Game Boy Color boot-ROM auto-palette. Same
|
-- OG BLUE: Pokemon Blue's Game Boy Color boot-ROM auto-palette. Same
|
||||||
-- one-global-pair scheme as OG RED (Blue also ships no CGB code), but the
|
-- one-global-pair scheme as OG RED (Blue also ships no CGB code), but the boot
|
||||||
-- boot ROM colorizes the background blue instead of red -- so "OG RED" for a
|
-- ROM gives Blue its OWN entry rather than a recolored Red: a light-blue/blue
|
||||||
-- Blue playthrough is white -> light blue -> dark blue -> black, mirroring
|
-- BACKGROUND and -- unlike Red -- a PINK object palette (OBP0). Values from
|
||||||
-- GBC_BG channel-for-channel so the blue reads at the same brightness. The
|
-- Bulbapedia's "List of color palettes ... Generation I" GBC boot-ROM table
|
||||||
-- OBJ (sprite) palette stays the same green, matching how Red and Blue share
|
-- (BG 0x63A5FF/0x0000FF, OBJ 0xFF8484/0x943A3A), confirmed against a Gambatte
|
||||||
-- the green-character look on a Game Boy Color.
|
-- hardware capture. The earlier code mirrored GBC_BG channel-for-channel
|
||||||
|
-- (0x8484FF/0x3A3A94) and reused Red's green sprites for both versions on the
|
||||||
|
-- premise that Red and Blue "share the green-character look"; both premises
|
||||||
|
-- are wrong -- Blue's background is a genuinely different blue and its
|
||||||
|
-- characters are pink (#155). Lightest shade first, like GBC_BG.
|
||||||
PaletteFX.GBC_BG_BLUE = {
|
PaletteFX.GBC_BG_BLUE = {
|
||||||
{ 255, 255, 255 }, { 132, 132, 255 }, { 58, 58, 148 }, { 0, 0, 0 },
|
{ 255, 255, 255 }, { 99, 165, 255 }, { 0, 0, 255 }, { 0, 0, 0 },
|
||||||
|
}
|
||||||
|
-- Blue's OBJ palette (OBP0) is the red/pink ramp -- the very same colors OG
|
||||||
|
-- RED uses for its BACKGROUND (GBC_BG), just applied to objects instead.
|
||||||
|
PaletteFX.GBC_OBJ_BLUE = {
|
||||||
|
{ 255, 255, 255 }, { 255, 132, 132 }, { 148, 58, 58 }, { 0, 0, 0 },
|
||||||
}
|
}
|
||||||
|
|
||||||
-- The active game's OG boot-ROM background palette: blue for a Blue
|
-- The active game's OG boot-ROM background palette: blue for a Blue
|
||||||
@@ -69,6 +78,16 @@ function PaletteFX.ogBg()
|
|||||||
return PaletteFX.GBC_BG
|
return PaletteFX.GBC_BG
|
||||||
end
|
end
|
||||||
|
|
||||||
|
-- The active game's OG boot-ROM object palette (OBP0): Blue's pink ramp for a
|
||||||
|
-- Blue playthrough, Red's green otherwise. Returns the colors AND a
|
||||||
|
-- version-distinct cache-group string, because SpriteRenderer.getObpImage keys
|
||||||
|
-- its baked-image cache by (image path, group): a shared group would collide a
|
||||||
|
-- Red bake with a Blue one and one version would show the other's colors.
|
||||||
|
function PaletteFX.ogObj()
|
||||||
|
if GameVersion.isBlue() then return PaletteFX.GBC_OBJ_BLUE, "gbcobj_blue" end
|
||||||
|
return PaletteFX.GBC_OBJ, "gbcobj"
|
||||||
|
end
|
||||||
|
|
||||||
local INV_MAP = { [0] = 3, [1] = 2, [2] = 1, [3] = 0 }
|
local INV_MAP = { [0] = 3, [1] = 2, [2] = 1, [3] = 0 }
|
||||||
|
|
||||||
function PaletteFX.shader()
|
function PaletteFX.shader()
|
||||||
@@ -183,17 +202,25 @@ function PaletteFX.usesGbcPack(mode)
|
|||||||
end
|
end
|
||||||
|
|
||||||
-- Whether the active mode bakes a per-OBJ palette onto overworld sprites
|
-- Whether the active mode bakes a per-OBJ palette onto overworld sprites
|
||||||
-- (the OBP bake + post-zone redraw path). ONLY OG RED does: it wears the
|
-- (the OBP bake + post-zone redraw path). OG RED and SGB both do: characters
|
||||||
-- GBC boot-ROM green object palette (PaletteFX.GBC_OBJ) so the player and
|
-- wear the GBC boot-ROM object palette (PaletteFX.ogObj -- green over Red's red
|
||||||
-- NPCs stay green over the red background, exactly like Pokemon Red on a
|
-- background, pink over Blue's blue background), so the player and NPCs carry a
|
||||||
-- Game Boy Color. SGB mode deliberately does NOT: an SGB OBJ carries no
|
-- fixed object color instead of tinting with whatever region palette their
|
||||||
-- palette of its own, so the characters tint with the whole-map region
|
-- feet stand over. On real hardware a sprite is an OBJ colored by an OBJ
|
||||||
-- palette along with the terrain (the Super Game Boy never colored Pokemon
|
-- palette (color/sprites.asm ColorOverworldSprite), distinct from the BG it
|
||||||
-- Red's sprites separately -- baking a per-sprite palette there was the
|
-- overlaps -- so Red's cap must stay green in tall grass, not turn the ROUTE
|
||||||
-- "reds coloring on the player/NPCs" bug). RED++ colors sprites through
|
-- palette's light-blue (issue #150: SGB region-tinting sent the cap to shade-2
|
||||||
-- the usesGbcPack() path in SpriteRenderer instead.
|
-- = light-blue and the character clashed with the grass it should blend into).
|
||||||
|
-- Terrain is unaffected -- pal() below still hands SGB its per-map BG palette;
|
||||||
|
-- only OG RED short-circuits BG to the one global red palette. An EARLIER
|
||||||
|
-- attempt at per-sprite SGB color baked GBC_BG (the RED background ramp) onto
|
||||||
|
-- characters -- that was the "reds coloring on the player/NPCs" bug; the object
|
||||||
|
-- palette is GBC_OBJ (green), so baking it here is the fix, not that
|
||||||
|
-- regression. RED++ colors sprites through the usesGbcPack() path in
|
||||||
|
-- SpriteRenderer instead.
|
||||||
function PaletteFX.usesSpriteObp(mode)
|
function PaletteFX.usesSpriteObp(mode)
|
||||||
return (mode or PaletteFX.mode) == "ogred"
|
mode = mode or PaletteFX.mode
|
||||||
|
return mode == "ogred" or mode == "gbc"
|
||||||
end
|
end
|
||||||
|
|
||||||
-- ------- post-zone sprite redraw (GBC mode)
|
-- ------- post-zone sprite redraw (GBC mode)
|
||||||
|
|||||||
@@ -0,0 +1,423 @@
|
|||||||
|
-- Rendering pipelines: the engine side of the render_pipelines registry.
|
||||||
|
--
|
||||||
|
-- A pipeline is a display mode a mod owns. It may replace the overworld's
|
||||||
|
-- world pass with geometry of its own (drawWorld) and/or post-process the
|
||||||
|
-- finished composite (present). Everything else about being a display mode
|
||||||
|
-- -- the OFF/1/2/3 ladder, the options row, the hotkey, persistence, the
|
||||||
|
-- free-roam gate, and never letting a mod's error take the frame down -- is
|
||||||
|
-- engine plumbing and lives here, so a renderer mod writes the two draw
|
||||||
|
-- functions and declares the rest.
|
||||||
|
--
|
||||||
|
-- The two halves compose independently and in priority order: the highest
|
||||||
|
-- priority eligible drawWorld renders the world, then every eligible
|
||||||
|
-- present folds over whatever came out (the world pipeline's canvas, or the
|
||||||
|
-- vanilla flat/tilt composite when none ran). A present that is switched
|
||||||
|
-- off returns its input, so a full ladder of them costs nothing at level 0.
|
||||||
|
--
|
||||||
|
-- Nothing here reaches collision, movement, triggers or scripts: like
|
||||||
|
-- survey zoom and tilt, a pipeline is purely presentational, which is why
|
||||||
|
-- its level rides in save.options rather than the save proper.
|
||||||
|
--
|
||||||
|
-- Spec: docs/modding.md (rendering pipelines)
|
||||||
|
|
||||||
|
local Data = require("src.core.Data")
|
||||||
|
local Logger = require("src.core.Logger")
|
||||||
|
local Runtime = require("src.mods.Runtime")
|
||||||
|
local Zoom = require("src.render.Zoom")
|
||||||
|
|
||||||
|
local Pipelines = {}
|
||||||
|
|
||||||
|
-- id -> level. Levels live here rather than on the records because the
|
||||||
|
-- records are merged content: frozen after load, and shared with whatever
|
||||||
|
-- else reads Data.
|
||||||
|
local levels = {}
|
||||||
|
|
||||||
|
-- ids whose callbacks have already thrown, so a pipeline that fails every
|
||||||
|
-- frame reports once instead of filling the log at 60Hz
|
||||||
|
local broken = {}
|
||||||
|
|
||||||
|
Pipelines.DEFAULT_LEVELS = { "OFF", "ON" }
|
||||||
|
|
||||||
|
-- The merged dataset the records live in. The boot singleton is the
|
||||||
|
-- default -- Game.data is that very table -- and install() lets a headless
|
||||||
|
-- caller (the SDK harness, a tool) point this at a dataset of its own.
|
||||||
|
local source = Data
|
||||||
|
|
||||||
|
function Pipelines.install(data)
|
||||||
|
source = data or Data
|
||||||
|
Pipelines.reset()
|
||||||
|
end
|
||||||
|
|
||||||
|
-- ------- catalog
|
||||||
|
|
||||||
|
-- Every registered pipeline as { id = ..., def = ... }, ordered by priority
|
||||||
|
-- (descending, ties by id) so selection, the options rows and the present
|
||||||
|
-- fold all walk the same sequence.
|
||||||
|
--
|
||||||
|
-- Memoized on the namespace table's identity. Content freezes at the merge
|
||||||
|
-- boundary, so the answer cannot change for a given table -- and this is
|
||||||
|
-- read several times per frame by update(), worldPipeline() and the
|
||||||
|
-- endFrame present check, which is no place to allocate and sort. An empty
|
||||||
|
-- list is cached too, so a mod-free boot pays one table for the process.
|
||||||
|
local listCache, listSource = nil, nil
|
||||||
|
|
||||||
|
function Pipelines.list()
|
||||||
|
local defs = source and source.render_pipelines
|
||||||
|
if defs == listSource and listCache then return listCache end
|
||||||
|
local out = {}
|
||||||
|
if type(defs) == "table" then
|
||||||
|
for id, def in pairs(defs) do
|
||||||
|
-- the merge writes provenance under _owners; skip the bookkeeping
|
||||||
|
-- keys rather than treating them as pipelines
|
||||||
|
if type(id) == "string" and id:sub(1, 1) ~= "_" and type(def) == "table" then
|
||||||
|
out[#out + 1] = { id = id, def = def }
|
||||||
|
end
|
||||||
|
end
|
||||||
|
table.sort(out, function(a, b)
|
||||||
|
local pa, pb = a.def.priority or 0, b.def.priority or 0
|
||||||
|
if pa ~= pb then return pa > pb end
|
||||||
|
return a.id < b.id
|
||||||
|
end)
|
||||||
|
end
|
||||||
|
listCache, listSource = out, defs
|
||||||
|
return out
|
||||||
|
end
|
||||||
|
|
||||||
|
function Pipelines.get(id)
|
||||||
|
local defs = source and source.render_pipelines
|
||||||
|
local def = type(defs) == "table" and defs[id] or nil
|
||||||
|
return type(def) == "table" and def or nil
|
||||||
|
end
|
||||||
|
|
||||||
|
-- the mod that registered a pipeline, so a runtime failure lands in the
|
||||||
|
-- feed the mod manager shows instead of only in the console
|
||||||
|
local function ownerOf(id)
|
||||||
|
local defs = source and source.render_pipelines
|
||||||
|
local owners = type(defs) == "table" and defs._owners or nil
|
||||||
|
return owners and owners[id] or nil
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Run one of a pipeline's callbacks under pcall. A mod that throws mid-
|
||||||
|
-- frame must not take the frame with it: the pipeline is marked broken,
|
||||||
|
-- attributed once, and treated as absent from then on -- which degrades to
|
||||||
|
-- the vanilla 2D path rather than a black screen.
|
||||||
|
local function guard(id, fn, ...)
|
||||||
|
if broken[id] then return nil end
|
||||||
|
local ok, result = pcall(fn, ...)
|
||||||
|
if ok then return result end
|
||||||
|
broken[id] = true
|
||||||
|
Logger.error("render pipeline %s failed: %s -- disabled for this session",
|
||||||
|
id, tostring(result))
|
||||||
|
Runtime.reportError(ownerOf(id), "render pipeline failed: " .. tostring(result))
|
||||||
|
return nil
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Whether a callback's return is a real Canvas we can composite. A mod that
|
||||||
|
-- forgets a return, or hands back a shade string / flag / number, must be
|
||||||
|
-- ignored rather than trusted -- draw() on a non-canvas takes the frame down.
|
||||||
|
-- Real LOVE canvases are userdata answering typeOf("Canvas"); the headless
|
||||||
|
-- test stub (tests/love_stub) fakes them as tables carrying the Canvas method
|
||||||
|
-- shape (love.graphics.newCanvas), so accept either and nothing else.
|
||||||
|
local function isCanvas(v)
|
||||||
|
if type(v) == "userdata" then
|
||||||
|
return type(v.typeOf) == "function" and v:typeOf("Canvas") == true
|
||||||
|
end
|
||||||
|
if type(v) == "table" then
|
||||||
|
return type(v.getWidth) == "function" and type(v.getHeight) == "function"
|
||||||
|
end
|
||||||
|
return false
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Dispatch a mod render callback with its GPU state fenced off: push("all")
|
||||||
|
-- before and pop() after, so a callback that returns cleanly but leaves a
|
||||||
|
-- shader bound, the canvas redirected, or blend/colour changed cannot corrupt
|
||||||
|
-- the engine composite that follows. guard() catches a callback that throws;
|
||||||
|
-- this catches one that dirties state. A pipeline already retired skips the
|
||||||
|
-- push/pop entirely, so the stack stays balanced.
|
||||||
|
local function guardRender(id, fn, ...)
|
||||||
|
if broken[id] then return nil end
|
||||||
|
love.graphics.push("all")
|
||||||
|
local out = guard(id, fn, ...)
|
||||||
|
love.graphics.pop()
|
||||||
|
return out
|
||||||
|
end
|
||||||
|
|
||||||
|
-- ------- levels
|
||||||
|
|
||||||
|
function Pipelines.levelLabels(id)
|
||||||
|
local def = Pipelines.get(id)
|
||||||
|
local labels = def and def.levels
|
||||||
|
if type(labels) ~= "table" or labels[1] == nil then
|
||||||
|
return Pipelines.DEFAULT_LEVELS
|
||||||
|
end
|
||||||
|
return labels
|
||||||
|
end
|
||||||
|
|
||||||
|
-- highest selectable level: one less than the label count, so a two-label
|
||||||
|
-- ladder is a plain OFF/ON toggle
|
||||||
|
function Pipelines.maxLevel(id)
|
||||||
|
return #Pipelines.levelLabels(id) - 1
|
||||||
|
end
|
||||||
|
|
||||||
|
function Pipelines.level(id)
|
||||||
|
return levels[id] or 0
|
||||||
|
end
|
||||||
|
|
||||||
|
function Pipelines.levelLabel(id, level)
|
||||||
|
local labels = Pipelines.levelLabels(id)
|
||||||
|
return labels[(level or Pipelines.level(id)) + 1] or labels[1] or "OFF"
|
||||||
|
end
|
||||||
|
|
||||||
|
-- A world pipeline and the engine's own tilt mode are two answers to the
|
||||||
|
-- same question, so switching one on switches the other off -- the rule
|
||||||
|
-- tilt and survey zoom already follow between themselves. Present-only
|
||||||
|
-- pipelines (post-processes) compose with tilt and are left alone.
|
||||||
|
local function excludeTilt(id, level)
|
||||||
|
local def = Pipelines.get(id)
|
||||||
|
if not (def and def.drawWorld) or level <= 0 then return end
|
||||||
|
local Tilt = require("src.render.Tilt")
|
||||||
|
if Tilt.level > 0 then Tilt.setLevel(0) end
|
||||||
|
-- one world pipeline at a time, for the same reason
|
||||||
|
for _, entry in ipairs(Pipelines.list()) do
|
||||||
|
if entry.id ~= id and entry.def.drawWorld and Pipelines.level(entry.id) > 0 then
|
||||||
|
levels[entry.id] = 0
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
function Pipelines.setLevel(id, level)
|
||||||
|
if not Pipelines.get(id) then return 0 end
|
||||||
|
level = math.floor(tonumber(level) or 0)
|
||||||
|
if level < 0 then level = 0 end
|
||||||
|
local max = Pipelines.maxLevel(id)
|
||||||
|
if level > max then level = max end
|
||||||
|
levels[id] = level
|
||||||
|
excludeTilt(id, level)
|
||||||
|
return level
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Advance the ladder and wrap to OFF, the shape every display hotkey walks.
|
||||||
|
function Pipelines.cycle(id, dir)
|
||||||
|
local max = Pipelines.maxLevel(id)
|
||||||
|
if max < 1 then return 0 end
|
||||||
|
local span = max + 1
|
||||||
|
local target = (Pipelines.level(id) + (dir or 1)) % span
|
||||||
|
if target < 0 then target = target + span end
|
||||||
|
return Pipelines.setLevel(id, target)
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Turning a world pipeline on must switch tilt off in the save too, not
|
||||||
|
-- just in the live module, or the next boot restores both. Call sites hand
|
||||||
|
-- over the options table so this stays the one place that rule lives.
|
||||||
|
function Pipelines.syncOptions(opts)
|
||||||
|
if type(opts) ~= "table" then return end
|
||||||
|
local bucket = opts.pipelines
|
||||||
|
if type(bucket) ~= "table" then
|
||||||
|
bucket = {}
|
||||||
|
opts.pipelines = bucket
|
||||||
|
end
|
||||||
|
for _, entry in ipairs(Pipelines.list()) do
|
||||||
|
bucket[entry.id] = Pipelines.level(entry.id)
|
||||||
|
if entry.def.drawWorld and Pipelines.level(entry.id) > 0 then
|
||||||
|
opts.tilt = 0
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Restore levels from a loaded options table. A pipeline whose mod is gone
|
||||||
|
-- keeps its stored level untouched in the bucket (so re-enabling the mod
|
||||||
|
-- restores the mode) but contributes nothing while absent.
|
||||||
|
function Pipelines.applyOptions(opts)
|
||||||
|
local bucket = type(opts) == "table" and opts.pipelines or nil
|
||||||
|
levels = {}
|
||||||
|
broken = {}
|
||||||
|
local world = nil
|
||||||
|
for _, entry in ipairs(Pipelines.list()) do
|
||||||
|
local stored = type(bucket) == "table" and bucket[entry.id] or 0
|
||||||
|
local level = math.floor(tonumber(stored) or 0)
|
||||||
|
if level < 0 then level = 0 end
|
||||||
|
local max = Pipelines.maxLevel(entry.id)
|
||||||
|
if level > max then level = max end
|
||||||
|
-- list() is priority order, so the first world pipeline with a stored
|
||||||
|
-- level is the one that wins; the rest restore to OFF rather than
|
||||||
|
-- sitting on a level that can never render
|
||||||
|
if entry.def.drawWorld and level > 0 then
|
||||||
|
if world then level = 0 else world = entry.id end
|
||||||
|
end
|
||||||
|
levels[entry.id] = level
|
||||||
|
end
|
||||||
|
-- a restored world pipeline and tilt are two answers to the same
|
||||||
|
-- question; the pipeline wins, as it does at every place that sets one
|
||||||
|
if world then require("src.render.Tilt").setLevel(0) end
|
||||||
|
end
|
||||||
|
|
||||||
|
function Pipelines.reset()
|
||||||
|
levels = {}
|
||||||
|
broken = {}
|
||||||
|
end
|
||||||
|
|
||||||
|
-- ------- per-frame
|
||||||
|
|
||||||
|
-- Presentational tweens run on real frame time, like Tilt's. Every
|
||||||
|
-- pipeline ticks, not just the active ones: a mode easing back OUT still
|
||||||
|
-- has an angle to retire.
|
||||||
|
function Pipelines.update(dt)
|
||||||
|
for _, entry in ipairs(Pipelines.list()) do
|
||||||
|
if entry.def.update then
|
||||||
|
guardRender(entry.id, entry.def.update, dt, Pipelines.level(entry.id))
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
-- A pipeline may run this frame when it is switched on, has not thrown, and
|
||||||
|
-- its hardware gate says yes. `available` is consulted every frame rather
|
||||||
|
-- than cached: a driver that loses its depth canvas on a resize has to be
|
||||||
|
-- able to change its mind.
|
||||||
|
--
|
||||||
|
-- Deliberately NOT gated on the state stack. `gate` governs whether the
|
||||||
|
-- player may CHANGE the mode, never whether it draws -- a display mode that
|
||||||
|
-- stopped rendering during a warp, a scripted cutscene or an open menu
|
||||||
|
-- would flash the flat 2D world for those frames every time the player
|
||||||
|
-- walked through a door. Once a mode is on it renders until it is off.
|
||||||
|
function Pipelines.eligible(id)
|
||||||
|
local def = Pipelines.get(id)
|
||||||
|
if not def or broken[id] then return false end
|
||||||
|
if Pipelines.level(id) <= 0 then return false end
|
||||||
|
if def.available and guard(id, def.available) ~= true then return false end
|
||||||
|
return true
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Whether the player may cycle this mode right now: the free-roam gate,
|
||||||
|
-- which keeps a hotkey press from switching modes mid-warp or mid-cutscene.
|
||||||
|
-- Input only -- see eligible() for why the draw path does not consult it.
|
||||||
|
function Pipelines.canToggle(id, top, overworld)
|
||||||
|
local def = Pipelines.get(id)
|
||||||
|
if not def then return false end
|
||||||
|
local gate = def.gate or Zoom.gateOK
|
||||||
|
return guard(id, gate, top, overworld) == true
|
||||||
|
end
|
||||||
|
|
||||||
|
-- The pipeline that owns the world pass right now, or nil for the vanilla
|
||||||
|
-- flat/tilt draw. Highest priority wins; the exclusion rules above mean
|
||||||
|
-- there is normally only one candidate anyway.
|
||||||
|
function Pipelines.worldPipeline()
|
||||||
|
for _, entry in ipairs(Pipelines.list()) do
|
||||||
|
if entry.def.drawWorld and Pipelines.eligible(entry.id) then
|
||||||
|
return entry.id, entry.def
|
||||||
|
end
|
||||||
|
end
|
||||||
|
return nil
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Render the world through `id`. Returns the canvas to composite, or nil
|
||||||
|
-- when the pipeline declined this frame (nothing to draw, a transient
|
||||||
|
-- failure), which the caller treats as "fall back to the 2D path".
|
||||||
|
function Pipelines.drawWorld(id, ctx)
|
||||||
|
local def = Pipelines.get(id)
|
||||||
|
if not (def and def.drawWorld) then return nil end
|
||||||
|
return guardRender(id, def.drawWorld, ctx)
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Fold every eligible world post-process over a pipeline's world image,
|
||||||
|
-- before the UI composites on top. This is where a depth-of-field or a
|
||||||
|
-- colour grade belongs when it must leave the dialog boxes and menus crisp;
|
||||||
|
-- `present` below is the whole-frame counterpart. Only reachable once some
|
||||||
|
-- pipeline rendered the world, so it is gated on the overworld state the
|
||||||
|
-- same way drawWorld is.
|
||||||
|
function Pipelines.worldPresent(canvas, ctx)
|
||||||
|
if canvas == nil then return nil end
|
||||||
|
for _, entry in ipairs(Pipelines.list()) do
|
||||||
|
if entry.def.worldPresent and Pipelines.eligible(entry.id) then
|
||||||
|
local out = guardRender(entry.id, entry.def.worldPresent, canvas, ctx)
|
||||||
|
-- accept only a real Canvas: a pass that returns a non-canvas (a
|
||||||
|
-- forgotten return, a shade string) is ignored, not folded in
|
||||||
|
if isCanvas(out) then canvas = out end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
return canvas
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Fold every eligible post-process over the finished frame. Present
|
||||||
|
-- pipelines are not gated on the overworld state -- a CRT curve or a colour
|
||||||
|
-- grade applies to menus and battles too -- so eligibility here is just
|
||||||
|
-- "switched on and available". A pass that returns a non-canvas is
|
||||||
|
-- ignored rather than trusted, so a mod cannot blank the screen by
|
||||||
|
-- forgetting a return.
|
||||||
|
function Pipelines.present(canvas, ctx)
|
||||||
|
if canvas == nil then return nil end
|
||||||
|
for _, entry in ipairs(Pipelines.list()) do
|
||||||
|
if entry.def.present and Pipelines.eligible(entry.id) then
|
||||||
|
local out = guardRender(entry.id, entry.def.present, canvas, ctx)
|
||||||
|
-- accept only a real Canvas: a pass that returns a non-canvas is
|
||||||
|
-- ignored (docstring above), so a mod cannot blank or crash the frame
|
||||||
|
-- by forgetting a return or handing back a truthy non-canvas
|
||||||
|
if isCanvas(out) then canvas = out end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
return canvas
|
||||||
|
end
|
||||||
|
|
||||||
|
-- true when any present-only pass wants to run, so the composite path can
|
||||||
|
-- skip allocating a target it would not use
|
||||||
|
function Pipelines.wantsPresent()
|
||||||
|
for _, entry in ipairs(Pipelines.list()) do
|
||||||
|
if entry.def.present and Pipelines.eligible(entry.id) then return true end
|
||||||
|
end
|
||||||
|
return false
|
||||||
|
end
|
||||||
|
|
||||||
|
-- ------- input and UI
|
||||||
|
|
||||||
|
-- Cycle whichever pipeline claims `key`. Returns the id when one did, so
|
||||||
|
-- the caller knows the key was consumed. Checked after the engine's own
|
||||||
|
-- display hotkeys, so a mod can never shadow one.
|
||||||
|
function Pipelines.hotkey(key, top, overworld)
|
||||||
|
for _, entry in ipairs(Pipelines.list()) do
|
||||||
|
if entry.def.hotkey == key then
|
||||||
|
-- the gate belongs here and nowhere else: it stops the player
|
||||||
|
-- flipping modes mid-warp or mid-cutscene, and has no say over
|
||||||
|
-- whether an already-on mode draws
|
||||||
|
if Pipelines.canToggle(entry.id, top, overworld) then
|
||||||
|
Pipelines.cycle(entry.id)
|
||||||
|
return entry.id
|
||||||
|
end
|
||||||
|
return nil
|
||||||
|
end
|
||||||
|
end
|
||||||
|
return nil
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Options rows for every registered pipeline, in the same priority order,
|
||||||
|
-- in the descriptor shape src/ui/OptionRows.lua renders.
|
||||||
|
function Pipelines.rows(game)
|
||||||
|
local rows = {}
|
||||||
|
for _, entry in ipairs(Pipelines.list()) do
|
||||||
|
local id = entry.id
|
||||||
|
rows[#rows + 1] = {
|
||||||
|
id = "pipeline:" .. id,
|
||||||
|
label = entry.def.label or id:upper(),
|
||||||
|
value = function() return Pipelines.levelLabel(id) end,
|
||||||
|
step = function(g, dir)
|
||||||
|
Pipelines.cycle(id, dir)
|
||||||
|
local opts = g and g.save and g.save.options
|
||||||
|
if opts then
|
||||||
|
Pipelines.syncOptions(opts)
|
||||||
|
-- the exclusion above may have switched tilt off; keep the live
|
||||||
|
-- module in step with the option it just wrote
|
||||||
|
require("src.render.Tilt").setLevel(opts.tilt or 0)
|
||||||
|
end
|
||||||
|
return true
|
||||||
|
end,
|
||||||
|
}
|
||||||
|
end
|
||||||
|
return rows
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Drop every pipeline's GPU objects (window resize, hot reload).
|
||||||
|
function Pipelines.invalidate()
|
||||||
|
for _, entry in ipairs(Pipelines.list()) do
|
||||||
|
if entry.def.invalidate then guardRender(entry.id, entry.def.invalidate) end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
return Pipelines
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
-- Render targets measured in real framebuffer pixels.
|
||||||
|
--
|
||||||
|
-- love.graphics.newCanvas defaults its `dpiscale` to
|
||||||
|
-- love.graphics.getDPIScale(), so on a highdpi surface the canvas texture is
|
||||||
|
-- NOT the size it was asked for: newCanvas(160, 144) on a device reporting a
|
||||||
|
-- DPI scale of 2.755 allocates a 441x397 texture, and the 160x144 GB scene
|
||||||
|
-- then renders into it at 2.755 texels per GB pixel. conf.lua sets
|
||||||
|
-- t.window.highdpi on Android/iOS (required for Retina), and Android's
|
||||||
|
-- DisplayMetrics.density is routinely non-integer (1.5, 2.75, ...), so this
|
||||||
|
-- is the normal mobile case, not an edge case.
|
||||||
|
--
|
||||||
|
-- That fractional render breaks the whole premise of Renderer's integer
|
||||||
|
-- scale pipeline. Renderer:fitScale() picks a whole number of physical
|
||||||
|
-- pixels per GB pixel (7 on 1080p) and the composite blit lands on it
|
||||||
|
-- exactly -- but if the *source* already holds 2.755 texels per GB pixel,
|
||||||
|
-- some GB pixels are 2 texels wide and some are 3, and the nearest-neighbour
|
||||||
|
-- upscale turns them into 5 / 7 / 8 physical pixels instead of a uniform 7.
|
||||||
|
-- Measured on a 1080p density-2.755 fixture, only 43 of 160 columns came out
|
||||||
|
-- the right width. Fonts show it worst: their strokes are single pixels.
|
||||||
|
-- That is issue #208 ("some are noticeably stretched, especially fonts").
|
||||||
|
--
|
||||||
|
-- Forcing dpiscale = 1 makes canvas texels the pixels the renderer already
|
||||||
|
-- believes it is drawing. getWidth()/getHeight() are unaffected (they always
|
||||||
|
-- reported the requested size), so no geometry anywhere changes -- only the
|
||||||
|
-- resolution of the target. Desktop is unchanged; dpiscale is already 1
|
||||||
|
-- there because conf.lua sets highdpi on mobile only.
|
||||||
|
--
|
||||||
|
-- Exception: a canvas that is deliberately sized in LOVE *units* and blitted
|
||||||
|
-- back at unit scale 1 (Renderer's presentCanvas) must keep the screen's DPI
|
||||||
|
-- scale so its texture still covers the framebuffer; it is not built with
|
||||||
|
-- this helper.
|
||||||
|
|
||||||
|
local PixelCanvas = {}
|
||||||
|
|
||||||
|
-- One framebuffer pixel per w/h unit, always. `filter` is applied only when
|
||||||
|
-- given, so callers that relied on LOVE's default ("linear") keep it.
|
||||||
|
function PixelCanvas.new(w, h, filter)
|
||||||
|
local canvas = love.graphics.newCanvas(w, h, { dpiscale = 1 })
|
||||||
|
if filter and canvas and canvas.setFilter then
|
||||||
|
canvas:setFilter(filter, filter)
|
||||||
|
end
|
||||||
|
return canvas
|
||||||
|
end
|
||||||
|
|
||||||
|
return PixelCanvas
|
||||||
@@ -9,12 +9,30 @@
|
|||||||
local Zoom = require("src.render.Zoom")
|
local Zoom = require("src.render.Zoom")
|
||||||
local Tilt = require("src.render.Tilt")
|
local Tilt = require("src.render.Tilt")
|
||||||
local PaletteFX = require("src.render.PaletteFX")
|
local PaletteFX = require("src.render.PaletteFX")
|
||||||
|
local Pipelines = require("src.render.Pipelines")
|
||||||
|
local PixelCanvas = require("src.render.PixelCanvas")
|
||||||
|
local Runtime = require("src.mods.Runtime")
|
||||||
|
|
||||||
local Renderer = {}
|
local Renderer = {}
|
||||||
|
|
||||||
Renderer.WIDTH = 160
|
Renderer.WIDTH = 160
|
||||||
Renderer.HEIGHT = 144
|
Renderer.HEIGHT = 144
|
||||||
|
|
||||||
|
-- Whether a value is a real Canvas we can composite. Real LOVE canvases are
|
||||||
|
-- userdata answering typeOf("Canvas"); the headless test stub fakes them as
|
||||||
|
-- tables carrying the Canvas method shape. A mod pipeline handing back a
|
||||||
|
-- non-canvas must be rejected before it reaches love.graphics.draw, which
|
||||||
|
-- would otherwise take the frame down with it.
|
||||||
|
local function isCanvas(v)
|
||||||
|
if type(v) == "userdata" then
|
||||||
|
return type(v.typeOf) == "function" and v:typeOf("Canvas") == true
|
||||||
|
end
|
||||||
|
if type(v) == "table" then
|
||||||
|
return type(v.getWidth) == "function" and type(v.getHeight) == "function"
|
||||||
|
end
|
||||||
|
return false
|
||||||
|
end
|
||||||
|
|
||||||
-- Tilt mode: the upright billboard canvas is grown by this many world
|
-- Tilt mode: the upright billboard canvas is grown by this many world
|
||||||
-- pixels on every side beyond the ground world view, so a structure or
|
-- pixels on every side beyond the ground world view, so a structure or
|
||||||
-- sprite standing near a view edge still draws in full instead of being
|
-- sprite standing near a view edge still draws in full instead of being
|
||||||
@@ -23,32 +41,49 @@ Renderer.HEIGHT = 144
|
|||||||
-- endFrame composites the padded canvas back with a matching offset.
|
-- endFrame composites the padded canvas back with a matching offset.
|
||||||
Renderer.UPRIGHT_MARGIN = 160
|
Renderer.UPRIGHT_MARGIN = 160
|
||||||
|
|
||||||
-- LOVE units + framebuffer pixels + the live unit→pixel ratio.
|
-- LOVE units + framebuffer pixels + per-axis unit→pixel ratios.
|
||||||
-- Android's DisplayMetrics.density (love.graphics.getDPIScale) is often
|
-- Android's DisplayMetrics.density is often non-integer (1.5, 2.75, …).
|
||||||
-- non-integer (1.5, 2.75, …). Integer scaling in units then maps each GB
|
-- Integer scaling in units then maps each GB pixel to a fractional number of
|
||||||
-- pixel to a fractional number of framebuffer pixels → shimmer, uneven /
|
-- framebuffer pixels → shimmer, uneven / non-square "pixels", and movement
|
||||||
-- non-square "pixels", and movement judder (issue #87). Always derive the
|
-- judder (issue #87). Always derive the crisp integer scale from the
|
||||||
-- crisp integer scale from the drawable pixel size; draw with (pixels/dpi)
|
-- *drawable* pixel size (the window framebuffer we present into -- not a
|
||||||
-- so the GPU lands on whole framebuffer pixels. Desktop dpi=1 is unchanged.
|
-- combined multi-display metric), then draw with (pixels / axisDpi) so the
|
||||||
|
-- GPU lands on whole framebuffer pixels. Desktop dpi=1 is unchanged.
|
||||||
|
--
|
||||||
|
-- LOVE's projection is anisotropic: 1 unit in X covers pw/ww framebuffer
|
||||||
|
-- pixels and 1 unit in Y covers ph/wh. Those ratios match on a normal
|
||||||
|
-- highdpi surface, but diverge when unit sizes are truncated independently
|
||||||
|
-- (`(int)(pixels/density)`) or when a dual-screen / forced-rotation device
|
||||||
|
-- reports mismatched unit vs drawable aspects (AYN Thor, issue #208).
|
||||||
|
-- love.graphics.getDPIScale() is only ph/wh, so using it (or pw/ww alone)
|
||||||
|
-- for both axes makes the other axis land on a fractional, stretched count.
|
||||||
|
-- Keep separate dpiX/dpiY so each GB pixel covers fitScale() physical pixels
|
||||||
|
-- on BOTH axes (square).
|
||||||
local function displayMetrics()
|
local function displayMetrics()
|
||||||
local ww, wh = love.graphics.getDimensions()
|
local ww, wh = love.graphics.getDimensions()
|
||||||
local pw, ph = ww, wh
|
local pw, ph = ww, wh
|
||||||
if love.graphics.getPixelDimensions then
|
if love.graphics.getPixelDimensions then
|
||||||
pw, ph = love.graphics.getPixelDimensions()
|
pw, ph = love.graphics.getPixelDimensions()
|
||||||
end
|
end
|
||||||
local dpi = 1
|
local dpiX, dpiY = 1, 1
|
||||||
if ww > 0 and pw > 0 then
|
if ww > 0 and pw > 0 then dpiX = pw / ww end
|
||||||
dpi = pw / ww
|
if wh > 0 and ph > 0 then dpiY = ph / wh end
|
||||||
elseif love.graphics.getDPIScale then
|
-- No pixel API (headless / old stub): fall back to getDPIScale, else 1.
|
||||||
dpi = love.graphics.getDPIScale()
|
if (dpiX == 1 and dpiY == 1) and not love.graphics.getPixelDimensions
|
||||||
|
and love.graphics.getDPIScale then
|
||||||
|
local d = love.graphics.getDPIScale()
|
||||||
|
if d and d > 1e-6 then dpiX, dpiY = d, d end
|
||||||
end
|
end
|
||||||
if not dpi or dpi < 1e-6 then dpi = 1 end
|
if dpiX < 1e-6 then dpiX = 1 end
|
||||||
return ww, wh, pw, ph, dpi
|
if dpiY < 1e-6 then dpiY = 1 end
|
||||||
|
return ww, wh, pw, ph, dpiX, dpiY
|
||||||
end
|
end
|
||||||
|
|
||||||
function Renderer:init()
|
function Renderer:init()
|
||||||
self.canvas = love.graphics.newCanvas(self.WIDTH, self.HEIGHT)
|
-- 160x144 real pixels, never DPI-scaled: see src/render/PixelCanvas.lua
|
||||||
self.canvas:setFilter("nearest", "nearest")
|
-- (#208). Every canvas below is sized in framebuffer pixels for the same
|
||||||
|
-- reason -- worldViewSize() already works in drawable pixels.
|
||||||
|
self.canvas = PixelCanvas.new(self.WIDTH, self.HEIGHT, "nearest")
|
||||||
self.worldCanvas = nil
|
self.worldCanvas = nil
|
||||||
self.worldActive = false
|
self.worldActive = false
|
||||||
-- tilt mode only: a transparent overlay canvas the size of the world
|
-- tilt mode only: a transparent overlay canvas the size of the world
|
||||||
@@ -57,25 +92,63 @@ function Renderer:init()
|
|||||||
-- the projected ground in endFrame; never touched while tilt is off.
|
-- the projected ground in endFrame; never touched while tilt is off.
|
||||||
self.uprightCanvas = nil
|
self.uprightCanvas = nil
|
||||||
self.uprightActive = false
|
self.uprightActive = false
|
||||||
|
-- a render pipeline's finished world image, already at window resolution
|
||||||
|
-- (see src/render/Pipelines.lua). nil is "no pipeline rendered this
|
||||||
|
-- frame", which is every vanilla frame.
|
||||||
|
self.worldOverride = nil
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Hand endFrame a pipeline's world image to composite instead of the world
|
||||||
|
-- canvas. Cleared every frame, so a pipeline that declines one frame falls
|
||||||
|
-- straight back to the 2D path rather than showing a stale image.
|
||||||
|
function Renderer:setWorldOverride(canvas)
|
||||||
|
-- Defensive: a pipeline that hands back a non-canvas (forgotten return, a
|
||||||
|
-- truthy sentinel) must not reach the worldOverride blit in endFrame, where
|
||||||
|
-- love.graphics.draw on it would crash the frame. Reject it and fall back
|
||||||
|
-- to the 2D path rather than trust it.
|
||||||
|
if canvas ~= nil and not isCanvas(canvas) then canvas = nil end
|
||||||
|
self.worldOverride = canvas
|
||||||
end
|
end
|
||||||
|
|
||||||
-- Integer framebuffer pixels per GB pixel that fit the window. Zoom /
|
-- Integer framebuffer pixels per GB pixel that fit the window. Zoom /
|
||||||
-- GBCFX / callers treat this as the crisp scale; endFrame converts to LOVE
|
-- GBCFX / callers treat this as the crisp scale; endFrame converts to LOVE
|
||||||
-- units via / dpi when drawing.
|
-- units via / dpiX and / dpiY when drawing.
|
||||||
function Renderer:fitScale()
|
function Renderer:fitScale()
|
||||||
local _, _, pw, ph = displayMetrics()
|
local _, _, pw, ph = displayMetrics()
|
||||||
return math.max(1, math.floor(math.min(pw / self.WIDTH, ph / self.HEIGHT)))
|
return math.max(1, math.floor(math.min(pw / self.WIDTH, ph / self.HEIGHT)))
|
||||||
end
|
end
|
||||||
|
|
||||||
-- world-pass canvas size in world pixels: enough to fill the window at s'.
|
-- LOVE-unit draw scales endFrame uses for the UI blit: integer framebuffer
|
||||||
-- In tilt mode the canvas grows (both dimensions, by Tilt.viewGrowth) so
|
-- scale (fitScale) divided by each axis's unit→pixel factor, so a GB pixel
|
||||||
-- the projected ground plane still covers the whole window with no
|
-- lands on fitScale() whole PHYSICAL pixels on both axes once LOVE applies
|
||||||
-- background peeking at the receded top/bottom corners; flat mode returns
|
-- its projection (fitScale() == drawScaleX() * dpiX == drawScaleY() * dpiY).
|
||||||
-- exactly today's size (growth factor is 1 when tilt is inactive).
|
-- Exposed for #208's regression (square pixels when dpiX ≠ dpiY).
|
||||||
|
function Renderer:drawScaleX()
|
||||||
|
local _, _, _, _, dpiX = displayMetrics()
|
||||||
|
return self:fitScale() / dpiX
|
||||||
|
end
|
||||||
|
|
||||||
|
function Renderer:drawScaleY()
|
||||||
|
local _, _, _, _, _, dpiY = displayMetrics()
|
||||||
|
return self:fitScale() / dpiY
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Back-compat alias: uniform surfaces have drawScaleX == drawScaleY.
|
||||||
|
function Renderer:drawScale()
|
||||||
|
return self:drawScaleX()
|
||||||
|
end
|
||||||
|
|
||||||
|
-- world-pass canvas size in world pixels: enough to fill the drawable at s'.
|
||||||
|
-- Sized from framebuffer pixels (not unit dims) so anisotropic dpiX/dpiY
|
||||||
|
-- cannot over/under-cover the window. In tilt mode the canvas grows (both
|
||||||
|
-- dimensions, by Tilt.viewGrowth) so the projected ground plane still covers
|
||||||
|
-- the whole window with no background peeking at the receded top/bottom
|
||||||
|
-- corners; flat mode returns exactly today's size (growth factor is 1 when
|
||||||
|
-- tilt is inactive).
|
||||||
function Renderer:worldViewSize()
|
function Renderer:worldViewSize()
|
||||||
local ww, wh, _, _, dpi = displayMetrics()
|
local _, _, pw, ph = displayMetrics()
|
||||||
local s = Zoom.scale(self:fitScale()) / dpi
|
local sp = Zoom.scale(self:fitScale())
|
||||||
local vw, vh = Zoom.fillViewSize(s, ww, wh)
|
local vw, vh = math.ceil(pw / sp), math.ceil(ph / sp)
|
||||||
-- Even sizes keep Camera:follow on integer pixels (viewW/2 is integral),
|
-- Even sizes keep Camera:follow on integer pixels (viewW/2 is integral),
|
||||||
-- so unfloored FX/sprite math cannot phase-shimmer against the tile layer.
|
-- so unfloored FX/sprite math cannot phase-shimmer against the tile layer.
|
||||||
if vw % 2 ~= 0 then vw = vw + 1 end
|
if vw % 2 ~= 0 then vw = vw + 1 end
|
||||||
@@ -91,6 +164,7 @@ end
|
|||||||
function Renderer:beginFrame(transparent)
|
function Renderer:beginFrame(transparent)
|
||||||
self.worldActive = false
|
self.worldActive = false
|
||||||
self.uprightActive = false
|
self.uprightActive = false
|
||||||
|
self.worldOverride = nil
|
||||||
-- warp-fade overlay from Transition (issue #121); cleared each frame so
|
-- warp-fade overlay from Transition (issue #121); cleared each frame so
|
||||||
-- a popped transition cannot leave a sticky black veil
|
-- a popped transition cannot leave a sticky black veil
|
||||||
self.worldFadeAlpha = nil
|
self.worldFadeAlpha = nil
|
||||||
@@ -112,21 +186,24 @@ end
|
|||||||
-- Black 8x8 (scaled) blocks cascading outward from the classic GB letterbox
|
-- Black 8x8 (scaled) blocks cascading outward from the classic GB letterbox
|
||||||
-- into the surrounding window, matching BattleTransition wipe progress.
|
-- into the surrounding window, matching BattleTransition wipe progress.
|
||||||
-- Tiles that sit entirely inside the 160x144 square are left to the OG wipe.
|
-- Tiles that sit entirely inside the 160x144 square are left to the OG wipe.
|
||||||
function Renderer:drawBattleCascade(prog, ww, wh, ox, oy, vpw, vph, S)
|
-- Sx/Sy are LOVE-unit scales (Sy defaults to Sx on uniform surfaces).
|
||||||
|
function Renderer:drawBattleCascade(prog, ww, wh, ox, oy, vpw, vph, Sx, Sy)
|
||||||
if not prog or prog <= 0 then return end
|
if not prog or prog <= 0 then return end
|
||||||
local TILE = 8 * S
|
Sy = Sy or Sx
|
||||||
if TILE < 1 then TILE = 1 end
|
local TILE_W, TILE_H = 8 * Sx, 8 * Sy
|
||||||
local cols = math.ceil(ww / TILE)
|
if TILE_W < 1 then TILE_W = 1 end
|
||||||
local rows = math.ceil(wh / TILE)
|
if TILE_H < 1 then TILE_H = 1 end
|
||||||
|
local cols = math.ceil(ww / TILE_W)
|
||||||
|
local rows = math.ceil(wh / TILE_H)
|
||||||
local cx, cy = ox + vpw / 2, oy + vph / 2
|
local cx, cy = ox + vpw / 2, oy + vph / 2
|
||||||
local order = {}
|
local order = {}
|
||||||
for row = 0, rows - 1 do
|
for row = 0, rows - 1 do
|
||||||
for col = 0, cols - 1 do
|
for col = 0, cols - 1 do
|
||||||
local x, y = col * TILE, row * TILE
|
local x, y = col * TILE_W, row * TILE_H
|
||||||
-- any tile with area outside the letterbox participates
|
-- any tile with area outside the letterbox participates
|
||||||
if x < ox or y < oy or x + TILE > ox + vpw or y + TILE > oy + vph then
|
if x < ox or y < oy or x + TILE_W > ox + vpw or y + TILE_H > oy + vph then
|
||||||
local dist = math.max(math.abs(x + TILE / 2 - cx),
|
local dist = math.max(math.abs(x + TILE_W / 2 - cx),
|
||||||
math.abs(y + TILE / 2 - cy))
|
math.abs(y + TILE_H / 2 - cy))
|
||||||
order[#order + 1] = { x, y, dist }
|
order[#order + 1] = { x, y, dist }
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
@@ -143,7 +220,7 @@ function Renderer:drawBattleCascade(prog, ww, wh, ox, oy, vpw, vph, S)
|
|||||||
love.graphics.setScissor(0, 0, ww, wh)
|
love.graphics.setScissor(0, 0, ww, wh)
|
||||||
for i = 1, n do
|
for i = 1, n do
|
||||||
local t = order[i]
|
local t = order[i]
|
||||||
love.graphics.rectangle("fill", t[1], t[2], TILE, TILE)
|
love.graphics.rectangle("fill", t[1], t[2], TILE_W, TILE_H)
|
||||||
end
|
end
|
||||||
love.graphics.setScissor()
|
love.graphics.setScissor()
|
||||||
love.graphics.setColor(1, 1, 1, 1)
|
love.graphics.setColor(1, 1, 1, 1)
|
||||||
@@ -157,8 +234,7 @@ function Renderer:beginWorldPass()
|
|||||||
-- the view size every frame, so without this the superseded canvases
|
-- the view size every frame, so without this the superseded canvases
|
||||||
-- pile up in VRAM until a GC finalizer happens to run
|
-- pile up in VRAM until a GC finalizer happens to run
|
||||||
if self.worldCanvas and self.worldCanvas.release then self.worldCanvas:release() end
|
if self.worldCanvas and self.worldCanvas.release then self.worldCanvas:release() end
|
||||||
self.worldCanvas = love.graphics.newCanvas(vw, vh)
|
self.worldCanvas = PixelCanvas.new(vw, vh, "nearest")
|
||||||
self.worldCanvas:setFilter("nearest", "nearest")
|
|
||||||
end
|
end
|
||||||
self.worldActive = true
|
self.worldActive = true
|
||||||
PaletteFX.setPass("world")
|
PaletteFX.setPass("world")
|
||||||
@@ -186,8 +262,7 @@ function Renderer:beginUprightPass()
|
|||||||
if not self.uprightCanvas or self.uprightCanvas:getWidth() ~= cw
|
if not self.uprightCanvas or self.uprightCanvas:getWidth() ~= cw
|
||||||
or self.uprightCanvas:getHeight() ~= ch then
|
or self.uprightCanvas:getHeight() ~= ch then
|
||||||
if self.uprightCanvas and self.uprightCanvas.release then self.uprightCanvas:release() end
|
if self.uprightCanvas and self.uprightCanvas.release then self.uprightCanvas:release() end
|
||||||
self.uprightCanvas = love.graphics.newCanvas(cw, ch)
|
self.uprightCanvas = PixelCanvas.new(cw, ch, "nearest")
|
||||||
self.uprightCanvas:setFilter("nearest", "nearest")
|
|
||||||
end
|
end
|
||||||
self.uprightActive = true
|
self.uprightActive = true
|
||||||
PaletteFX.setPass(nil)
|
PaletteFX.setPass(nil)
|
||||||
@@ -266,10 +341,11 @@ end
|
|||||||
-- into (nil = default framebuffer; presentCanvas when CRT is on).
|
-- into (nil = default framebuffer; presentCanvas when CRT is on).
|
||||||
-- Returns true on success; false (no shader/mesh) tells endFrame to fall
|
-- Returns true on success; false (no shader/mesh) tells endFrame to fall
|
||||||
-- back to the flat blit unchanged.
|
-- back to the flat blit unchanged.
|
||||||
function Renderer:drawTiltedWorld(zoneList, s, wox, woy, target)
|
function Renderer:drawTiltedWorld(zoneList, sx, sy, wox, woy, target)
|
||||||
local shader = self:tiltShader()
|
local shader = self:tiltShader()
|
||||||
local mesh = self:tiltMesh()
|
local mesh = self:tiltMesh()
|
||||||
if not (shader and mesh) then return false end
|
if not (shader and mesh) then return false end
|
||||||
|
sy = sy or sx
|
||||||
local wvw = self.worldCanvas:getWidth()
|
local wvw = self.worldCanvas:getWidth()
|
||||||
local wvh = self.worldCanvas:getHeight()
|
local wvh = self.worldCanvas:getHeight()
|
||||||
|
|
||||||
@@ -280,8 +356,7 @@ function Renderer:drawTiltedWorld(zoneList, s, wox, woy, target)
|
|||||||
if not self.tiltCanvas or self.tiltCanvas:getWidth() ~= wvw
|
if not self.tiltCanvas or self.tiltCanvas:getWidth() ~= wvw
|
||||||
or self.tiltCanvas:getHeight() ~= wvh then
|
or self.tiltCanvas:getHeight() ~= wvh then
|
||||||
if self.tiltCanvas and self.tiltCanvas.release then self.tiltCanvas:release() end
|
if self.tiltCanvas and self.tiltCanvas.release then self.tiltCanvas:release() end
|
||||||
self.tiltCanvas = love.graphics.newCanvas(wvw, wvh)
|
self.tiltCanvas = PixelCanvas.new(wvw, wvh, "linear")
|
||||||
self.tiltCanvas:setFilter("linear", "linear")
|
|
||||||
end
|
end
|
||||||
|
|
||||||
love.graphics.setCanvas(self.tiltCanvas)
|
love.graphics.setCanvas(self.tiltCanvas)
|
||||||
@@ -318,7 +393,7 @@ function Renderer:drawTiltedWorld(zoneList, s, wox, woy, target)
|
|||||||
mesh:setVertices(Tilt.meshCorners(wvw, wvh))
|
mesh:setVertices(Tilt.meshCorners(wvw, wvh))
|
||||||
love.graphics.push()
|
love.graphics.push()
|
||||||
love.graphics.translate(wox, woy)
|
love.graphics.translate(wox, woy)
|
||||||
love.graphics.scale(s, s)
|
love.graphics.scale(sx, sy)
|
||||||
love.graphics.setColor(1, 1, 1, 1)
|
love.graphics.setColor(1, 1, 1, 1)
|
||||||
love.graphics.setShader(shader)
|
love.graphics.setShader(shader)
|
||||||
love.graphics.draw(mesh)
|
love.graphics.draw(mesh)
|
||||||
@@ -360,14 +435,15 @@ end
|
|||||||
-- presented through the GBC FX shader as a final pass.
|
-- presented through the GBC FX shader as a final pass.
|
||||||
function Renderer:endFrame(zones, worldZones)
|
function Renderer:endFrame(zones, worldZones)
|
||||||
love.graphics.setCanvas()
|
love.graphics.setCanvas()
|
||||||
local ww, wh, pw, ph, dpi = displayMetrics()
|
local ww, wh, pw, ph, dpiX, dpiY = displayMetrics()
|
||||||
-- Sp = integer framebuffer pixels per GB pixel; S = LOVE-unit draw scale.
|
-- Sp = integer framebuffer pixels per GB pixel;
|
||||||
|
-- Sx/Sy = LOVE-unit draw scales (may differ when dpiX ≠ dpiY).
|
||||||
local Sp = self:fitScale()
|
local Sp = self:fitScale()
|
||||||
local S = Sp / dpi
|
local Sx, Sy = Sp / dpiX, Sp / dpiY
|
||||||
local vpw, vph = self.WIDTH * S, self.HEIGHT * S
|
local vpw, vph = self.WIDTH * Sx, self.HEIGHT * Sy
|
||||||
-- Snap the letterbox origin to a framebuffer pixel, then convert to units.
|
-- Snap the letterbox origin to a framebuffer pixel, then convert to units.
|
||||||
local ox = math.floor((pw - self.WIDTH * Sp) / 2) / dpi
|
local ox = math.floor((pw - self.WIDTH * Sp) / 2) / dpiX
|
||||||
local oy = math.floor((ph - self.HEIGHT * Sp) / 2) / dpi
|
local oy = math.floor((ph - self.HEIGHT * Sp) / 2) / dpiY
|
||||||
local GBCFX = require("src.render.GBCFX")
|
local GBCFX = require("src.render.GBCFX")
|
||||||
-- Forced mono/Classic modes still need a whole-screen zone when a state
|
-- Forced mono/Classic modes still need a whole-screen zone when a state
|
||||||
-- exposes no SGB packets (raw DMG canvas), so sendColors can remap.
|
-- exposes no SGB packets (raw DMG canvas), so sendColors can remap.
|
||||||
@@ -380,11 +456,23 @@ function Renderer:endFrame(zones, worldZones)
|
|||||||
zones = withTrueColor(zones, "ui")
|
zones = withTrueColor(zones, "ui")
|
||||||
worldZones = withTrueColor(worldZones, "world")
|
worldZones = withTrueColor(worldZones, "world")
|
||||||
|
|
||||||
local needPresent = GBCFX.active()
|
-- A post-process pipeline needs the whole composite in a canvas for the
|
||||||
|
-- same reason GBC FX does, so either one alone is enough to take the
|
||||||
|
-- present path; with neither, the frame draws straight to the screen
|
||||||
|
-- exactly as it always did.
|
||||||
|
local needPresent = GBCFX.active() or Pipelines.wantsPresent()
|
||||||
local present = nil
|
local present = nil
|
||||||
if needPresent then
|
if needPresent then
|
||||||
if not self.presentCanvas or self.presentCanvas:getWidth() ~= ww
|
if not self.presentCanvas or self.presentCanvas:getWidth() ~= ww
|
||||||
or self.presentCanvas:getHeight() ~= wh then
|
or self.presentCanvas:getHeight() ~= wh then
|
||||||
|
-- The one canvas NOT built through PixelCanvas: it is sized in LOVE
|
||||||
|
-- units and blitted back at unit scale 1 (and handed to mod present
|
||||||
|
-- passes as ww x wh), so it has to keep the screen's DPI scale for its
|
||||||
|
-- texture to cover the framebuffer. Everything composited into it is
|
||||||
|
-- already native-resolution now, so #208's fractional source is gone;
|
||||||
|
-- what remains here is the dpiX vs dpiY truncation gap (well under 1%,
|
||||||
|
-- one seam across the window) that a single scalar dpiscale cannot
|
||||||
|
-- express.
|
||||||
self.presentCanvas = love.graphics.newCanvas(ww, wh)
|
self.presentCanvas = love.graphics.newCanvas(ww, wh)
|
||||||
self.presentCanvas:setFilter("linear", "linear")
|
self.presentCanvas:setFilter("linear", "linear")
|
||||||
end
|
end
|
||||||
@@ -407,15 +495,27 @@ function Renderer:endFrame(zones, worldZones)
|
|||||||
love.graphics.setColor(clearR, clearG, clearB, 1)
|
love.graphics.setColor(clearR, clearG, clearB, 1)
|
||||||
love.graphics.rectangle("fill", 0, 0, ww, wh)
|
love.graphics.rectangle("fill", 0, 0, ww, wh)
|
||||||
love.graphics.setColor(1, 1, 1, 1)
|
love.graphics.setColor(1, 1, 1, 1)
|
||||||
|
-- render.letterbox: SGB borders / custom void art in the bars around the
|
||||||
|
-- 160x144 (or world) blit. Drawn after the clear and before the game
|
||||||
|
-- canvas so the playfield sits on top of the border.
|
||||||
|
if Runtime.wantsHook("render.letterbox") then
|
||||||
|
Runtime.call("render.letterbox", function() end, {
|
||||||
|
ww = ww, wh = wh, pw = pw, ph = ph,
|
||||||
|
ox = ox, oy = oy, vpw = vpw, vph = vph,
|
||||||
|
scale = Sp, dpiX = dpiX, dpiY = dpiY,
|
||||||
|
worldActive = self.worldActive and true or false,
|
||||||
|
})
|
||||||
|
end
|
||||||
|
|
||||||
-- blit `canvas` at `scale` (LOVE units) into origin (bx, by), scissored to
|
-- blit `canvas` at (sx, sy) LOVE-unit scales into origin (bx, by),
|
||||||
-- the (boxX, boxY, boxW, boxH) screen rect. zoneScale converts zone
|
-- scissored to the (boxX, boxY, boxW, boxH) screen rect. zoneSx/zoneSy
|
||||||
-- coords (canvas-space) into screen units.
|
-- convert zone coords (canvas-space) into screen units.
|
||||||
local function blit(canvas, scale, zoneList, zoneScale, bx, by, boxX, boxY, boxW, boxH)
|
local function blit(canvas, sx, sy, zoneList, zoneSx, zoneSy,
|
||||||
|
bx, by, boxX, boxY, boxW, boxH)
|
||||||
local shader = zoneList and zoneList[1] and PaletteFX.shader() or nil
|
local shader = zoneList and zoneList[1] and PaletteFX.shader() or nil
|
||||||
if not shader then
|
if not shader then
|
||||||
love.graphics.setScissor(boxX, boxY, boxW, boxH)
|
love.graphics.setScissor(boxX, boxY, boxW, boxH)
|
||||||
love.graphics.draw(canvas, bx, by, 0, scale, scale)
|
love.graphics.draw(canvas, bx, by, 0, sx, sy)
|
||||||
love.graphics.setScissor()
|
love.graphics.setScissor()
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
@@ -431,23 +531,43 @@ function Renderer:endFrame(zones, worldZones)
|
|||||||
love.graphics.setShader(not plain and shader or nil)
|
love.graphics.setShader(not plain and shader or nil)
|
||||||
end
|
end
|
||||||
if not plain then PaletteFX.sendColors(shader, z.colors) end
|
if not plain then PaletteFX.sendColors(shader, z.colors) end
|
||||||
if scissorClamped(bx + z.x * zoneScale, by + z.y * zoneScale,
|
if scissorClamped(bx + z.x * zoneSx, by + z.y * zoneSy,
|
||||||
z.w * zoneScale, z.h * zoneScale,
|
z.w * zoneSx, z.h * zoneSy,
|
||||||
boxX, boxY, boxW, boxH) then
|
boxX, boxY, boxW, boxH) then
|
||||||
love.graphics.draw(canvas, bx, by, 0, scale, scale)
|
love.graphics.draw(canvas, bx, by, 0, sx, sy)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
love.graphics.setScissor()
|
love.graphics.setScissor()
|
||||||
love.graphics.setShader()
|
love.graphics.setShader()
|
||||||
end
|
end
|
||||||
|
|
||||||
if self.worldActive then
|
if self.worldOverride then
|
||||||
|
-- A render pipeline already produced the whole world -- terrain,
|
||||||
|
-- characters and its own FX overlay -- as one window-resolution image,
|
||||||
|
-- so it composites with a straight 1:1 blit and the world canvas is
|
||||||
|
-- skipped entirely (nothing drew into it). The UI blit below still
|
||||||
|
-- runs, so dialogs, menus and the HUD sit on top as usual.
|
||||||
|
love.graphics.setColor(1, 1, 1, 1)
|
||||||
|
love.graphics.setScissor(0, 0, ww, wh)
|
||||||
|
love.graphics.draw(self.worldOverride, 0, 0, 0, 1 / dpiX, 1 / dpiY)
|
||||||
|
love.graphics.setScissor()
|
||||||
|
-- the screen-space overlays the flat path draws over its composite
|
||||||
|
local fade = self.worldFadeAlpha
|
||||||
|
if fade and fade > 0 then
|
||||||
|
love.graphics.setColor(0, 0, 0, fade)
|
||||||
|
love.graphics.rectangle("fill", 0, 0, ww, wh)
|
||||||
|
love.graphics.setColor(1, 1, 1, 1)
|
||||||
|
end
|
||||||
|
if self.battleCascadeProg then
|
||||||
|
self:drawBattleCascade(self.battleCascadeProg, ww, wh, ox, oy, vpw, vph, Sx, Sy)
|
||||||
|
end
|
||||||
|
elseif self.worldActive then
|
||||||
local sp = Zoom.scale(Sp)
|
local sp = Zoom.scale(Sp)
|
||||||
local s = sp / dpi
|
local sx, sy = sp / dpiX, sp / dpiY
|
||||||
local wvw = self.worldCanvas:getWidth()
|
local wvw = self.worldCanvas:getWidth()
|
||||||
local wvh = self.worldCanvas:getHeight()
|
local wvh = self.worldCanvas:getHeight()
|
||||||
local wox = math.floor((pw - wvw * sp) / 2) / dpi
|
local wox = math.floor((pw - wvw * sp) / 2) / dpiX
|
||||||
local woy = math.floor((ph - wvh * sp) / 2) / dpi
|
local woy = math.floor((ph - wvh * sp) / 2) / dpiY
|
||||||
-- Tilt mode projects the ground world pass through the perspective mesh
|
-- Tilt mode projects the ground world pass through the perspective mesh
|
||||||
-- (SGB zones baked in beforehand -- see drawTiltedWorld -- so no zone
|
-- (SGB zones baked in beforehand -- see drawTiltedWorld -- so no zone
|
||||||
-- scissoring here). drawTiltedWorld returns false when tilt is off or
|
-- scissoring here). drawTiltedWorld returns false when tilt is off or
|
||||||
@@ -455,12 +575,12 @@ function Renderer:endFrame(zones, worldZones)
|
|||||||
-- falls through to the flat blit, keeping the flat frame byte-for-byte
|
-- falls through to the flat blit, keeping the flat frame byte-for-byte
|
||||||
-- identical to today.
|
-- identical to today.
|
||||||
local projected =
|
local projected =
|
||||||
Tilt.active() and self:drawTiltedWorld(worldZones or zones, s, wox, woy, present)
|
Tilt.active() and self:drawTiltedWorld(worldZones or zones, sx, sy, wox, woy, present)
|
||||||
if not projected then
|
if not projected then
|
||||||
if worldZones then
|
if worldZones then
|
||||||
blit(self.worldCanvas, s, worldZones, s, wox, woy, 0, 0, ww, wh)
|
blit(self.worldCanvas, sx, sy, worldZones, sx, sy, wox, woy, 0, 0, ww, wh)
|
||||||
else
|
else
|
||||||
blit(self.worldCanvas, s, zones, S, wox, woy, 0, 0, ww, wh)
|
blit(self.worldCanvas, sx, sy, zones, Sx, Sy, wox, woy, 0, 0, ww, wh)
|
||||||
end
|
end
|
||||||
-- OBP-baked overworld sprites replay on top of the zone pass (GBC
|
-- OBP-baked overworld sprites replay on top of the zone pass (GBC
|
||||||
-- mode per-object coloring; see PaletteFX.markSpriteRedraw). Grass
|
-- mode per-object coloring; see PaletteFX.markSpriteRedraw). Grass
|
||||||
@@ -481,11 +601,11 @@ function Renderer:endFrame(zones, worldZones)
|
|||||||
end
|
end
|
||||||
if wanted then PaletteFX.sendColors(wanted, r.colors) end
|
if wanted then PaletteFX.sendColors(wanted, r.colors) end
|
||||||
if r.quad then
|
if r.quad then
|
||||||
love.graphics.draw(r.image, r.quad, wox + r.x * s, woy + r.y * s,
|
love.graphics.draw(r.image, r.quad, wox + r.x * sx, woy + r.y * sy,
|
||||||
0, s * r.sx, s)
|
0, sx * r.sx, sy)
|
||||||
else
|
else
|
||||||
love.graphics.draw(r.image, wox + r.x * s, woy + r.y * s,
|
love.graphics.draw(r.image, wox + r.x * sx, woy + r.y * sy,
|
||||||
0, s * r.sx, s)
|
0, sx * r.sx, sy)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
if activeShader then love.graphics.setShader() end
|
if activeShader then love.graphics.setShader() end
|
||||||
@@ -502,7 +622,7 @@ function Renderer:endFrame(zones, worldZones)
|
|||||||
local M = self.UPRIGHT_MARGIN
|
local M = self.UPRIGHT_MARGIN
|
||||||
love.graphics.setColor(1, 1, 1, 1)
|
love.graphics.setColor(1, 1, 1, 1)
|
||||||
love.graphics.setScissor(0, 0, ww, wh)
|
love.graphics.setScissor(0, 0, ww, wh)
|
||||||
love.graphics.draw(self.uprightCanvas, wox - M * s, woy - M * s, 0, s, s)
|
love.graphics.draw(self.uprightCanvas, wox - M * sx, woy - M * sy, 0, sx, sy)
|
||||||
love.graphics.setScissor()
|
love.graphics.setScissor()
|
||||||
end
|
end
|
||||||
-- Screen-space warp fade (Transition) over the full world composite so
|
-- Screen-space warp fade (Transition) over the full world composite so
|
||||||
@@ -518,19 +638,34 @@ function Renderer:endFrame(zones, worldZones)
|
|||||||
-- Battle transition: cascade black blocks into the area outside the
|
-- Battle transition: cascade black blocks into the area outside the
|
||||||
-- classic 160x144 wipe square (world still shows through until filled).
|
-- classic 160x144 wipe square (world still shows through until filled).
|
||||||
if self.battleCascadeProg then
|
if self.battleCascadeProg then
|
||||||
self:drawBattleCascade(self.battleCascadeProg, ww, wh, ox, oy, vpw, vph, S)
|
self:drawBattleCascade(self.battleCascadeProg, ww, wh, ox, oy, vpw, vph, Sx, Sy)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
-- UI stays in the classic centered GB letterbox
|
-- UI stays in the classic centered GB letterbox
|
||||||
blit(self.canvas, S, zones, S, ox, oy, ox, oy, vpw, vph)
|
blit(self.canvas, Sx, Sy, zones, Sx, Sy, ox, oy, ox, oy, vpw, vph)
|
||||||
|
|
||||||
if present then
|
if present then
|
||||||
love.graphics.setCanvas()
|
love.graphics.setCanvas()
|
||||||
-- shader grid/shadow math is in framebuffer pixels
|
-- Post-process pipelines run over the finished composite -- world, UI
|
||||||
GBCFX.present(present, Sp)
|
-- and all -- and before GBC FX, so a blur or colour grade is what the
|
||||||
|
-- LCD grid is then drawn over rather than something that smears the
|
||||||
|
-- grid itself. Each pass hands back a canvas; with none registered
|
||||||
|
-- this returns `present` unchanged and the frame is byte-identical.
|
||||||
|
local composed = Pipelines.present(present,
|
||||||
|
{ width = ww, height = wh, scale = Sp, dpi = dpiY, dpiX = dpiX, dpiY = dpiY }) or present
|
||||||
|
if GBCFX.active() then
|
||||||
|
-- shader grid/shadow math is in framebuffer pixels
|
||||||
|
GBCFX.present(composed, Sp)
|
||||||
|
else
|
||||||
|
-- the present canvas only existed for the post-process, so put the
|
||||||
|
-- result on the screen at the same 1:1 unit mapping it was built at
|
||||||
|
love.graphics.setColor(1, 1, 1, 1)
|
||||||
|
love.graphics.draw(composed, 0, 0)
|
||||||
|
end
|
||||||
end
|
end
|
||||||
self.worldActive = false
|
self.worldActive = false
|
||||||
self.uprightActive = false
|
self.uprightActive = false
|
||||||
|
self.worldOverride = nil
|
||||||
PaletteFX.setPass(nil)
|
PaletteFX.setPass(nil)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|||||||
@@ -65,8 +65,12 @@ end
|
|||||||
|
|
||||||
Assets.register(SpriteRenderer.invalidate)
|
Assets.register(SpriteRenderer.invalidate)
|
||||||
|
|
||||||
|
-- exported: a render pipeline's own sprite geometry picks frames by the
|
||||||
|
-- same tables, so a 3D pose can never drift from the 2D one
|
||||||
local STAND = { down = 0, up = 1, left = 2, right = 2 }
|
local STAND = { down = 0, up = 1, left = 2, right = 2 }
|
||||||
local WALK = { down = 3, up = 4, left = 5, right = 5 }
|
local WALK = { down = 3, up = 4, left = 5, right = 5 }
|
||||||
|
SpriteRenderer.STAND = STAND
|
||||||
|
SpriteRenderer.WALK = WALK
|
||||||
|
|
||||||
-- seed: any stable per-instance value (e.g. an NPC's `id`) used to resolve
|
-- seed: any stable per-instance value (e.g. an NPC's `id`) used to resolve
|
||||||
-- RED++'s per-instance "random" OBP sentinel (PaletteFX.spriteObp)
|
-- RED++'s per-instance "random" OBP sentinel (PaletteFX.spriteObp)
|
||||||
@@ -83,6 +87,32 @@ function SpriteRenderer.new(spriteDef, seed)
|
|||||||
return self
|
return self
|
||||||
end
|
end
|
||||||
|
|
||||||
|
-- The image this sprite would draw from right now: the plain sheet, or the
|
||||||
|
-- OBP-recolored bake of it. Exposed so a render pipeline can texture its
|
||||||
|
-- own geometry from the very same image -- the geometry carries sheet pixel
|
||||||
|
-- coordinates rather than baked colors, so sharing this one resolver is
|
||||||
|
-- what makes palette modes and sprite-replacing mods apply to 2D and 3D
|
||||||
|
-- alike.
|
||||||
|
--
|
||||||
|
-- Deliberately free of draw's bookkeeping: markTrueColor and
|
||||||
|
-- markSpriteRedraw exist to patch up the screen-space zone shader, and a
|
||||||
|
-- pipeline that renders into its own canvas never runs through it. For the
|
||||||
|
-- same reason the OG-RED bake is returned unconditionally here rather than
|
||||||
|
-- only during a redraw pass -- there is no later pass to restore it.
|
||||||
|
function SpriteRenderer:resolveImage()
|
||||||
|
if self.def.trueColor then return self.image end
|
||||||
|
if PaletteFX.usesGbcPack() then
|
||||||
|
local colors, group = PaletteFX.spriteObp(self.def, self.seed)
|
||||||
|
if colors then return getObpImage(self.def.image, colors, group) end
|
||||||
|
elseif PaletteFX.usesSpriteObp() then
|
||||||
|
-- OG boot-ROM OBJ palette: green on Red, pink on Blue (PaletteFX.ogObj
|
||||||
|
-- returns colors + a version-distinct cache group so the two never
|
||||||
|
-- collide in obpCache) -- see issue #155
|
||||||
|
return getObpImage(self.def.image, PaletteFX.ogObj())
|
||||||
|
end
|
||||||
|
return self.image
|
||||||
|
end
|
||||||
|
|
||||||
-- facing: down/up/left/right; walkPhase: 0 stand, 1 walk; flip: alternate
|
-- facing: down/up/left/right; walkPhase: 0 stand, 1 walk; flip: alternate
|
||||||
-- steps mirror the walk frame for up/down (GB uses OAM flip for this).
|
-- steps mirror the walk frame for up/down (GB uses OAM flip for this).
|
||||||
local function blitFrame(image, quad, x, y, flip, redraw)
|
local function blitFrame(image, quad, x, y, flip, redraw)
|
||||||
@@ -114,11 +144,13 @@ function SpriteRenderer:draw(px, py, camX, camY, facing, walkPhase, stepFlip)
|
|||||||
image = getObpImage(self.def.image, colors, group)
|
image = getObpImage(self.def.image, colors, group)
|
||||||
end
|
end
|
||||||
elseif PaletteFX.usesSpriteObp() and PaletteFX.spriteRedrawPassActive() then
|
elseif PaletteFX.usesSpriteObp() and PaletteFX.spriteRedrawPassActive() then
|
||||||
-- OG RED (GBC boot-ROM look): every OBJ wears the one global green
|
-- OG RED (GBC boot-ROM look): every OBJ wears the one global object
|
||||||
-- object palette. The red BG zone shader still runs over the world
|
-- palette -- green over Red's red background, pink over Blue's blue
|
||||||
-- canvas, so the baked sprite is queued for a post-zone redraw
|
-- background (PaletteFX.ogObj, #155). The BG zone shader still runs over
|
||||||
-- (PaletteFX.markSpriteRedraw) that restores its green pixels on top.
|
-- the world canvas, so the baked sprite is queued for a post-zone redraw
|
||||||
image = getObpImage(self.def.image, PaletteFX.GBC_OBJ, "gbcobj")
|
-- (PaletteFX.markSpriteRedraw) that restores its object-colored pixels on
|
||||||
|
-- top.
|
||||||
|
image = getObpImage(self.def.image, PaletteFX.ogObj())
|
||||||
redraw = true
|
redraw = true
|
||||||
end
|
end
|
||||||
-- single-frame sprites (item balls, fossils...) have one fixed pose;
|
-- single-frame sprites (item balls, fossils...) have one fixed pose;
|
||||||
|
|||||||
@@ -50,6 +50,7 @@ function TextBox.new(game, text, onDone, opts)
|
|||||||
self.charIndex = 0
|
self.charIndex = 0
|
||||||
self.shown = {} -- visible lines (max 2), each a list of glyph codes
|
self.shown = {} -- visible lines (max 2), each a list of glyph codes
|
||||||
self.waiting = false
|
self.waiting = false
|
||||||
|
self.contAdvance = false
|
||||||
self.done = false
|
self.done = false
|
||||||
self.blink = 0
|
self.blink = 0
|
||||||
self:beginLine()
|
self:beginLine()
|
||||||
@@ -65,7 +66,11 @@ TextBox.TOKENS = {
|
|||||||
PLAYER = function(game) return game.save.player.name or "RED" end,
|
PLAYER = function(game) return game.save.player.name or "RED" end,
|
||||||
RIVAL = function(game) return game.save.player.rival or "BLUE" end,
|
RIVAL = function(game) return game.save.player.rival or "BLUE" end,
|
||||||
RAM = function(game, arg)
|
RAM = function(game, arg)
|
||||||
return arg == "wStringBuffer" and game.stringBuffer or nil
|
if arg == "wStringBuffer" then return game.stringBuffer end
|
||||||
|
if arg == "wBoxNumString" then return game.boxNumString end
|
||||||
|
-- SendNewMonToBox / _SentToBoxText reads the deposited nick here
|
||||||
|
if arg == "wBoxMonNicks" then return game.boxMonNicks end
|
||||||
|
return nil
|
||||||
end,
|
end,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -83,31 +88,55 @@ end
|
|||||||
|
|
||||||
-- Split marked-up text into pages of lines. \v-scrolled lines become
|
-- Split marked-up text into pages of lines. \v-scrolled lines become
|
||||||
-- additional lines on the same page (the box scrolls them).
|
-- additional lines on the same page (the box scrolls them).
|
||||||
|
-- pages.contBefore[p][i] is true when line i was preceded by \v (cont):
|
||||||
|
-- pokered ContText waits for A/B + ▼ before scrolling that line in.
|
||||||
function TextBox.paginate(text, maxCols)
|
function TextBox.paginate(text, maxCols)
|
||||||
maxCols = maxCols or (Theme.textBox and Theme.textBox.maxCols) or MAX_COLS
|
maxCols = maxCols or (Theme.textBox and Theme.textBox.maxCols) or MAX_COLS
|
||||||
local pages = {}
|
local pages = {}
|
||||||
|
local contBefore = {}
|
||||||
|
local function pushLine(lines, conts, line, wait)
|
||||||
|
while #line > maxCols do
|
||||||
|
local cut = maxCols
|
||||||
|
for i = maxCols, 1, -1 do
|
||||||
|
if line:sub(i, i) == " " then cut = i break end
|
||||||
|
end
|
||||||
|
table.insert(lines, line:sub(1, cut))
|
||||||
|
table.insert(conts, wait)
|
||||||
|
wait = false
|
||||||
|
line = line:sub(cut + 1)
|
||||||
|
end
|
||||||
|
table.insert(lines, line)
|
||||||
|
table.insert(conts, wait)
|
||||||
|
end
|
||||||
for pageText in (text .. "\f"):gmatch("(.-)\f") do
|
for pageText in (text .. "\f"):gmatch("(.-)\f") do
|
||||||
if pageText ~= "" then
|
if pageText ~= "" then
|
||||||
local lines = {}
|
local lines, conts = {}, {}
|
||||||
for chunk in (pageText .. "\n"):gmatch("(.-)[\n\v]") do
|
local pos, waitNext = 1, false
|
||||||
local line = chunk
|
while true do
|
||||||
-- wrap long lines defensively (the source rarely needs it)
|
local npos = pageText:find("[\n\v]", pos)
|
||||||
while #line > maxCols do
|
if not npos then
|
||||||
local cut = maxCols
|
pushLine(lines, conts, pageText:sub(pos), waitNext)
|
||||||
for i = maxCols, 1, -1 do
|
break
|
||||||
if line:sub(i, i) == " " then cut = i break end
|
|
||||||
end
|
|
||||||
table.insert(lines, line:sub(1, cut))
|
|
||||||
line = line:sub(cut + 1)
|
|
||||||
end
|
end
|
||||||
table.insert(lines, line)
|
pushLine(lines, conts, pageText:sub(pos, npos - 1), waitNext)
|
||||||
|
waitNext = pageText:sub(npos, npos) == "\v"
|
||||||
|
pos = npos + 1
|
||||||
|
end
|
||||||
|
if lines[#lines] == "" then
|
||||||
|
table.remove(lines)
|
||||||
|
table.remove(conts)
|
||||||
|
end
|
||||||
|
if #lines > 0 then
|
||||||
|
table.insert(pages, lines)
|
||||||
|
table.insert(contBefore, conts)
|
||||||
end
|
end
|
||||||
-- drop trailing empty line from the final gmatch round
|
|
||||||
if lines[#lines] == "" then table.remove(lines) end
|
|
||||||
if #lines > 0 then table.insert(pages, lines) end
|
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
if #pages == 0 then pages = { { "" } } end
|
if #pages == 0 then
|
||||||
|
pages = { { "" } }
|
||||||
|
contBefore = { { false } }
|
||||||
|
end
|
||||||
|
pages.contBefore = contBefore
|
||||||
return pages
|
return pages
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -179,10 +208,17 @@ function TextBox:update(dt)
|
|||||||
if input:wasPressed("a") or input:wasPressed("b") then
|
if input:wasPressed("a") or input:wasPressed("b") then
|
||||||
require("src.core.Sound").play(self.game.data, "Press_AB")
|
require("src.core.Sound").play(self.game.data, "Press_AB")
|
||||||
self.waiting = false
|
self.waiting = false
|
||||||
self.shown = {}
|
if self.contAdvance then
|
||||||
self.pageIndex = self.pageIndex + 1
|
-- ContText / ManualTextScroll: keep the box, scroll one line
|
||||||
self.lineIndex = 1
|
self.contAdvance = false
|
||||||
self:beginLine()
|
self.lineIndex = self.lineIndex + 1
|
||||||
|
self:beginLine()
|
||||||
|
else
|
||||||
|
self.shown = {}
|
||||||
|
self.pageIndex = self.pageIndex + 1
|
||||||
|
self.lineIndex = 1
|
||||||
|
self:beginLine()
|
||||||
|
end
|
||||||
end
|
end
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
@@ -203,10 +239,19 @@ function TextBox:update(dt)
|
|||||||
-- line finished
|
-- line finished
|
||||||
local page = self.pages[self.pageIndex]
|
local page = self.pages[self.pageIndex]
|
||||||
if self.lineIndex < #page then
|
if self.lineIndex < #page then
|
||||||
self.lineIndex = self.lineIndex + 1
|
local nextIdx = self.lineIndex + 1
|
||||||
self:beginLine()
|
local conts = self.pages.contBefore and self.pages.contBefore[self.pageIndex]
|
||||||
|
if conts and conts[nextIdx] then
|
||||||
|
-- pokered <CONT>: ▼ + WaitForTextScrollButtonPress before scroll
|
||||||
|
self.waiting = true
|
||||||
|
self.contAdvance = true
|
||||||
|
else
|
||||||
|
self.lineIndex = nextIdx
|
||||||
|
self:beginLine()
|
||||||
|
end
|
||||||
elseif self.pageIndex < #self.pages then
|
elseif self.pageIndex < #self.pages then
|
||||||
self.waiting = true
|
self.waiting = true
|
||||||
|
self.contAdvance = false
|
||||||
else
|
else
|
||||||
self.done = true
|
self.done = true
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -179,6 +179,11 @@ local function recolorSample(r, g, b, a, colors)
|
|||||||
return col[1] / 255, col[2] / 255, col[3] / 255, a
|
return col[1] / 255, col[2] / 255, col[3] / 255, a
|
||||||
end
|
end
|
||||||
|
|
||||||
|
-- exported: a render pipeline bakes a map's palette into its own texture
|
||||||
|
-- atlas the same way, and has to land on the identical colors as the 2D
|
||||||
|
-- tiles it is standing in for
|
||||||
|
TileRenderer.recolorSample = recolorSample
|
||||||
|
|
||||||
-- the 8 shifted variants of one tile (built once per sheet + tile id [+
|
-- the 8 shifted variants of one tile (built once per sheet + tile id [+
|
||||||
-- gbcKey, when `colors` recolors it for RED++ -- see buildAnim])
|
-- gbcKey, when `colors` recolors it for RED++ -- see buildAnim])
|
||||||
local shiftVariants = {}
|
local shiftVariants = {}
|
||||||
@@ -537,7 +542,10 @@ end
|
|||||||
local function bakeBorderFill(self, block)
|
local function bakeBorderFill(self, block)
|
||||||
local border = self.map.tileset.blocks[block + 1]
|
local border = self.map.tileset.blocks[block + 1]
|
||||||
if not border then return end
|
if not border then return end
|
||||||
local canvas = love.graphics.newCanvas(32, 32)
|
-- 32x32 real pixels: a DPI-scaled canvas would bake the border block at a
|
||||||
|
-- fractional texel size and the repeat-wrapped image would then tile at
|
||||||
|
-- non-square pixels (#208, see src/render/PixelCanvas.lua)
|
||||||
|
local canvas = require("src.render.PixelCanvas").new(32, 32)
|
||||||
love.graphics.push("all")
|
love.graphics.push("all")
|
||||||
love.graphics.setCanvas(canvas)
|
love.graphics.setCanvas(canvas)
|
||||||
love.graphics.clear(1, 1, 1, 1)
|
love.graphics.clear(1, 1, 1, 1)
|
||||||
|
|||||||
@@ -4,19 +4,38 @@
|
|||||||
-- zoom. Persisted as save.options.zoom (default 0 = FIT).
|
-- zoom. Persisted as save.options.zoom (default 0 = FIT).
|
||||||
-- Spec: docs/new-features.md (survey zoom)
|
-- Spec: docs/new-features.md (survey zoom)
|
||||||
|
|
||||||
|
local Runtime = require("src.mods.Runtime")
|
||||||
|
|
||||||
local Zoom = {}
|
local Zoom = {}
|
||||||
|
|
||||||
Zoom.offset = 0
|
Zoom.offset = 0
|
||||||
|
|
||||||
-- effective integer scale s' in [1, 2*S]
|
-- legal offset range for a given fit scale (vanilla: survey at 1 px/world
|
||||||
function Zoom.scale(S)
|
-- through 2× fit). zoom.range may widen or shrink the window.
|
||||||
return math.max(1, math.min(2 * S, S + Zoom.offset))
|
|
||||||
end
|
|
||||||
|
|
||||||
-- legal offset range for a given fit scale
|
|
||||||
function Zoom.offsetRange(S)
|
function Zoom.offsetRange(S)
|
||||||
S = math.max(1, math.floor(tonumber(S) or 1))
|
S = math.max(1, math.floor(tonumber(S) or 1))
|
||||||
return 1 - S, S
|
local lo, hi = 1 - S, S
|
||||||
|
if Runtime.wantsHook("zoom.range") then
|
||||||
|
lo, hi = Runtime.call("zoom.range", function(a, b) return a, b end, lo, hi, S)
|
||||||
|
lo = math.floor(tonumber(lo) or (1 - S))
|
||||||
|
hi = math.floor(tonumber(hi) or S)
|
||||||
|
if lo > hi then lo, hi = hi, lo end
|
||||||
|
end
|
||||||
|
return lo, hi
|
||||||
|
end
|
||||||
|
|
||||||
|
-- effective scale s' = S + offset, clamped to the (possibly modded) range.
|
||||||
|
-- Vanilla stays in [1, 2*S]. A zoom.range wrapper that lowers `lo` below
|
||||||
|
-- 1-S permits sub-1 survey scales so the whole region can fit on screen.
|
||||||
|
function Zoom.scale(S)
|
||||||
|
local lo, hi = Zoom.offsetRange(S)
|
||||||
|
local s = S + Zoom.offset
|
||||||
|
local minScale = S + lo
|
||||||
|
local maxScale = math.max(minScale, S + hi)
|
||||||
|
if s < minScale then s = minScale end
|
||||||
|
if s > maxScale then s = maxScale end
|
||||||
|
if s < 0.25 then s = 0.25 end
|
||||||
|
return s
|
||||||
end
|
end
|
||||||
|
|
||||||
function Zoom.clampOffset(offset, S)
|
function Zoom.clampOffset(offset, S)
|
||||||
|
|||||||
@@ -0,0 +1,813 @@
|
|||||||
|
-- Vanilla Gen1 (Red/Blue, international) raw SRAM save (32768 bytes) <->
|
||||||
|
-- this project's save.lua shape (src/core/SaveData.lua / SaveData.newGame).
|
||||||
|
--
|
||||||
|
-- Pure Lua, no love.* dependency -- runs under plain luajit for the CLI
|
||||||
|
-- (tools/save_convert/convert.lua) and headless tests alike.
|
||||||
|
--
|
||||||
|
-- Every offset below was derived mechanically from the authoritative
|
||||||
|
-- source (../pokered/ram/wram.asm, ram/sram.asm, macros/ram.asm) and
|
||||||
|
-- cross-checked against three independently well-known Gen1 save
|
||||||
|
-- addresses: money @ 0x25F3, badges @ 0x2602, party data @ 0x2F2C --
|
||||||
|
-- all three fall out exactly right from the single sPlayerName anchor
|
||||||
|
-- below, strong triangulated confirmation the whole chain (SRAM bank 1
|
||||||
|
-- layout, wMainData field order, party_struct/box_struct sizes) is right.
|
||||||
|
--
|
||||||
|
-- SRAM layout (32768 bytes = 4 banks x 8192): bank 0 is sprite buffers +
|
||||||
|
-- Hall of Fame (not modeled -- see "explicitly out of scope" in the
|
||||||
|
-- save-converter plan); bank 1 is "Save Data" (sPlayerName through
|
||||||
|
-- sMainDataCheckSum); banks 2/3 are the 12 PC boxes (6 each) + checksums.
|
||||||
|
--
|
||||||
|
-- Fields with no equivalent in save.lua (current sprite/animation state,
|
||||||
|
-- connection-header cache, Day Care, Safari Zone, HOF roster) are
|
||||||
|
-- intentionally not modeled: on export, encode() starts from the
|
||||||
|
-- ORIGINAL imported bytes as a template when available (GenSave.decode
|
||||||
|
-- stashes them) so that scratch state round-trips untouched instead of
|
||||||
|
-- being invented; with no template (a save that originated in this
|
||||||
|
-- project) those bytes stay zero-filled, which is safe because the real
|
||||||
|
-- game regenerates all of it from wCurMap on the next map load anyway.
|
||||||
|
|
||||||
|
local bit = require("bit")
|
||||||
|
|
||||||
|
local GenSave = {}
|
||||||
|
|
||||||
|
-- ------------------------------------------------------------------
|
||||||
|
-- Absolute byte offsets (0-based, matching a raw 32768-byte .sav file)
|
||||||
|
-- ------------------------------------------------------------------
|
||||||
|
|
||||||
|
local NAME_LENGTH = 11
|
||||||
|
local PARTY_LENGTH = 6
|
||||||
|
local MONS_PER_BOX = 20
|
||||||
|
local NUM_BADGES = 8
|
||||||
|
local BOX_STRUCT_SIZE = 33 -- Species,HP,Level,Status,Type1,Type2,CatchRate,
|
||||||
|
-- Moves x4,OTID,Exp x3,HPExp,AtkExp,DefExp,
|
||||||
|
-- SpdExp,SpcExp,DVs,PP x4 (macros/ram.asm box_struct)
|
||||||
|
local PARTY_STRUCT_SIZE = 44 -- box_struct + Level + Stats x5 (party_struct)
|
||||||
|
local BOX_REGION_SIZE = 1 + (MONS_PER_BOX + 1) + MONS_PER_BOX * BOX_STRUCT_SIZE
|
||||||
|
+ MONS_PER_BOX * NAME_LENGTH + MONS_PER_BOX * NAME_LENGTH -- 1122
|
||||||
|
|
||||||
|
local O = {}
|
||||||
|
O.playerName = 9624 -- sPlayerName (11B) = 0x2598
|
||||||
|
O.mainData = O.playerName + NAME_LENGTH -- sMainData (wMainDataStart mirror)
|
||||||
|
O.pokedexOwned = O.mainData + 0 -- 19B (flag_array 151)
|
||||||
|
O.pokedexSeen = O.mainData + 19 -- 19B
|
||||||
|
O.numBagItems = O.mainData + 38 -- 1B
|
||||||
|
O.bagItems = O.mainData + 39 -- 41B (20 x (id,qty) + $FF term)
|
||||||
|
O.money = O.mainData + 80 -- 3B BCD = 0x25F3
|
||||||
|
O.rivalName = O.mainData + 83 -- 11B
|
||||||
|
O.options = O.mainData + 94 -- 1B
|
||||||
|
O.badges = O.mainData + 95 -- 1B = 0x2602
|
||||||
|
O.playerId = O.mainData + 98 -- 2B (big-endian)
|
||||||
|
O.curMap = O.mainData + 103 -- 1B
|
||||||
|
O.yCoord = O.mainData + 106 -- 1B
|
||||||
|
O.xCoord = O.mainData + 107 -- 1B
|
||||||
|
O.lastMap = O.mainData + 110 -- 1B
|
||||||
|
O.numPcItems = O.mainData + 579 -- 1B
|
||||||
|
O.pcItems = O.mainData + 580 -- 101B (50 x (id,qty) + $FF term)
|
||||||
|
O.currentBoxNum = O.mainData + 681 -- 1B (bits 0-6: box 0-11, bit 7: unused here)
|
||||||
|
O.coins = O.mainData + 685 -- 2B BCD
|
||||||
|
O.eventFlags = O.mainData + 1104 -- 320B (flag_array NUM_EVENTS = 2560 bits)
|
||||||
|
-- Play time (wPlayTimeHours/Maxed/Minutes/Seconds/Frames) lives INSIDE the
|
||||||
|
-- sMainData window (wMainDataStart..wMainDataEnd is copied verbatim into
|
||||||
|
-- SRAM), 1866 bytes past wMainDataStart -- reached from the checksum-verified
|
||||||
|
-- wEventFlags anchor: 320 (event flag_array) + 293 (the wGrassRate/enemy-party
|
||||||
|
-- battle UNION) + 66 + 66 (wEnemyMonOT/Nicks, 6 x NAME_LENGTH each; the
|
||||||
|
-- rgbds FOR n,1,PARTY_LENGTH+1 loop is end-exclusive => 6 mons, not 7) + 2
|
||||||
|
-- (wTrainerHeaderPtr) + 6 (ds) + 1 (wOpponentAfterWrongAnswer) + 1
|
||||||
|
-- (wCurMapScript) + 7 (ds) = 762. Confirmed on the real fixture: those five
|
||||||
|
-- bytes read 201h 30m 07s, a sane completed-save clock.
|
||||||
|
O.playTimeHours = O.mainData + 1866 -- 1B
|
||||||
|
O.playTimeMaxed = O.mainData + 1867 -- 1B (set once past 255h)
|
||||||
|
O.playTimeMinutes = O.mainData + 1868 -- 1B (0-59)
|
||||||
|
O.playTimeSeconds = O.mainData + 1869 -- 1B (0-59)
|
||||||
|
O.playTimeFrames = O.mainData + 1870 -- 1B (0-59, 1/60s ticks)
|
||||||
|
O.mainDataSize = 1929 -- wMainDataEnd - wMainDataStart
|
||||||
|
|
||||||
|
O.spriteData = O.mainData + O.mainDataSize
|
||||||
|
O.spriteDataSize = 512 -- 2 x 16 sprites x 16B
|
||||||
|
|
||||||
|
O.partyData = O.spriteData + O.spriteDataSize -- = 0x2F2C
|
||||||
|
O.partyCount = O.partyData
|
||||||
|
O.partySpecies = O.partyData + 1 -- 7B (PARTY_LENGTH+1)
|
||||||
|
O.partyMons = O.partyData + 8 -- 6 x 44B
|
||||||
|
O.partyMonOT = O.partyData + 8 + PARTY_LENGTH * PARTY_STRUCT_SIZE
|
||||||
|
O.partyMonNicks = O.partyMonOT + PARTY_LENGTH * NAME_LENGTH
|
||||||
|
O.partyDataSize = 1 + (PARTY_LENGTH + 1) + PARTY_LENGTH * PARTY_STRUCT_SIZE
|
||||||
|
+ PARTY_LENGTH * NAME_LENGTH + PARTY_LENGTH * NAME_LENGTH -- 404
|
||||||
|
|
||||||
|
O.curBoxData = O.partyData + O.partyDataSize
|
||||||
|
O.boxCount = O.curBoxData
|
||||||
|
O.boxSpecies = O.curBoxData + 1 -- 21B
|
||||||
|
O.boxMons = O.curBoxData + 22 -- 20 x 33B
|
||||||
|
O.boxMonOT = O.curBoxData + 22 + MONS_PER_BOX * BOX_STRUCT_SIZE
|
||||||
|
O.boxMonNicks = O.boxMonOT + MONS_PER_BOX * NAME_LENGTH
|
||||||
|
|
||||||
|
O.checksumStart = O.playerName
|
||||||
|
O.checksumEnd = O.curBoxData + BOX_REGION_SIZE + 1 -- + sTileAnimations (1B)
|
||||||
|
O.mainChecksum = O.checksumEnd -- 1B
|
||||||
|
|
||||||
|
O.box1 = 16384 -- bank 2 start
|
||||||
|
O.boxBank2Checksum = O.box1 + 6 * BOX_REGION_SIZE
|
||||||
|
O.boxBank2IndividualChecksums = O.boxBank2Checksum + 1 -- 6B
|
||||||
|
O.box7 = 24576 -- bank 3 start
|
||||||
|
O.boxBank3Checksum = O.box7 + 6 * BOX_REGION_SIZE
|
||||||
|
O.boxBank3IndividualChecksums = O.boxBank3Checksum + 1 -- 6B
|
||||||
|
|
||||||
|
GenSave.OFFSETS = O
|
||||||
|
GenSave.BOX_REGION_SIZE = BOX_REGION_SIZE
|
||||||
|
GenSave.SAVE_SIZE = 32768
|
||||||
|
|
||||||
|
-- ------------------------------------------------------------------
|
||||||
|
-- Byte-level helpers. `bytes` is a 32768-byte Lua string (1-based
|
||||||
|
-- indexing, so byte offset N is string position N+1); `buf` for writing
|
||||||
|
-- is a 32768-entry array of 1-char strings, joined at the end.
|
||||||
|
-- ------------------------------------------------------------------
|
||||||
|
|
||||||
|
local function u8(bytes, off) return bytes:byte(off + 1) end
|
||||||
|
local function u16be(bytes, off) return u8(bytes, off) * 256 + u8(bytes, off + 1) end
|
||||||
|
local function u24be(bytes, off)
|
||||||
|
return u8(bytes, off) * 65536 + u8(bytes, off + 1) * 256 + u8(bytes, off + 2)
|
||||||
|
end
|
||||||
|
|
||||||
|
local function setByte(buf, off, v)
|
||||||
|
buf[off + 1] = string.char(bit.band(v, 0xFF))
|
||||||
|
end
|
||||||
|
local function setU16be(buf, off, v)
|
||||||
|
setByte(buf, off, bit.band(bit.rshift(v, 8), 0xFF))
|
||||||
|
setByte(buf, off + 1, bit.band(v, 0xFF))
|
||||||
|
end
|
||||||
|
local function setU24be(buf, off, v)
|
||||||
|
setByte(buf, off, bit.band(bit.rshift(v, 16), 0xFF))
|
||||||
|
setByte(buf, off + 1, bit.band(bit.rshift(v, 8), 0xFF))
|
||||||
|
setByte(buf, off + 2, bit.band(v, 0xFF))
|
||||||
|
end
|
||||||
|
local function setBcd(buf, off, nbytes, v)
|
||||||
|
for i = nbytes - 1, 0, -1 do
|
||||||
|
local d = v % 100
|
||||||
|
v = math.floor(v / 100)
|
||||||
|
setByte(buf, off + i, math.floor(d / 10) * 16 + (d % 10))
|
||||||
|
end
|
||||||
|
end
|
||||||
|
local function readBcd(bytes, off, nbytes)
|
||||||
|
local n = 0
|
||||||
|
for i = 0, nbytes - 1 do
|
||||||
|
local b = u8(bytes, off + i)
|
||||||
|
n = n * 100 + math.floor(b / 16) * 10 + (b % 16)
|
||||||
|
end
|
||||||
|
return n
|
||||||
|
end
|
||||||
|
|
||||||
|
-- CalcCheckSum (engine/menus/save.asm): complement of the additive sum
|
||||||
|
local function checksum(bytes, from, to)
|
||||||
|
local sum = 0
|
||||||
|
for i = from, to - 1 do sum = bit.band(sum + u8(bytes, i), 0xFF) end
|
||||||
|
return bit.band(bit.bnot(sum), 0xFF)
|
||||||
|
end
|
||||||
|
|
||||||
|
-- flag_array packs LSB-first within each byte (bit 0 of byte 0 = index 0).
|
||||||
|
-- This is pokered's runtime FlagAction convention (home/predef macros): it
|
||||||
|
-- takes flag number N, addresses byte N/8, and builds the mask by rotating
|
||||||
|
-- a 1 left N%8 times starting from bit 0 -- i.e. flag N%8==0 is the LSB.
|
||||||
|
-- Same convention PKHeX uses for Gen1 dex/event flags (FlagUtil.GetFlag:
|
||||||
|
-- data[ofs + bit/8] >> (bit%8) & 1). Cross-validated against the real save:
|
||||||
|
-- ZAPDOS (dex 145) is physically boxed there, so its owned/seen flag must be
|
||||||
|
-- set; only the LSB reading returns it set (MSB-first spuriously drops
|
||||||
|
-- exactly that one bit at the byte-18 boundary), yielding a complete 151/151
|
||||||
|
-- dex. The prior MSB-first code round-tripped self-consistently but decoded
|
||||||
|
-- every flag_array (pokedex AND event flags) to the wrong bit.
|
||||||
|
local function bitGet(bytes, base, index)
|
||||||
|
local byteOff = base + math.floor(index / 8)
|
||||||
|
local b = u8(bytes, byteOff)
|
||||||
|
return bit.band(bit.rshift(b, index % 8), 1) == 1
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Set one bit directly in `buf` (0-based flag index into a flag_array
|
||||||
|
-- starting at `base`), preserving every other bit already in that byte --
|
||||||
|
-- template bytes (see encode()'s header note) survive for bits this pass
|
||||||
|
-- never explicitly touches, e.g. event-flag bits with no known name
|
||||||
|
-- sharing a byte with ones that do.
|
||||||
|
local function bitSet(buf, base, index, value)
|
||||||
|
local byteOff = base + math.floor(index / 8)
|
||||||
|
local bitIdx = index % 8
|
||||||
|
local cur = buf[byteOff + 1] and buf[byteOff + 1]:byte() or 0
|
||||||
|
local mask = bit.lshift(1, bitIdx)
|
||||||
|
setByte(buf, byteOff, value and bit.bor(cur, mask) or bit.band(cur, bit.bnot(mask)))
|
||||||
|
end
|
||||||
|
|
||||||
|
-- ------------------------------------------------------------------
|
||||||
|
-- Text (fixed-length name fields: charmap-encoded, "@" ($50) terminated,
|
||||||
|
-- $50-padded after the terminator). setCharmap(cm) must be called once
|
||||||
|
-- before decode/encode (src/save_convert/data/charmap.lua's shape).
|
||||||
|
-- ------------------------------------------------------------------
|
||||||
|
|
||||||
|
local charmap
|
||||||
|
|
||||||
|
function GenSave.setCharmap(cm) charmap = cm end
|
||||||
|
|
||||||
|
local function decodeName(bytes, off, len)
|
||||||
|
local out = {}
|
||||||
|
for i = 0, len - 1 do
|
||||||
|
local b = u8(bytes, off + i)
|
||||||
|
if b == 0x50 then break end
|
||||||
|
out[#out + 1] = charmap.byByte[b] or "?"
|
||||||
|
end
|
||||||
|
return table.concat(out)
|
||||||
|
end
|
||||||
|
|
||||||
|
local function encodeName(buf, off, len, text)
|
||||||
|
local i, pos = 0, 1
|
||||||
|
while i < len - 1 and pos <= #text do
|
||||||
|
-- a bracketed control token (e.g. "<DOT>", from decodeName reading a
|
||||||
|
-- byte with no plain-glyph mapping) is ONE game character despite
|
||||||
|
-- being several text bytes here; match it as a whole unit first, or
|
||||||
|
-- it would fall through to per-byte matching and turn into "?" x5
|
||||||
|
local bracket = text:match("^(<[^<>]*>)", pos)
|
||||||
|
local ch, clen
|
||||||
|
if bracket and charmap.byToken[bracket] then
|
||||||
|
ch, clen = bracket, #bracket
|
||||||
|
else
|
||||||
|
local b0 = text:byte(pos)
|
||||||
|
clen = (b0 < 0x80 and 1) or (b0 < 0xE0 and 2) or (b0 < 0xF0 and 3) or 4
|
||||||
|
ch = text:sub(pos, pos + clen - 1)
|
||||||
|
end
|
||||||
|
setByte(buf, off + i, charmap.byToken[ch] or charmap.byToken["?"] or 0x50)
|
||||||
|
i, pos = i + 1, pos + clen
|
||||||
|
end
|
||||||
|
-- Write exactly ONE $50 terminator and then STOP. The bytes after it are
|
||||||
|
-- left untouched: when encoding over a template they stay as the original
|
||||||
|
-- save's post-terminator padding (so an unchanged name round-trips
|
||||||
|
-- byte-identical), and on a templateless export they stay zero-filled. The
|
||||||
|
-- game reads a name only up to the first $50, so whatever follows is inert.
|
||||||
|
if i < len then setByte(buf, off + i, 0x50) end
|
||||||
|
end
|
||||||
|
|
||||||
|
-- ------------------------------------------------------------------
|
||||||
|
-- DV / PP packing (box_struct DVs, PP)
|
||||||
|
-- ------------------------------------------------------------------
|
||||||
|
|
||||||
|
-- DVs:: dw, packed as byte0=(Attack<<4)|Defense, byte1=(Speed<<4)|Special;
|
||||||
|
-- HP DV is derived, not stored, from each stat DV's low bit.
|
||||||
|
local function decodeDVs(bytes, off)
|
||||||
|
local b0, b1 = u8(bytes, off), u8(bytes, off + 1)
|
||||||
|
local atk, def = bit.rshift(b0, 4), bit.band(b0, 0xF)
|
||||||
|
local spe, spc = bit.rshift(b1, 4), bit.band(b1, 0xF)
|
||||||
|
local hp = bit.bor(bit.lshift(bit.band(atk, 1), 3), bit.lshift(bit.band(def, 1), 2),
|
||||||
|
bit.lshift(bit.band(spe, 1), 1), bit.band(spc, 1))
|
||||||
|
return { hp = hp, attack = atk, defense = def, speed = spe, special = spc }
|
||||||
|
end
|
||||||
|
|
||||||
|
local function encodeDVs(buf, off, dvs)
|
||||||
|
setByte(buf, off, bit.bor(bit.lshift(bit.band(dvs.attack or 0, 0xF), 4), bit.band(dvs.defense or 0, 0xF)))
|
||||||
|
setByte(buf, off + 1, bit.bor(bit.lshift(bit.band(dvs.speed or 0, 0xF), 4), bit.band(dvs.special or 0, 0xF)))
|
||||||
|
end
|
||||||
|
|
||||||
|
-- PP byte: top 2 bits = PP Up count (0-3), bottom 6 bits = current PP
|
||||||
|
local function decodePPByte(b) return bit.band(b, 0x3F), bit.rshift(b, 6) end
|
||||||
|
local function encodePPByte(pp, ppUps) return bit.bor(bit.lshift(bit.band(ppUps or 0, 3), 6), bit.band(pp or 0, 0x3F)) end
|
||||||
|
|
||||||
|
-- ------------------------------------------------------------------
|
||||||
|
-- Crosswalks (built once from `data` = {pokemon=,moves=,items=,maps=})
|
||||||
|
-- ------------------------------------------------------------------
|
||||||
|
|
||||||
|
-- pokered constants/type_constants.asm PHYSICAL/SPECIAL block; stable,
|
||||||
|
-- not worth a dedicated extractor for 15 names.
|
||||||
|
local TYPE_BY_INDEX = {
|
||||||
|
[0] = "NORMAL", [1] = "FIGHTING", [2] = "FLYING", [3] = "POISON",
|
||||||
|
[4] = "GROUND", [5] = "ROCK", [6] = "BIRD", [7] = "BUG", [8] = "GHOST",
|
||||||
|
[20] = "FIRE", [21] = "WATER", [22] = "GRASS", [23] = "ELECTRIC",
|
||||||
|
[24] = "PSYCHIC_TYPE", [25] = "ICE", [26] = "DRAGON",
|
||||||
|
}
|
||||||
|
local TYPE_INDEX = {}
|
||||||
|
for i, name in pairs(TYPE_BY_INDEX) do TYPE_INDEX[name] = i end
|
||||||
|
|
||||||
|
-- Badge bit order (constants/ram_constants.asm BIT_BOULDERBADGE=0 ..
|
||||||
|
-- BIT_EARTHBADGE=7); this project stores badges as truthy
|
||||||
|
-- save.inventory[id] entries, not a flag or a separate bitmask
|
||||||
|
-- (src/inventory/Badges.lua Badges.list's VANILLA order matches exactly).
|
||||||
|
local BADGE_BY_BIT = {
|
||||||
|
[0] = "BOULDERBADGE", [1] = "CASCADEBADGE", [2] = "THUNDERBADGE",
|
||||||
|
[3] = "RAINBOWBADGE", [4] = "SOULBADGE", [5] = "MARSHBADGE",
|
||||||
|
[6] = "VOLCANOBADGE", [7] = "EARTHBADGE",
|
||||||
|
}
|
||||||
|
local BADGE_BY_BIT_SET = {}
|
||||||
|
for _, name in pairs(BADGE_BY_BIT) do BADGE_BY_BIT_SET[name] = true end
|
||||||
|
|
||||||
|
-- STATUS_* bits (constants/battle_constants.asm): 0-2 sleep-turns-left,
|
||||||
|
-- 3 PSN, 4 BRN, 5 FRZ, 6 PAR
|
||||||
|
local STATUS_BIT = { PSN = 3, BRN = 4, FRZ = 5, PAR = 6 }
|
||||||
|
local function decodeStatus(b)
|
||||||
|
if bit.band(b, 7) > 0 then return "SLP" end
|
||||||
|
for name, bitIdx in pairs(STATUS_BIT) do
|
||||||
|
if bit.band(b, bit.lshift(1, bitIdx)) ~= 0 then return name end
|
||||||
|
end
|
||||||
|
return nil
|
||||||
|
end
|
||||||
|
local function encodeStatus(status)
|
||||||
|
if status == "SLP" then return 7 end
|
||||||
|
if status and STATUS_BIT[status] then return bit.lshift(1, STATUS_BIT[status]) end
|
||||||
|
return 0
|
||||||
|
end
|
||||||
|
|
||||||
|
local function buildIndexCrosswalk(defs)
|
||||||
|
local byIndex, byId = {}, {}
|
||||||
|
for id, def in pairs(defs or {}) do
|
||||||
|
if def.index ~= nil then
|
||||||
|
byIndex[def.index] = id
|
||||||
|
byId[id] = def.index
|
||||||
|
end
|
||||||
|
end
|
||||||
|
return byIndex, byId
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Pokedex bit position: NATIONAL DEX NUMBER (1-151), NOT the internal ROM
|
||||||
|
-- species byte (`def.index`, used for party/box mon structs) -- these are
|
||||||
|
-- two completely different Gen1 numbering schemes (the whole "MissingNo"
|
||||||
|
-- phenomenon is dex-number vs internal-index mismatches). This project's
|
||||||
|
-- generated data has no dedicated dex-number field, but every pokemon.lua
|
||||||
|
-- entry's `source` documents its extraction origin as "ROM:BaseStats[N]",
|
||||||
|
-- and BaseStats is declared in dex order in the disassembly -- verified
|
||||||
|
-- directly against 5 species (BULBASAUR->[1], CHARMANDER->[4],
|
||||||
|
-- SQUIRTLE->[7], PIKACHU->[25], MEWTWO->[150], all exactly their real
|
||||||
|
-- national dex numbers) before relying on it here.
|
||||||
|
local function buildDexCrosswalk(defs)
|
||||||
|
local byDex, dexOf = {}, {}
|
||||||
|
for id, def in pairs(defs or {}) do
|
||||||
|
local n = def.source and tonumber(def.source:match("BaseStats%[(%d+)%]"))
|
||||||
|
if n then
|
||||||
|
byDex[n] = id
|
||||||
|
dexOf[id] = n
|
||||||
|
end
|
||||||
|
end
|
||||||
|
return byDex, dexOf
|
||||||
|
end
|
||||||
|
|
||||||
|
-- TM/HM item entries carry no `index` (data/generated/items.lua extracts
|
||||||
|
-- them by move/slot, not by their place in the raw item-constant table),
|
||||||
|
-- so buildIndexCrosswalk alone would silently drop every TM/HM from the
|
||||||
|
-- bag/PC on encode. Their real item ids ARE derivable: pokered's
|
||||||
|
-- constants/item_constants.asm declares "HM_\1: the item id, starting at
|
||||||
|
-- $C4" and "TM_\1: the item id, starting at $C9" for slot 1, incrementing
|
||||||
|
-- per slot -- i.e. HM01=196+.. , TM01=201+(number-1).
|
||||||
|
local function addMachineIndices(defs, byIndex, byId)
|
||||||
|
for id, def in pairs(defs or {}) do
|
||||||
|
if byId[id] == nil and def.machine and def.machine.number then
|
||||||
|
local base = def.machine.kind == "HM" and 195 or 200
|
||||||
|
local idx = base + def.machine.number
|
||||||
|
byIndex[idx] = id
|
||||||
|
byId[id] = idx
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
function GenSave.crosswalks(data)
|
||||||
|
local pokemonByIndex, pokemonIndex = buildIndexCrosswalk(data.pokemon)
|
||||||
|
local movesByIndex, movesIndex = buildIndexCrosswalk(data.moves)
|
||||||
|
local itemsByIndex, itemsIndex = buildIndexCrosswalk(data.items)
|
||||||
|
addMachineIndices(data.items, itemsByIndex, itemsIndex)
|
||||||
|
local mapsByIndex, mapsIndex = buildIndexCrosswalk(data.maps)
|
||||||
|
local pokemonByDex, pokemonDex = buildDexCrosswalk(data.pokemon)
|
||||||
|
return {
|
||||||
|
pokemonByIndex = pokemonByIndex, pokemonIndex = pokemonIndex,
|
||||||
|
pokemonByDex = pokemonByDex, pokemonDex = pokemonDex,
|
||||||
|
movesByIndex = movesByIndex, movesIndex = movesIndex,
|
||||||
|
itemsByIndex = itemsByIndex, itemsIndex = itemsIndex,
|
||||||
|
mapsByIndex = mapsByIndex, mapsIndex = mapsIndex,
|
||||||
|
speciesDefs = data.pokemon or {},
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
-- ------------------------------------------------------------------
|
||||||
|
-- Mon struct (box_struct is a byte-for-byte prefix of party_struct;
|
||||||
|
-- decodeMon reads the box_struct fields, then Level+Stats if isParty).
|
||||||
|
-- Type1/Type2 are read for nothing (this project derives type from
|
||||||
|
-- species) but re-derived from data.pokemon[species].types on encode.
|
||||||
|
-- ------------------------------------------------------------------
|
||||||
|
|
||||||
|
local function decodeMon(bytes, off, isParty, cw)
|
||||||
|
local speciesIdx = u8(bytes, off)
|
||||||
|
if speciesIdx == 0 then return nil end -- empty slot
|
||||||
|
local species = cw.pokemonByIndex[speciesIdx]
|
||||||
|
local hp = u16be(bytes, off + 1)
|
||||||
|
local boxLevel = u8(bytes, off + 3)
|
||||||
|
local status = decodeStatus(u8(bytes, off + 4))
|
||||||
|
local catchRate = u8(bytes, off + 7)
|
||||||
|
local moves = {}
|
||||||
|
for i = 0, 3 do
|
||||||
|
local moveIdx = u8(bytes, off + 8 + i)
|
||||||
|
if moveIdx > 0 then
|
||||||
|
local pp, ppUps = decodePPByte(u8(bytes, off + 29 + i))
|
||||||
|
moves[#moves + 1] = { id = cw.movesByIndex[moveIdx], pp = pp, ppUps = ppUps }
|
||||||
|
end
|
||||||
|
end
|
||||||
|
local otId = u16be(bytes, off + 12)
|
||||||
|
local exp = u24be(bytes, off + 14)
|
||||||
|
local statExp = {
|
||||||
|
hp = u16be(bytes, off + 17), attack = u16be(bytes, off + 19),
|
||||||
|
defense = u16be(bytes, off + 21), speed = u16be(bytes, off + 23),
|
||||||
|
special = u16be(bytes, off + 25),
|
||||||
|
}
|
||||||
|
local dvs = decodeDVs(bytes, off + 27)
|
||||||
|
local mon = {
|
||||||
|
species = species, exp = exp, dvs = dvs, statExp = statExp,
|
||||||
|
hp = hp, status = status, moves = moves, otId = otId,
|
||||||
|
catchRate = catchRate, level = boxLevel,
|
||||||
|
-- Type1/Type2 as physically stored. This project derives type from species
|
||||||
|
-- for gameplay, but the raw bytes are captured so encode() can reproduce
|
||||||
|
-- them verbatim: some real saves (traded/tampered mons) carry type values
|
||||||
|
-- that do not match the ROM base stats, and re-deriving would corrupt them.
|
||||||
|
typeBytes = { u8(bytes, off + 5), u8(bytes, off + 6) },
|
||||||
|
}
|
||||||
|
if isParty then
|
||||||
|
mon.level = u8(bytes, off + 33)
|
||||||
|
mon.stats = {
|
||||||
|
hp = u16be(bytes, off + 34), attack = u16be(bytes, off + 36),
|
||||||
|
defense = u16be(bytes, off + 38), speed = u16be(bytes, off + 40),
|
||||||
|
special = u16be(bytes, off + 42),
|
||||||
|
}
|
||||||
|
end
|
||||||
|
return mon
|
||||||
|
end
|
||||||
|
|
||||||
|
local function encodeMon(buf, off, mon, isParty, cw)
|
||||||
|
if not mon then
|
||||||
|
setByte(buf, off, 0)
|
||||||
|
return
|
||||||
|
end
|
||||||
|
setByte(buf, off, cw.pokemonIndex[mon.species] or 0)
|
||||||
|
setU16be(buf, off + 1, mon.hp or 0)
|
||||||
|
setByte(buf, off + 3, mon.level or 1)
|
||||||
|
setByte(buf, off + 4, encodeStatus(mon.status))
|
||||||
|
local def = cw.speciesDefs[mon.species]
|
||||||
|
if mon.typeBytes then
|
||||||
|
-- reproduce the exact stored type bytes captured on decode (faithful
|
||||||
|
-- byte round-trip); fresh, engine-built mons have none and derive below.
|
||||||
|
setByte(buf, off + 5, mon.typeBytes[1] or 0)
|
||||||
|
setByte(buf, off + 6, mon.typeBytes[2] or 0)
|
||||||
|
else
|
||||||
|
local t = (def and def.types) or {}
|
||||||
|
setByte(buf, off + 5, TYPE_INDEX[t[1]] or 0)
|
||||||
|
setByte(buf, off + 6, TYPE_INDEX[t[2] or t[1]] or 0)
|
||||||
|
end
|
||||||
|
setByte(buf, off + 7, mon.catchRate or (def and def.catchRate) or 0)
|
||||||
|
for i = 0, 3 do
|
||||||
|
local mv = mon.moves and mon.moves[i + 1]
|
||||||
|
setByte(buf, off + 8 + i, mv and (cw.movesIndex[mv.id] or 0) or 0)
|
||||||
|
setByte(buf, off + 29 + i, mv and encodePPByte(mv.pp, mv.ppUps) or 0)
|
||||||
|
end
|
||||||
|
setU16be(buf, off + 12, mon.otId or 0)
|
||||||
|
setU24be(buf, off + 14, mon.exp or 0)
|
||||||
|
local se = mon.statExp or {}
|
||||||
|
setU16be(buf, off + 17, se.hp or 0)
|
||||||
|
setU16be(buf, off + 19, se.attack or 0)
|
||||||
|
setU16be(buf, off + 21, se.defense or 0)
|
||||||
|
setU16be(buf, off + 23, se.speed or 0)
|
||||||
|
setU16be(buf, off + 25, se.special or 0)
|
||||||
|
encodeDVs(buf, off + 27, mon.dvs or {})
|
||||||
|
if isParty then
|
||||||
|
setByte(buf, off + 33, mon.level or 1)
|
||||||
|
local st = mon.stats or {}
|
||||||
|
setU16be(buf, off + 34, st.hp or 0)
|
||||||
|
setU16be(buf, off + 36, st.attack or 0)
|
||||||
|
setU16be(buf, off + 38, st.defense or 0)
|
||||||
|
setU16be(buf, off + 40, st.speed or 0)
|
||||||
|
setU16be(buf, off + 42, st.special or 0)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
-- ------------------------------------------------------------------
|
||||||
|
-- Bag / PC items: (id, qty) byte pairs, $FF-terminated
|
||||||
|
-- ------------------------------------------------------------------
|
||||||
|
|
||||||
|
local function decodeItemList(bytes, off, capacity, cw)
|
||||||
|
local inventory, order = {}, {}
|
||||||
|
for i = 0, capacity - 1 do
|
||||||
|
local idByte = u8(bytes, off + i * 2)
|
||||||
|
if idByte == 0xFF then break end
|
||||||
|
local qty = u8(bytes, off + i * 2 + 1)
|
||||||
|
local id = cw.itemsByIndex[idByte]
|
||||||
|
if id then
|
||||||
|
inventory[id] = qty
|
||||||
|
order[#order + 1] = id
|
||||||
|
end
|
||||||
|
end
|
||||||
|
return inventory, order
|
||||||
|
end
|
||||||
|
|
||||||
|
local function encodeItemList(buf, off, capacity, inventory, order, cw)
|
||||||
|
local i = 0
|
||||||
|
local seen = {}
|
||||||
|
local function put(id, qty)
|
||||||
|
if i >= capacity or not qty or qty <= 0 then return end
|
||||||
|
local idByte = cw.itemsIndex[id]
|
||||||
|
if not idByte then return end
|
||||||
|
setByte(buf, off + i * 2, idByte)
|
||||||
|
setByte(buf, off + i * 2 + 1, math.min(qty, 99))
|
||||||
|
i = i + 1
|
||||||
|
seen[id] = true
|
||||||
|
end
|
||||||
|
for _, id in ipairs(order or {}) do
|
||||||
|
if inventory[id] and not seen[id] then put(id, inventory[id]) end
|
||||||
|
end
|
||||||
|
for id, qty in pairs(inventory or {}) do
|
||||||
|
if not seen[id] then put(id, qty) end
|
||||||
|
end
|
||||||
|
setByte(buf, off + i * 2, 0xFF)
|
||||||
|
return i -- count actually written (badges etc. in `inventory` that
|
||||||
|
-- aren't real items are silently skipped by put(), so this
|
||||||
|
-- can be less than #inventory -- see the wNumBagItems caller)
|
||||||
|
end
|
||||||
|
|
||||||
|
-- ------------------------------------------------------------------
|
||||||
|
-- decode: raw 32768-byte SRAM string -> save.lua-shaped table
|
||||||
|
-- ------------------------------------------------------------------
|
||||||
|
|
||||||
|
function GenSave.decode(bytes, data, opts)
|
||||||
|
assert(#bytes == GenSave.SAVE_SIZE, "expected a 32768-byte save")
|
||||||
|
local cw = GenSave.crosswalks(data)
|
||||||
|
local warnings = {}
|
||||||
|
local function warn(msg) warnings[#warnings + 1] = msg end
|
||||||
|
|
||||||
|
if checksum(bytes, O.checksumStart, O.checksumEnd) ~= u8(bytes, O.mainChecksum) then
|
||||||
|
warn("main data checksum mismatch (importing anyway)")
|
||||||
|
end
|
||||||
|
|
||||||
|
local save = {
|
||||||
|
meta = { format = "gen1_import" },
|
||||||
|
player = {
|
||||||
|
name = decodeName(bytes, O.playerName, NAME_LENGTH),
|
||||||
|
rival = decodeName(bytes, O.rivalName, NAME_LENGTH),
|
||||||
|
id = u16be(bytes, O.playerId),
|
||||||
|
},
|
||||||
|
money = readBcd(bytes, O.money, 3),
|
||||||
|
coins = readBcd(bytes, O.coins, 2),
|
||||||
|
inventory = {},
|
||||||
|
pcItems = {},
|
||||||
|
pokedex = { seen = {}, owned = {} },
|
||||||
|
flags = {},
|
||||||
|
party = {},
|
||||||
|
boxes = {},
|
||||||
|
currentBox = 1,
|
||||||
|
}
|
||||||
|
|
||||||
|
-- pokedex (own/seen, flag_array NUM_POKEMON: bit 0 = dex #1)
|
||||||
|
for dex, species in pairs(cw.pokemonByDex) do
|
||||||
|
local bitIdx = dex - 1
|
||||||
|
if bitGet(bytes, O.pokedexOwned, bitIdx) then save.pokedex.owned[species] = true end
|
||||||
|
if bitGet(bytes, O.pokedexSeen, bitIdx) then save.pokedex.seen[species] = true end
|
||||||
|
end
|
||||||
|
|
||||||
|
save.inventory, save.bagOrder = decodeItemList(bytes, O.bagItems, 20, cw)
|
||||||
|
save.pcItems, save.pcOrder = decodeItemList(bytes, O.pcItems, 50, cw)
|
||||||
|
|
||||||
|
-- badges: truthy save.inventory[id] entries (src/inventory/Badges.lua),
|
||||||
|
-- set AFTER decodeItemList since that call replaces save.inventory
|
||||||
|
local badgesByte = u8(bytes, O.badges)
|
||||||
|
for i = 0, NUM_BADGES - 1 do
|
||||||
|
if bit.band(badgesByte, bit.lshift(1, i)) ~= 0 then
|
||||||
|
save.inventory[BADGE_BY_BIT[i]] = 1
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
-- party
|
||||||
|
local partyCount = u8(bytes, O.partyCount)
|
||||||
|
for i = 0, math.min(partyCount, PARTY_LENGTH) - 1 do
|
||||||
|
local mon = decodeMon(bytes, O.partyMons + i * PARTY_STRUCT_SIZE, true, cw)
|
||||||
|
if mon then
|
||||||
|
mon.ot = decodeName(bytes, O.partyMonOT + i * NAME_LENGTH, NAME_LENGTH)
|
||||||
|
mon.nickname = decodeName(bytes, O.partyMonNicks + i * NAME_LENGTH, NAME_LENGTH)
|
||||||
|
save.party[#save.party + 1] = mon
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
-- current box (bank 1) + the 11 stored boxes (banks 2/3)
|
||||||
|
for i = 1, 12 do save.boxes[i] = {} end
|
||||||
|
local function decodeBoxRegion(base, boxNum)
|
||||||
|
local count = u8(bytes, base)
|
||||||
|
for i = 0, math.min(count, MONS_PER_BOX) - 1 do
|
||||||
|
local mon = decodeMon(bytes, base + 22 + i * BOX_STRUCT_SIZE, false, cw)
|
||||||
|
if mon then
|
||||||
|
mon.ot = decodeName(bytes, base + 22 + MONS_PER_BOX * BOX_STRUCT_SIZE + i * NAME_LENGTH, NAME_LENGTH)
|
||||||
|
mon.nickname = decodeName(bytes, base + 22 + MONS_PER_BOX * (BOX_STRUCT_SIZE + NAME_LENGTH) + i * NAME_LENGTH, NAME_LENGTH)
|
||||||
|
table.insert(save.boxes[boxNum], mon)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
local curBoxNum = bit.band(u8(bytes, O.currentBoxNum), 0x7F) -- 0-based box index
|
||||||
|
curBoxNum = math.max(1, math.min(12, curBoxNum + 1))
|
||||||
|
decodeBoxRegion(O.curBoxData, curBoxNum)
|
||||||
|
for b = 1, 6 do
|
||||||
|
if b + 0 ~= curBoxNum then decodeBoxRegion(O.box1 + (b - 1) * BOX_REGION_SIZE, b) end
|
||||||
|
end
|
||||||
|
for b = 7, 12 do
|
||||||
|
if b ~= curBoxNum then decodeBoxRegion(O.box7 + (b - 7) * BOX_REGION_SIZE, b) end
|
||||||
|
end
|
||||||
|
save.currentBox = curBoxNum
|
||||||
|
|
||||||
|
-- event flags (only bits with a known name are decoded)
|
||||||
|
local events = data.eventFlags
|
||||||
|
if events then
|
||||||
|
for bitIdx, name in pairs(events.byBit) do
|
||||||
|
if bitGet(bytes, O.eventFlags, bitIdx) then save.flags[name] = true end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
-- map + position
|
||||||
|
local mapIdx = u8(bytes, O.curMap)
|
||||||
|
local mapId = cw.mapsByIndex[mapIdx]
|
||||||
|
local y, x = u8(bytes, O.yCoord), u8(bytes, O.xCoord)
|
||||||
|
if mapId then
|
||||||
|
save.player.map, save.player.x, save.player.y = mapId, x, y
|
||||||
|
else
|
||||||
|
warn(("unknown map index %d, defaulting spawn"):format(mapIdx))
|
||||||
|
end
|
||||||
|
local lastMapIdx = u8(bytes, O.lastMap)
|
||||||
|
local lastMapId = cw.mapsByIndex[lastMapIdx]
|
||||||
|
if lastMapId then save.lastOutdoor = { id = lastMapId } end
|
||||||
|
|
||||||
|
-- play time: this project stores save.playTime as a single float of
|
||||||
|
-- SECONDS (src/core/Game.lua accumulates dt each frame; StartMenu /
|
||||||
|
-- TrainerCard / TitleState render it H:MM via t/3600 and (t/60)%60).
|
||||||
|
-- Fold the Gen1 H/M/S/F fields into that one number; frames are 1/60s
|
||||||
|
-- sub-second ticks, kept as a fraction so an export recovers them exactly.
|
||||||
|
save.playTime = u8(bytes, O.playTimeHours) * 3600
|
||||||
|
+ u8(bytes, O.playTimeMinutes) * 60
|
||||||
|
+ u8(bytes, O.playTimeSeconds)
|
||||||
|
+ u8(bytes, O.playTimeFrames) / 60
|
||||||
|
|
||||||
|
save.warnings = warnings
|
||||||
|
save.rawImport = bytes -- template for a later encode(); see file header
|
||||||
|
return save
|
||||||
|
end
|
||||||
|
|
||||||
|
-- ------------------------------------------------------------------
|
||||||
|
-- encode: save.lua-shaped table -> raw 32768-byte SRAM string
|
||||||
|
-- ------------------------------------------------------------------
|
||||||
|
|
||||||
|
function GenSave.encode(save, data, template)
|
||||||
|
local cw = GenSave.crosswalks(data)
|
||||||
|
local src = template or save.rawImport
|
||||||
|
local buf = {}
|
||||||
|
if src then
|
||||||
|
for i = 1, GenSave.SAVE_SIZE do buf[i] = src:sub(i, i) end
|
||||||
|
else
|
||||||
|
local zero = string.char(0)
|
||||||
|
for i = 1, GenSave.SAVE_SIZE do buf[i] = zero end
|
||||||
|
end
|
||||||
|
|
||||||
|
encodeName(buf, O.playerName, NAME_LENGTH, (save.player and save.player.name) or "RED")
|
||||||
|
encodeName(buf, O.rivalName, NAME_LENGTH, (save.player and save.player.rival) or "BLUE")
|
||||||
|
setU16be(buf, O.playerId, (save.player and save.player.id) or 0)
|
||||||
|
setBcd(buf, O.money, 3, math.min(save.money or 0, 999999))
|
||||||
|
setBcd(buf, O.coins, 2, math.min(save.coins or 0, 9999))
|
||||||
|
|
||||||
|
local badgesByte = 0
|
||||||
|
for bitIdx, name in pairs(BADGE_BY_BIT) do
|
||||||
|
if save.inventory and save.inventory[name] then
|
||||||
|
badgesByte = bit.bor(badgesByte, bit.lshift(1, bitIdx))
|
||||||
|
end
|
||||||
|
end
|
||||||
|
setByte(buf, O.badges, badgesByte)
|
||||||
|
|
||||||
|
for dex, species in pairs(cw.pokemonByDex) do
|
||||||
|
local bitIdx = dex - 1
|
||||||
|
bitSet(buf, O.pokedexOwned, bitIdx,
|
||||||
|
(save.pokedex and save.pokedex.owned and save.pokedex.owned[species]) and true or false)
|
||||||
|
bitSet(buf, O.pokedexSeen, bitIdx,
|
||||||
|
(save.pokedex and save.pokedex.seen and save.pokedex.seen[species]) and true or false)
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Badges occupy real item IDs in data/generated/items.lua (Gen1's item
|
||||||
|
-- ID space includes them, $01-$08, for the "got the BOULDERBADGE!"
|
||||||
|
-- text display), but this project's save.lua stores them as truthy
|
||||||
|
-- save.inventory[id] entries alongside actual bag items (see the badge
|
||||||
|
-- block above and src/inventory/Badges.lua) -- a real save NEVER
|
||||||
|
-- writes them into wBagItems (they only ever live in wObtainedBadges,
|
||||||
|
-- already encoded above), so they must be filtered out here or they'd
|
||||||
|
-- corrupt the bag with bogus "badge items".
|
||||||
|
local bagInventory = {}
|
||||||
|
for id, qty in pairs(save.inventory or {}) do
|
||||||
|
if not BADGE_BY_BIT_SET[id] then bagInventory[id] = qty end
|
||||||
|
end
|
||||||
|
local bagN = encodeItemList(buf, O.bagItems, 20, bagInventory, save.bagOrder, cw)
|
||||||
|
setByte(buf, O.numBagItems, bagN)
|
||||||
|
local pcN = encodeItemList(buf, O.pcItems, 50, save.pcItems or {}, save.pcOrder, cw)
|
||||||
|
setByte(buf, O.numPcItems, pcN)
|
||||||
|
|
||||||
|
local events = data.eventFlags
|
||||||
|
if events and save.flags then
|
||||||
|
for name in pairs(save.flags) do
|
||||||
|
local bitIdx = events.byName[name]
|
||||||
|
if bitIdx then bitSet(buf, O.eventFlags, bitIdx, true) end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
-- party
|
||||||
|
local party = save.party or {}
|
||||||
|
local partyN = math.min(#party, PARTY_LENGTH)
|
||||||
|
setByte(buf, O.partyCount, partyN)
|
||||||
|
for i = 0, partyN - 1 do
|
||||||
|
local mon = party[i + 1]
|
||||||
|
encodeMon(buf, O.partyMons + i * PARTY_STRUCT_SIZE, mon, true, cw)
|
||||||
|
setByte(buf, O.partySpecies + i, cw.pokemonIndex[mon.species] or 0)
|
||||||
|
encodeName(buf, O.partyMonOT + i * NAME_LENGTH, NAME_LENGTH,
|
||||||
|
mon.ot or (save.player and save.player.name) or "RED")
|
||||||
|
encodeName(buf, O.partyMonNicks + i * NAME_LENGTH, NAME_LENGTH,
|
||||||
|
mon.nickname or mon.species or "")
|
||||||
|
end
|
||||||
|
-- $FF-terminate the species index list right after the last real mon. The
|
||||||
|
-- struct, OT-name and nickname bytes of the empty slots past partyN are left
|
||||||
|
-- exactly as the template holds them (original stale data -> byte-identical
|
||||||
|
-- round-trip) or zero on a fresh export -- the game never reads past the
|
||||||
|
-- count, so this matches how it leaves those bytes itself.
|
||||||
|
setByte(buf, O.partySpecies + partyN, 0xFF)
|
||||||
|
|
||||||
|
-- boxes: current box mirrors save.currentBox into sCurBoxData; all 12
|
||||||
|
-- also get written into their bank-2/3 slot (sCurBoxData is a working
|
||||||
|
-- copy the real game keeps in sync on every PC visit, so keeping both
|
||||||
|
-- copies consistent here matches that invariant)
|
||||||
|
local function encodeBoxRegion(base, mons)
|
||||||
|
local n = math.min(#mons, MONS_PER_BOX)
|
||||||
|
setByte(buf, base, n)
|
||||||
|
for i = 0, n - 1 do
|
||||||
|
local mon = mons[i + 1]
|
||||||
|
encodeMon(buf, base + 22 + i * BOX_STRUCT_SIZE, mon, false, cw)
|
||||||
|
setByte(buf, base + 1 + i, cw.pokemonIndex[mon.species] or 0)
|
||||||
|
encodeName(buf, base + 22 + MONS_PER_BOX * BOX_STRUCT_SIZE + i * NAME_LENGTH, NAME_LENGTH,
|
||||||
|
mon.ot or (save.player and save.player.name) or "RED")
|
||||||
|
encodeName(buf, base + 22 + MONS_PER_BOX * (BOX_STRUCT_SIZE + NAME_LENGTH) + i * NAME_LENGTH, NAME_LENGTH,
|
||||||
|
mon.nickname or mon.species or "")
|
||||||
|
end
|
||||||
|
-- $FF-terminate the species list after the last real mon; empty slots past
|
||||||
|
-- n keep their template bytes (byte-identical round-trip) or zero (fresh
|
||||||
|
-- export), just as the game leaves stale box data untouched past the count.
|
||||||
|
setByte(buf, base + 1 + n, 0xFF)
|
||||||
|
end
|
||||||
|
local boxes = save.boxes or {}
|
||||||
|
local curBoxNum = math.max(1, math.min(12, save.currentBox or 1))
|
||||||
|
encodeBoxRegion(O.curBoxData, boxes[curBoxNum] or {})
|
||||||
|
-- bit 7 of wCurBoxNum is the "box system initialized" flag, not part of the
|
||||||
|
-- 0-11 index; preserve it from the template, or set it on a templateless
|
||||||
|
-- export (any save we emit has an initialized box system).
|
||||||
|
local prevBoxByte = buf[O.currentBoxNum + 1]
|
||||||
|
local boxHiBit = (src and prevBoxByte) and bit.band(prevBoxByte:byte(), 0x80) or 0x80
|
||||||
|
setByte(buf, O.currentBoxNum, bit.bor(bit.band(curBoxNum - 1, 0x7F), boxHiBit))
|
||||||
|
for b = 1, 6 do encodeBoxRegion(O.box1 + (b - 1) * BOX_REGION_SIZE, boxes[b] or {}) end
|
||||||
|
for b = 7, 12 do encodeBoxRegion(O.box7 + (b - 7) * BOX_REGION_SIZE, boxes[b] or {}) end
|
||||||
|
|
||||||
|
-- map + position
|
||||||
|
if save.player and save.player.map then
|
||||||
|
setByte(buf, O.curMap, cw.mapsIndex[save.player.map] or 0)
|
||||||
|
setByte(buf, O.yCoord, save.player.y or 0)
|
||||||
|
setByte(buf, O.xCoord, save.player.x or 0)
|
||||||
|
end
|
||||||
|
if save.lastOutdoor and save.lastOutdoor.id then
|
||||||
|
setByte(buf, O.lastMap, cw.mapsIndex[save.lastOutdoor.id] or 0)
|
||||||
|
end
|
||||||
|
|
||||||
|
-- play time: split save.playTime (seconds) back into H/M/S/F. The real
|
||||||
|
-- game freezes the clock at 255h and sets wPlayTimeMaxed once past it, so
|
||||||
|
-- mirror that cap rather than letting hours overflow a single byte.
|
||||||
|
local totalFrames = math.floor((save.playTime or 0) * 60 + 0.5)
|
||||||
|
local hours = math.floor(totalFrames / 216000) -- 3600s * 60 frames
|
||||||
|
if hours > 255 then
|
||||||
|
setByte(buf, O.playTimeHours, 255)
|
||||||
|
setByte(buf, O.playTimeMaxed, 1)
|
||||||
|
setByte(buf, O.playTimeMinutes, 59)
|
||||||
|
setByte(buf, O.playTimeSeconds, 59)
|
||||||
|
setByte(buf, O.playTimeFrames, 59)
|
||||||
|
else
|
||||||
|
local rem = totalFrames - hours * 216000
|
||||||
|
local mins = math.floor(rem / 3600); rem = rem - mins * 3600
|
||||||
|
local secs = math.floor(rem / 60)
|
||||||
|
setByte(buf, O.playTimeHours, hours)
|
||||||
|
setByte(buf, O.playTimeMaxed, 0)
|
||||||
|
setByte(buf, O.playTimeMinutes, mins)
|
||||||
|
setByte(buf, O.playTimeSeconds, secs)
|
||||||
|
setByte(buf, O.playTimeFrames, rem - secs * 60)
|
||||||
|
end
|
||||||
|
|
||||||
|
local out = table.concat(buf)
|
||||||
|
-- checksums, computed last over the now-final bytes
|
||||||
|
local outBuf = {}
|
||||||
|
for i = 1, #out do outBuf[i] = out:sub(i, i) end
|
||||||
|
setByte(outBuf, O.mainChecksum, checksum(out, O.checksumStart, O.checksumEnd))
|
||||||
|
-- Per pokered (engine/menus/save.asm SaveSAVtoSRAM / CalcCheckSum): each box
|
||||||
|
-- gets its own checksum, and the bank aggregate is CalcCheckSum over the
|
||||||
|
-- ENTIRE six-box region (6 x 1122 bytes), not a sum of the six box sums.
|
||||||
|
local function boxChecksum(base) return checksum(out, base, base + BOX_REGION_SIZE) end
|
||||||
|
for b = 0, 5 do
|
||||||
|
setByte(outBuf, O.boxBank2IndividualChecksums + b,
|
||||||
|
boxChecksum(O.box1 + b * BOX_REGION_SIZE))
|
||||||
|
end
|
||||||
|
setByte(outBuf, O.boxBank2Checksum,
|
||||||
|
checksum(out, O.box1, O.box1 + 6 * BOX_REGION_SIZE))
|
||||||
|
for b = 0, 5 do
|
||||||
|
setByte(outBuf, O.boxBank3IndividualChecksums + b,
|
||||||
|
boxChecksum(O.box7 + b * BOX_REGION_SIZE))
|
||||||
|
end
|
||||||
|
setByte(outBuf, O.boxBank3Checksum,
|
||||||
|
checksum(out, O.box7, O.box7 + 6 * BOX_REGION_SIZE))
|
||||||
|
|
||||||
|
return table.concat(outBuf)
|
||||||
|
end
|
||||||
|
|
||||||
|
return GenSave
|
||||||
@@ -0,0 +1,189 @@
|
|||||||
|
-- SaveConvert -- the runtime-facing entry point the launcher UI calls to
|
||||||
|
-- turn a vanilla Gen1 (Red/Blue, international) battery save into this
|
||||||
|
-- project's in-memory save table, and back out to a raw .sav image.
|
||||||
|
--
|
||||||
|
-- This is the ONE place the engine, the tests and the CLI
|
||||||
|
-- (tools/save_convert/convert.lua) share: the GenSave codec, the crosswalk
|
||||||
|
-- data loading, the merge over new-game defaults, and the version tag all
|
||||||
|
-- live here so every consumer behaves identically.
|
||||||
|
--
|
||||||
|
-- Pure Lua, no love.* dependency at require time: GenSave and the crosswalk
|
||||||
|
-- tables load through `require`, exactly how src/core/Data.lua pulls the
|
||||||
|
-- generated modules -- which resolves under both plain luajit (package.path
|
||||||
|
-- "./?.lua") for the headless CLI/tests and love.filesystem for a fused
|
||||||
|
-- build, with an OS-path fallback for odd working directories. The only
|
||||||
|
-- place `love` is referenced is inside a guarded fallback, so running under
|
||||||
|
-- stock Lua never touches it.
|
||||||
|
|
||||||
|
local GenSave = require("src.save_convert.GenSave")
|
||||||
|
|
||||||
|
local SaveConvert = {}
|
||||||
|
|
||||||
|
SaveConvert.SAVE_SIZE = GenSave.SAVE_SIZE
|
||||||
|
|
||||||
|
-- ------------------------------------------------------------------
|
||||||
|
-- Crosswalk data loading (cached). Mirrors src/core/Data.lua: prefer
|
||||||
|
-- `require` (works headless via package.path and fused via love's package
|
||||||
|
-- searcher); fall back to love.filesystem.load, then a plain dofile, for
|
||||||
|
-- the rare case a host has an unusual cwd or module path.
|
||||||
|
-- ------------------------------------------------------------------
|
||||||
|
|
||||||
|
-- { require-module-path, os-relative-file-path } for each table the codec
|
||||||
|
-- needs. pokemon/moves/items/maps come from the shared generated data;
|
||||||
|
-- charmap/event_flags are the save-convert-specific crosswalks.
|
||||||
|
local DATA_MODULES = {
|
||||||
|
pokemon = { "data.generated.pokemon", "data/generated/pokemon.lua" },
|
||||||
|
moves = { "data.generated.moves", "data/generated/moves.lua" },
|
||||||
|
items = { "data.generated.items", "data/generated/items.lua" },
|
||||||
|
maps = { "data.generated.maps", "data/generated/maps.lua" },
|
||||||
|
charmap = { "src.save_convert.data.charmap", "src/save_convert/data/charmap.lua" },
|
||||||
|
eventFlags = { "src.save_convert.data.event_flags", "src/save_convert/data/event_flags.lua" },
|
||||||
|
}
|
||||||
|
|
||||||
|
local function loadTable(requirePath, filePath)
|
||||||
|
local ok, mod = pcall(require, requirePath)
|
||||||
|
if ok and type(mod) == "table" then return mod end
|
||||||
|
-- fused build with an unexpected module path: read straight off the
|
||||||
|
-- mounted filesystem (love is a global here, only ever touched when it
|
||||||
|
-- actually exists -- stock Lua never reaches this branch)
|
||||||
|
if love and love.filesystem and love.filesystem.getInfo
|
||||||
|
and love.filesystem.getInfo(filePath) then
|
||||||
|
local chunk = love.filesystem.load(filePath)
|
||||||
|
if chunk then
|
||||||
|
local m = chunk()
|
||||||
|
if type(m) == "table" then return m end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
local chunk = loadfile(filePath)
|
||||||
|
if chunk then
|
||||||
|
local m = chunk()
|
||||||
|
if type(m) == "table" then return m end
|
||||||
|
end
|
||||||
|
return nil, ("cannot load save-convert data module %q (tried require %q and file %q)")
|
||||||
|
:format(requirePath, requirePath, filePath)
|
||||||
|
end
|
||||||
|
|
||||||
|
local crosswalk -- { pokemon=, moves=, items=, maps=, eventFlags= }
|
||||||
|
local charmapReady
|
||||||
|
|
||||||
|
local function ensureData()
|
||||||
|
if not crosswalk then
|
||||||
|
local data = {}
|
||||||
|
for key, spec in pairs(DATA_MODULES) do
|
||||||
|
if key ~= "charmap" then
|
||||||
|
local mod, e = loadTable(spec[1], spec[2])
|
||||||
|
if not mod then return nil, e end
|
||||||
|
data[key] = mod
|
||||||
|
end
|
||||||
|
end
|
||||||
|
crosswalk = data
|
||||||
|
end
|
||||||
|
if not charmapReady then
|
||||||
|
local cm, err = loadTable(DATA_MODULES.charmap[1], DATA_MODULES.charmap[2])
|
||||||
|
if not cm then return nil, err end
|
||||||
|
GenSave.setCharmap(cm)
|
||||||
|
charmapReady = true
|
||||||
|
end
|
||||||
|
return crosswalk
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Exposed for the CLI/tests so they can share the exact data set the codec
|
||||||
|
-- uses (and so a caller can pre-warm the cache). Returns data, err.
|
||||||
|
function SaveConvert.loadData()
|
||||||
|
return ensureData()
|
||||||
|
end
|
||||||
|
|
||||||
|
-- ------------------------------------------------------------------
|
||||||
|
-- new-game default skeleton the decoded fields merge on top of. Carried
|
||||||
|
-- verbatim from tools/save_convert/convert.lua so the CLI and the runtime
|
||||||
|
-- produce a byte-identical save table for the same input.
|
||||||
|
-- ------------------------------------------------------------------
|
||||||
|
|
||||||
|
local function defaultsSave()
|
||||||
|
return {
|
||||||
|
meta = { format = "gen1_import", mods = {} },
|
||||||
|
defeatedTrainers = {},
|
||||||
|
repelSteps = 0,
|
||||||
|
modData = {},
|
||||||
|
options = {
|
||||||
|
textSpeed = 3, animations = true, battleStyle = "shift",
|
||||||
|
ruleset = "gen1_faithful", musicVol = 7, sfxVol = 7, musicFilter = 0,
|
||||||
|
speed = 1, colors = "gbc", tilt = 0, gbcfx = 0,
|
||||||
|
videoMode = "windowed", mods = {},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Merge a GenSave.decode() result over the new-game defaults, exactly the
|
||||||
|
-- way convert.lua did, then stamp the requested version. The 32768-byte
|
||||||
|
-- import template GenSave stashes as `rawImport` and the decode `warnings`
|
||||||
|
-- are dropped here: neither belongs in a serialized slot file (a fresh
|
||||||
|
-- export always starts zero-filled -- see GenSave.lua's header).
|
||||||
|
local function mergeDefaults(decoded, version)
|
||||||
|
decoded.warnings = nil
|
||||||
|
decoded.rawImport = nil
|
||||||
|
local save = defaultsSave()
|
||||||
|
for k, v in pairs(decoded) do save[k] = v end
|
||||||
|
save.lastHeal = { map = save.player.map, x = save.player.x, y = save.player.y }
|
||||||
|
save.lastOutdoor = save.lastOutdoor or { id = save.player.map }
|
||||||
|
if version ~= nil then
|
||||||
|
save.meta = save.meta or {}
|
||||||
|
save.meta.version = version
|
||||||
|
end
|
||||||
|
return save
|
||||||
|
end
|
||||||
|
SaveConvert.mergeDefaults = mergeDefaults
|
||||||
|
|
||||||
|
-- ------------------------------------------------------------------
|
||||||
|
-- Public API
|
||||||
|
-- ------------------------------------------------------------------
|
||||||
|
|
||||||
|
-- importSav(bytes, version) -> saveTable, err
|
||||||
|
-- bytes: the raw 32768-byte SRAM string. Validates size and the main-data
|
||||||
|
-- checksum, decodes through GenSave, and returns a save table fully merged
|
||||||
|
-- over the new-game defaults and tagged with `version`, ready to hand to
|
||||||
|
-- SaveSerializer.encode for a slot file. On any failure returns nil + a
|
||||||
|
-- message (never raises).
|
||||||
|
function SaveConvert.importSav(bytes, version)
|
||||||
|
if type(bytes) ~= "string" then
|
||||||
|
return nil, "expected raw save bytes as a string"
|
||||||
|
end
|
||||||
|
if #bytes ~= GenSave.SAVE_SIZE then
|
||||||
|
return nil, ("save must be %d bytes, got %d"):format(GenSave.SAVE_SIZE, #bytes)
|
||||||
|
end
|
||||||
|
local data, derr = ensureData()
|
||||||
|
if not data then return nil, derr end
|
||||||
|
|
||||||
|
local ok, decoded = pcall(GenSave.decode, bytes, data)
|
||||||
|
if not ok then return nil, "decode failed: " .. tostring(decoded) end
|
||||||
|
|
||||||
|
-- checksum validation: GenSave.decode records a warning rather than
|
||||||
|
-- throwing (so it can still read a foreign/corrupt save), but for the
|
||||||
|
-- runtime import path a bad main-data checksum means the file is not a
|
||||||
|
-- trustworthy save, so reject it.
|
||||||
|
for _, w in ipairs(decoded.warnings or {}) do
|
||||||
|
if tostring(w):find("checksum") then
|
||||||
|
return nil, "save data checksum invalid (" .. tostring(w) .. ")"
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
return mergeDefaults(decoded, version)
|
||||||
|
end
|
||||||
|
|
||||||
|
-- exportSav(saveTable) -> bytes, err
|
||||||
|
-- Encodes a save table back to a raw 32768-byte SRAM image. Template-aware:
|
||||||
|
-- if the table still carries the stashed import template (saveTable.rawImport)
|
||||||
|
-- GenSave reproduces every unmodeled region from it; otherwise those regions
|
||||||
|
-- are zero-filled. On failure returns nil + a message (never raises).
|
||||||
|
function SaveConvert.exportSav(saveTable)
|
||||||
|
if type(saveTable) ~= "table" then
|
||||||
|
return nil, "expected a save table"
|
||||||
|
end
|
||||||
|
local data, derr = ensureData()
|
||||||
|
if not data then return nil, derr end
|
||||||
|
local ok, bytes = pcall(GenSave.encode, saveTable, data, nil)
|
||||||
|
if not ok then return nil, "encode failed: " .. tostring(bytes) end
|
||||||
|
return bytes
|
||||||
|
end
|
||||||
|
|
||||||
|
return SaveConvert
|
||||||
@@ -0,0 +1,579 @@
|
|||||||
|
-- Generated by tools/build_data.py. DO NOT EDIT.
|
||||||
|
-- Gen1 text byte <-> glyph/token charmap (fixed-length name
|
||||||
|
-- fields: player/rival/OT names, nicknames -- box/party mon
|
||||||
|
-- names are NUL-free, '@' ($50) terminated, space-padded).
|
||||||
|
-- byToken's key is the literal glyph for ordinary characters
|
||||||
|
-- ("A", "é", ...) or a bracketed control token
|
||||||
|
-- ("<PLAYER>", "@") for terminators/substitutions -- only
|
||||||
|
-- the plain single-glyph entries are meaningful inside a name.
|
||||||
|
return {
|
||||||
|
byByte = {
|
||||||
|
[0] = "<NULL>",
|
||||||
|
[5] = "ガ",
|
||||||
|
[6] = "ギ",
|
||||||
|
[7] = "グ",
|
||||||
|
[8] = "ゲ",
|
||||||
|
[9] = "ゴ",
|
||||||
|
[10] = "ザ",
|
||||||
|
[11] = "ジ",
|
||||||
|
[12] = "ズ",
|
||||||
|
[13] = "ゼ",
|
||||||
|
[14] = "ゾ",
|
||||||
|
[15] = "ダ",
|
||||||
|
[16] = "ヂ",
|
||||||
|
[17] = "ヅ",
|
||||||
|
[18] = "デ",
|
||||||
|
[19] = "ド",
|
||||||
|
[25] = "バ",
|
||||||
|
[26] = "ビ",
|
||||||
|
[27] = "ブ",
|
||||||
|
[28] = "ボ",
|
||||||
|
[38] = "が",
|
||||||
|
[39] = "ぎ",
|
||||||
|
[40] = "ぐ",
|
||||||
|
[41] = "げ",
|
||||||
|
[42] = "ご",
|
||||||
|
[43] = "ざ",
|
||||||
|
[44] = "じ",
|
||||||
|
[45] = "ず",
|
||||||
|
[46] = "ぜ",
|
||||||
|
[47] = "ぞ",
|
||||||
|
[48] = "だ",
|
||||||
|
[49] = "ぢ",
|
||||||
|
[50] = "づ",
|
||||||
|
[51] = "で",
|
||||||
|
[52] = "ど",
|
||||||
|
[58] = "ば",
|
||||||
|
[59] = "び",
|
||||||
|
[60] = "ぶ",
|
||||||
|
[61] = "べ",
|
||||||
|
[62] = "ぼ",
|
||||||
|
[64] = "パ",
|
||||||
|
[65] = "ピ",
|
||||||
|
[66] = "プ",
|
||||||
|
[67] = "ポ",
|
||||||
|
[68] = "ぱ",
|
||||||
|
[69] = "ぴ",
|
||||||
|
[70] = "ぷ",
|
||||||
|
[71] = "ぺ",
|
||||||
|
[72] = "ぽ",
|
||||||
|
[73] = "<PAGE>",
|
||||||
|
[74] = "<PKMN>",
|
||||||
|
[75] = "<_CONT>",
|
||||||
|
[76] = "<SCROLL>",
|
||||||
|
[78] = "<NEXT>",
|
||||||
|
[79] = "<LINE>",
|
||||||
|
[80] = "@",
|
||||||
|
[81] = "<PARA>",
|
||||||
|
[82] = "<PLAYER>",
|
||||||
|
[83] = "<RIVAL>",
|
||||||
|
[84] = "#",
|
||||||
|
[85] = "<CONT>",
|
||||||
|
[86] = "<……>",
|
||||||
|
[87] = "<DONE>",
|
||||||
|
[88] = "<PROMPT>",
|
||||||
|
[89] = "<TARGET>",
|
||||||
|
[90] = "<USER>",
|
||||||
|
[91] = "<PC>",
|
||||||
|
[92] = "<TM>",
|
||||||
|
[93] = "<TRAINER>",
|
||||||
|
[94] = "<ROCKET>",
|
||||||
|
[95] = "<DEXEND>",
|
||||||
|
[96] = "<BOLD_A>",
|
||||||
|
[97] = "<BOLD_B>",
|
||||||
|
[98] = "<BOLD_C>",
|
||||||
|
[99] = "<BOLD_D>",
|
||||||
|
[100] = "<BOLD_E>",
|
||||||
|
[101] = "<BOLD_F>",
|
||||||
|
[102] = "<BOLD_G>",
|
||||||
|
[103] = "<BOLD_H>",
|
||||||
|
[104] = "<BOLD_I>",
|
||||||
|
[105] = "<BOLD_V>",
|
||||||
|
[106] = "<BOLD_S>",
|
||||||
|
[107] = "<BOLD_L>",
|
||||||
|
[108] = "<BOLD_M>",
|
||||||
|
[109] = "<COLON>",
|
||||||
|
[110] = "ぃ",
|
||||||
|
[111] = "ぅ",
|
||||||
|
[112] = "‘",
|
||||||
|
[113] = "’",
|
||||||
|
[114] = "“",
|
||||||
|
[115] = "”",
|
||||||
|
[116] = "·",
|
||||||
|
[117] = "…",
|
||||||
|
[118] = "ぁ",
|
||||||
|
[119] = "ぇ",
|
||||||
|
[120] = "ぉ",
|
||||||
|
[121] = "┌",
|
||||||
|
[122] = "─",
|
||||||
|
[123] = "┐",
|
||||||
|
[124] = "│",
|
||||||
|
[125] = "└",
|
||||||
|
[126] = "┘",
|
||||||
|
[127] = " ",
|
||||||
|
[128] = "A",
|
||||||
|
[129] = "B",
|
||||||
|
[130] = "C",
|
||||||
|
[131] = "D",
|
||||||
|
[132] = "E",
|
||||||
|
[133] = "F",
|
||||||
|
[134] = "G",
|
||||||
|
[135] = "H",
|
||||||
|
[136] = "I",
|
||||||
|
[137] = "J",
|
||||||
|
[138] = "K",
|
||||||
|
[139] = "L",
|
||||||
|
[140] = "M",
|
||||||
|
[141] = "N",
|
||||||
|
[142] = "O",
|
||||||
|
[143] = "P",
|
||||||
|
[144] = "Q",
|
||||||
|
[145] = "R",
|
||||||
|
[146] = "S",
|
||||||
|
[147] = "T",
|
||||||
|
[148] = "U",
|
||||||
|
[149] = "V",
|
||||||
|
[150] = "W",
|
||||||
|
[151] = "X",
|
||||||
|
[152] = "Y",
|
||||||
|
[153] = "Z",
|
||||||
|
[154] = "(",
|
||||||
|
[155] = ")",
|
||||||
|
[156] = ":",
|
||||||
|
[157] = ";",
|
||||||
|
[158] = "[",
|
||||||
|
[159] = "]",
|
||||||
|
[160] = "a",
|
||||||
|
[161] = "b",
|
||||||
|
[162] = "c",
|
||||||
|
[163] = "d",
|
||||||
|
[164] = "e",
|
||||||
|
[165] = "f",
|
||||||
|
[166] = "g",
|
||||||
|
[167] = "h",
|
||||||
|
[168] = "i",
|
||||||
|
[169] = "j",
|
||||||
|
[170] = "k",
|
||||||
|
[171] = "l",
|
||||||
|
[172] = "m",
|
||||||
|
[173] = "n",
|
||||||
|
[174] = "o",
|
||||||
|
[175] = "p",
|
||||||
|
[176] = "q",
|
||||||
|
[177] = "r",
|
||||||
|
[178] = "s",
|
||||||
|
[179] = "t",
|
||||||
|
[180] = "u",
|
||||||
|
[181] = "v",
|
||||||
|
[182] = "w",
|
||||||
|
[183] = "x",
|
||||||
|
[184] = "y",
|
||||||
|
[185] = "z",
|
||||||
|
[186] = "é",
|
||||||
|
[187] = "'d",
|
||||||
|
[188] = "'l",
|
||||||
|
[189] = "'s",
|
||||||
|
[190] = "'t",
|
||||||
|
[191] = "'v",
|
||||||
|
[192] = "た",
|
||||||
|
[193] = "ち",
|
||||||
|
[194] = "つ",
|
||||||
|
[195] = "て",
|
||||||
|
[196] = "と",
|
||||||
|
[197] = "な",
|
||||||
|
[198] = "に",
|
||||||
|
[199] = "ぬ",
|
||||||
|
[200] = "ね",
|
||||||
|
[201] = "の",
|
||||||
|
[202] = "は",
|
||||||
|
[203] = "ひ",
|
||||||
|
[204] = "ふ",
|
||||||
|
[205] = "へ",
|
||||||
|
[206] = "ほ",
|
||||||
|
[207] = "ま",
|
||||||
|
[208] = "み",
|
||||||
|
[209] = "む",
|
||||||
|
[210] = "め",
|
||||||
|
[211] = "も",
|
||||||
|
[212] = "や",
|
||||||
|
[213] = "ゆ",
|
||||||
|
[214] = "よ",
|
||||||
|
[215] = "ら",
|
||||||
|
[216] = "り",
|
||||||
|
[217] = "る",
|
||||||
|
[218] = "れ",
|
||||||
|
[219] = "ろ",
|
||||||
|
[220] = "わ",
|
||||||
|
[221] = "を",
|
||||||
|
[222] = "ん",
|
||||||
|
[223] = "っ",
|
||||||
|
[224] = "'",
|
||||||
|
[225] = "<PK>",
|
||||||
|
[226] = "<MN>",
|
||||||
|
[227] = "-",
|
||||||
|
[228] = "'r",
|
||||||
|
[229] = "'m",
|
||||||
|
[230] = "?",
|
||||||
|
[231] = "!",
|
||||||
|
[232] = ".",
|
||||||
|
[233] = "ァ",
|
||||||
|
[234] = "ゥ",
|
||||||
|
[235] = "ェ",
|
||||||
|
[236] = "▷",
|
||||||
|
[237] = "▲",
|
||||||
|
[238] = "▼",
|
||||||
|
[239] = "♂",
|
||||||
|
[240] = "<ED>",
|
||||||
|
[241] = "×",
|
||||||
|
[242] = "<DOT>",
|
||||||
|
[243] = "/",
|
||||||
|
[244] = ",",
|
||||||
|
[245] = "♀",
|
||||||
|
[246] = "0",
|
||||||
|
[247] = "1",
|
||||||
|
[248] = "2",
|
||||||
|
[249] = "3",
|
||||||
|
[250] = "4",
|
||||||
|
[251] = "5",
|
||||||
|
[252] = "6",
|
||||||
|
[253] = "7",
|
||||||
|
[254] = "8",
|
||||||
|
[255] = "9",
|
||||||
|
},
|
||||||
|
byToken = {
|
||||||
|
[" "] = 127,
|
||||||
|
["!"] = 231,
|
||||||
|
["#"] = 84,
|
||||||
|
["'"] = 224,
|
||||||
|
["'d"] = 187,
|
||||||
|
["'l"] = 188,
|
||||||
|
["'m"] = 229,
|
||||||
|
["'r"] = 228,
|
||||||
|
["'s"] = 189,
|
||||||
|
["'t"] = 190,
|
||||||
|
["'v"] = 191,
|
||||||
|
["("] = 154,
|
||||||
|
[")"] = 155,
|
||||||
|
[","] = 244,
|
||||||
|
["-"] = 227,
|
||||||
|
["."] = 232,
|
||||||
|
["/"] = 243,
|
||||||
|
["0"] = 246,
|
||||||
|
["1"] = 247,
|
||||||
|
["2"] = 248,
|
||||||
|
["3"] = 249,
|
||||||
|
["4"] = 250,
|
||||||
|
["5"] = 251,
|
||||||
|
["6"] = 252,
|
||||||
|
["7"] = 253,
|
||||||
|
["8"] = 254,
|
||||||
|
["9"] = 255,
|
||||||
|
[":"] = 156,
|
||||||
|
[";"] = 157,
|
||||||
|
["<BOLD_A>"] = 96,
|
||||||
|
["<BOLD_B>"] = 97,
|
||||||
|
["<BOLD_C>"] = 98,
|
||||||
|
["<BOLD_D>"] = 99,
|
||||||
|
["<BOLD_E>"] = 100,
|
||||||
|
["<BOLD_F>"] = 101,
|
||||||
|
["<BOLD_G>"] = 102,
|
||||||
|
["<BOLD_H>"] = 103,
|
||||||
|
["<BOLD_I>"] = 104,
|
||||||
|
["<BOLD_L>"] = 107,
|
||||||
|
["<BOLD_M>"] = 108,
|
||||||
|
["<BOLD_P>"] = 114,
|
||||||
|
["<BOLD_S>"] = 106,
|
||||||
|
["<BOLD_V>"] = 105,
|
||||||
|
["<COLON>"] = 109,
|
||||||
|
["<CONT>"] = 85,
|
||||||
|
["<DEXEND>"] = 95,
|
||||||
|
["<DONE>"] = 87,
|
||||||
|
["<DOT>"] = 242,
|
||||||
|
["<ED>"] = 240,
|
||||||
|
["<ID>"] = 115,
|
||||||
|
["<LINE>"] = 79,
|
||||||
|
["<LV>"] = 110,
|
||||||
|
["<MN>"] = 226,
|
||||||
|
["<NEXT>"] = 78,
|
||||||
|
["<NULL>"] = 0,
|
||||||
|
["<PAGE>"] = 73,
|
||||||
|
["<PARA>"] = 81,
|
||||||
|
["<PC>"] = 91,
|
||||||
|
["<PK>"] = 225,
|
||||||
|
["<PKMN>"] = 74,
|
||||||
|
["<PLAYER>"] = 82,
|
||||||
|
["<PROMPT>"] = 88,
|
||||||
|
["<RIVAL>"] = 83,
|
||||||
|
["<ROCKET>"] = 94,
|
||||||
|
["<SCROLL>"] = 76,
|
||||||
|
["<TARGET>"] = 89,
|
||||||
|
["<TM>"] = 92,
|
||||||
|
["<TRAINER>"] = 93,
|
||||||
|
["<USER>"] = 90,
|
||||||
|
["<_CONT>"] = 75,
|
||||||
|
["<to>"] = 112,
|
||||||
|
["<……>"] = 86,
|
||||||
|
["?"] = 230,
|
||||||
|
["@"] = 80,
|
||||||
|
A = 128,
|
||||||
|
B = 129,
|
||||||
|
C = 130,
|
||||||
|
D = 131,
|
||||||
|
E = 132,
|
||||||
|
F = 133,
|
||||||
|
G = 134,
|
||||||
|
H = 135,
|
||||||
|
I = 136,
|
||||||
|
J = 137,
|
||||||
|
K = 138,
|
||||||
|
L = 139,
|
||||||
|
M = 140,
|
||||||
|
N = 141,
|
||||||
|
O = 142,
|
||||||
|
P = 143,
|
||||||
|
Q = 144,
|
||||||
|
R = 145,
|
||||||
|
S = 146,
|
||||||
|
T = 147,
|
||||||
|
U = 148,
|
||||||
|
V = 149,
|
||||||
|
W = 150,
|
||||||
|
X = 151,
|
||||||
|
Y = 152,
|
||||||
|
Z = 153,
|
||||||
|
["["] = 158,
|
||||||
|
["]"] = 159,
|
||||||
|
a = 160,
|
||||||
|
b = 161,
|
||||||
|
c = 162,
|
||||||
|
d = 163,
|
||||||
|
e = 164,
|
||||||
|
f = 165,
|
||||||
|
g = 166,
|
||||||
|
h = 167,
|
||||||
|
i = 168,
|
||||||
|
j = 169,
|
||||||
|
k = 170,
|
||||||
|
l = 171,
|
||||||
|
m = 172,
|
||||||
|
n = 173,
|
||||||
|
o = 174,
|
||||||
|
p = 175,
|
||||||
|
q = 176,
|
||||||
|
r = 177,
|
||||||
|
s = 178,
|
||||||
|
t = 179,
|
||||||
|
u = 180,
|
||||||
|
v = 181,
|
||||||
|
w = 182,
|
||||||
|
x = 183,
|
||||||
|
y = 184,
|
||||||
|
z = 185,
|
||||||
|
["¥"] = 240,
|
||||||
|
["·"] = 116,
|
||||||
|
["×"] = 241,
|
||||||
|
["é"] = 186,
|
||||||
|
["‘"] = 112,
|
||||||
|
["’"] = 113,
|
||||||
|
["“"] = 114,
|
||||||
|
["”"] = 115,
|
||||||
|
["…"] = 117,
|
||||||
|
["′"] = 96,
|
||||||
|
["″"] = 97,
|
||||||
|
["№"] = 116,
|
||||||
|
["⋯"] = 117,
|
||||||
|
["─"] = 122,
|
||||||
|
["│"] = 124,
|
||||||
|
["┌"] = 121,
|
||||||
|
["┐"] = 123,
|
||||||
|
["└"] = 125,
|
||||||
|
["┘"] = 126,
|
||||||
|
["▲"] = 237,
|
||||||
|
["▶"] = 237,
|
||||||
|
["▷"] = 236,
|
||||||
|
["▼"] = 238,
|
||||||
|
["♀"] = 245,
|
||||||
|
["♂"] = 239,
|
||||||
|
[" "] = 127,
|
||||||
|
["。"] = 232,
|
||||||
|
["「"] = 112,
|
||||||
|
["」"] = 113,
|
||||||
|
["『"] = 114,
|
||||||
|
["』"] = 115,
|
||||||
|
["ぁ"] = 118,
|
||||||
|
["あ"] = 177,
|
||||||
|
["ぃ"] = 110,
|
||||||
|
["い"] = 178,
|
||||||
|
["ぅ"] = 111,
|
||||||
|
["う"] = 179,
|
||||||
|
["ぇ"] = 119,
|
||||||
|
["え"] = 180,
|
||||||
|
["ぉ"] = 120,
|
||||||
|
["お"] = 181,
|
||||||
|
["か"] = 182,
|
||||||
|
["が"] = 38,
|
||||||
|
["き"] = 183,
|
||||||
|
["ぎ"] = 39,
|
||||||
|
["く"] = 184,
|
||||||
|
["ぐ"] = 40,
|
||||||
|
["け"] = 185,
|
||||||
|
["げ"] = 41,
|
||||||
|
["こ"] = 186,
|
||||||
|
["ご"] = 42,
|
||||||
|
["さ"] = 187,
|
||||||
|
["ざ"] = 43,
|
||||||
|
["し"] = 188,
|
||||||
|
["じ"] = 44,
|
||||||
|
["す"] = 189,
|
||||||
|
["ず"] = 45,
|
||||||
|
["せ"] = 190,
|
||||||
|
["ぜ"] = 46,
|
||||||
|
["そ"] = 191,
|
||||||
|
["ぞ"] = 47,
|
||||||
|
["た"] = 192,
|
||||||
|
["だ"] = 48,
|
||||||
|
["ち"] = 193,
|
||||||
|
["ぢ"] = 49,
|
||||||
|
["っ"] = 223,
|
||||||
|
["つ"] = 194,
|
||||||
|
["づ"] = 50,
|
||||||
|
["て"] = 195,
|
||||||
|
["で"] = 51,
|
||||||
|
["と"] = 196,
|
||||||
|
["ど"] = 52,
|
||||||
|
["な"] = 197,
|
||||||
|
["に"] = 198,
|
||||||
|
["ぬ"] = 199,
|
||||||
|
["ね"] = 200,
|
||||||
|
["の"] = 201,
|
||||||
|
["は"] = 202,
|
||||||
|
["ば"] = 58,
|
||||||
|
["ぱ"] = 68,
|
||||||
|
["ひ"] = 203,
|
||||||
|
["び"] = 59,
|
||||||
|
["ぴ"] = 69,
|
||||||
|
["ふ"] = 204,
|
||||||
|
["ぶ"] = 60,
|
||||||
|
["ぷ"] = 70,
|
||||||
|
["へ"] = 205,
|
||||||
|
["べ"] = 61,
|
||||||
|
["ぺ"] = 71,
|
||||||
|
["ほ"] = 206,
|
||||||
|
["ぼ"] = 62,
|
||||||
|
["ぽ"] = 72,
|
||||||
|
["ま"] = 207,
|
||||||
|
["み"] = 208,
|
||||||
|
["む"] = 209,
|
||||||
|
["め"] = 210,
|
||||||
|
["も"] = 211,
|
||||||
|
["ゃ"] = 224,
|
||||||
|
["や"] = 212,
|
||||||
|
["ゅ"] = 225,
|
||||||
|
["ゆ"] = 213,
|
||||||
|
["ょ"] = 226,
|
||||||
|
["よ"] = 214,
|
||||||
|
["ら"] = 215,
|
||||||
|
["り"] = 216,
|
||||||
|
["る"] = 217,
|
||||||
|
["れ"] = 218,
|
||||||
|
["ろ"] = 219,
|
||||||
|
["わ"] = 220,
|
||||||
|
["を"] = 221,
|
||||||
|
["ん"] = 222,
|
||||||
|
["ァ"] = 233,
|
||||||
|
["ア"] = 128,
|
||||||
|
["ィ"] = 176,
|
||||||
|
["イ"] = 129,
|
||||||
|
["ゥ"] = 234,
|
||||||
|
["ウ"] = 130,
|
||||||
|
["ェ"] = 235,
|
||||||
|
["エ"] = 131,
|
||||||
|
["ォ"] = 244,
|
||||||
|
["オ"] = 132,
|
||||||
|
["カ"] = 133,
|
||||||
|
["ガ"] = 5,
|
||||||
|
["キ"] = 134,
|
||||||
|
["ギ"] = 6,
|
||||||
|
["ク"] = 135,
|
||||||
|
["グ"] = 7,
|
||||||
|
["ケ"] = 136,
|
||||||
|
["ゲ"] = 8,
|
||||||
|
["コ"] = 137,
|
||||||
|
["ゴ"] = 9,
|
||||||
|
["サ"] = 138,
|
||||||
|
["ザ"] = 10,
|
||||||
|
["シ"] = 139,
|
||||||
|
["ジ"] = 11,
|
||||||
|
["ス"] = 140,
|
||||||
|
["ズ"] = 12,
|
||||||
|
["セ"] = 141,
|
||||||
|
["ゼ"] = 13,
|
||||||
|
["ソ"] = 142,
|
||||||
|
["ゾ"] = 14,
|
||||||
|
["タ"] = 143,
|
||||||
|
["ダ"] = 15,
|
||||||
|
["チ"] = 144,
|
||||||
|
["ヂ"] = 16,
|
||||||
|
["ッ"] = 172,
|
||||||
|
["ツ"] = 145,
|
||||||
|
["ヅ"] = 17,
|
||||||
|
["テ"] = 146,
|
||||||
|
["デ"] = 18,
|
||||||
|
["ト"] = 147,
|
||||||
|
["ド"] = 19,
|
||||||
|
["ナ"] = 148,
|
||||||
|
["ニ"] = 149,
|
||||||
|
["ヌ"] = 150,
|
||||||
|
["ネ"] = 151,
|
||||||
|
["ノ"] = 152,
|
||||||
|
["ハ"] = 153,
|
||||||
|
["バ"] = 25,
|
||||||
|
["パ"] = 64,
|
||||||
|
["ヒ"] = 154,
|
||||||
|
["ビ"] = 26,
|
||||||
|
["ピ"] = 65,
|
||||||
|
["フ"] = 155,
|
||||||
|
["ブ"] = 27,
|
||||||
|
["プ"] = 66,
|
||||||
|
["ホ"] = 156,
|
||||||
|
["ボ"] = 28,
|
||||||
|
["ポ"] = 67,
|
||||||
|
["マ"] = 157,
|
||||||
|
["ミ"] = 158,
|
||||||
|
["ム"] = 159,
|
||||||
|
["メ"] = 160,
|
||||||
|
["モ"] = 161,
|
||||||
|
["ャ"] = 173,
|
||||||
|
["ヤ"] = 162,
|
||||||
|
["ュ"] = 174,
|
||||||
|
["ユ"] = 163,
|
||||||
|
["ョ"] = 175,
|
||||||
|
["ヨ"] = 164,
|
||||||
|
["ラ"] = 165,
|
||||||
|
["ル"] = 166,
|
||||||
|
["レ"] = 167,
|
||||||
|
["ロ"] = 168,
|
||||||
|
["ワ"] = 169,
|
||||||
|
["ヲ"] = 170,
|
||||||
|
["ン"] = 171,
|
||||||
|
["ー"] = 227,
|
||||||
|
["円"] = 240,
|
||||||
|
["!"] = 231,
|
||||||
|
["."] = 242,
|
||||||
|
["/"] = 243,
|
||||||
|
["0"] = 246,
|
||||||
|
["1"] = 247,
|
||||||
|
["2"] = 248,
|
||||||
|
["3"] = 249,
|
||||||
|
["4"] = 250,
|
||||||
|
["5"] = 251,
|
||||||
|
["6"] = 252,
|
||||||
|
["7"] = 253,
|
||||||
|
["8"] = 254,
|
||||||
|
["9"] = 255,
|
||||||
|
["?"] = 230,
|
||||||
|
["゙"] = 229,
|
||||||
|
["゚"] = 228,
|
||||||
|
},
|
||||||
|
source = "pokered constants/charmap.asm",
|
||||||
|
}
|
||||||
@@ -261,7 +261,9 @@ function Commands.move_player(ctx, dir, tiles)
|
|||||||
end
|
end
|
||||||
|
|
||||||
function Commands.move_npc(ctx, objIndex, dir, tiles)
|
function Commands.move_npc(ctx, objIndex, dir, tiles)
|
||||||
local npc = ctx.overworld:npcByIndex(objIndex)
|
local ow = ctx.overworld
|
||||||
|
if not ow then return end
|
||||||
|
local npc = ow:npcByIndex(objIndex)
|
||||||
if npc then walkEntity(ctx, npc, dir, tiles) end
|
if npc then walkEntity(ctx, npc, dir, tiles) end
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -308,6 +310,7 @@ end
|
|||||||
|
|
||||||
function Commands.move_npc_to(ctx, objIndex, tx, ty)
|
function Commands.move_npc_to(ctx, objIndex, tx, ty)
|
||||||
local ow = ctx.overworld
|
local ow = ctx.overworld
|
||||||
|
if not ow then return end
|
||||||
local npc = ow:npcByIndex(objIndex)
|
local npc = ow:npcByIndex(objIndex)
|
||||||
if not npc then return end
|
if not npc then return end
|
||||||
local path = bfsPath(ow.map, npc.cellX, npc.cellY, tx, ty, ow.entities, npc)
|
local path = bfsPath(ow.map, npc.cellX, npc.cellY, tx, ty, ow.entities, npc)
|
||||||
@@ -339,6 +342,20 @@ function Commands.face_object(ctx, objIndex, dir)
|
|||||||
if npc then npc.facing = dir end
|
if npc then npc.facing = dir end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
-- Instantly relocate an NPC (OaksLabCalcRivalMovementScript / SetSpritePosition1).
|
||||||
|
-- facing is optional. Headless-safe no-op without an overworld.
|
||||||
|
function Commands.place_npc(ctx, objIndex, x, y, facing)
|
||||||
|
local ow = ctx.overworld
|
||||||
|
if not ow then return end
|
||||||
|
local npc = ow:npcByIndex(objIndex)
|
||||||
|
if not npc then return end
|
||||||
|
npc.cellX, npc.cellY = x, y
|
||||||
|
npc.px, npc.py = x * 16, y * 16
|
||||||
|
npc.moving = false
|
||||||
|
npc.targetX, npc.targetY = nil, nil
|
||||||
|
if facing then npc.facing = facing end
|
||||||
|
end
|
||||||
|
|
||||||
function Commands.face_npc(ctx)
|
function Commands.face_npc(ctx)
|
||||||
-- make the player face the talking NPC
|
-- make the player face the talking NPC
|
||||||
if ctx.npc and ctx.overworld then
|
if ctx.npc and ctx.overworld then
|
||||||
@@ -485,7 +502,7 @@ function Commands.heal_party(ctx)
|
|||||||
end
|
end
|
||||||
|
|
||||||
-- AskName (engine/menus/naming_screen.asm): yes/no then NamingScreen.
|
-- AskName (engine/menus/naming_screen.asm): yes/no then NamingScreen.
|
||||||
-- AddPartyMon only offers this for party mons (box deposits skip it).
|
-- AddPartyMon and SendNewMonToBox both offer this.
|
||||||
-- Preserves ctx.lastCheck so GivePokemon's carry is not clobbered by the
|
-- Preserves ctx.lastCheck so GivePokemon's carry is not clobbered by the
|
||||||
-- yes/no result (scripts jump_if_false on give failure afterwards).
|
-- yes/no result (scripts jump_if_false on give failure afterwards).
|
||||||
local function askNickname(ctx, mon)
|
local function askNickname(ctx, mon)
|
||||||
@@ -527,8 +544,9 @@ end
|
|||||||
-- the asm's carry: true when the mon was given, false when both the
|
-- the asm's carry: true when the mon was given, false when both the
|
||||||
-- party and every box are full (that .boxFull path leaves the giver's
|
-- party and every box are full (that .boxFull path leaves the giver's
|
||||||
-- script able to offer again later, e.g. the Celadon Eevee ball).
|
-- script able to offer again later, e.g. the Celadon Eevee ball).
|
||||||
-- Party adds run AskName (AddPartyMon) when a script runner is present;
|
-- AskName runs for party (AddPartyMon) and box (SendNewMonToBox) when a
|
||||||
-- mods that pre-set gift.nickname skip the prompt.
|
-- script runner is present; mods that pre-set gift.nickname skip it.
|
||||||
|
-- Box deposits also print SentToBoxText (give_pokemon.asm:36-37).
|
||||||
function Commands.give_pokemon(ctx, species, level)
|
function Commands.give_pokemon(ctx, species, level)
|
||||||
-- Native mods can transform a gift before the Pokémon object is created.
|
-- Native mods can transform a gift before the Pokémon object is created.
|
||||||
-- This is intentionally an event rather than a special-case starter hook:
|
-- This is intentionally an event rather than a special-case starter hook:
|
||||||
@@ -540,14 +558,17 @@ function Commands.give_pokemon(ctx, species, level)
|
|||||||
end
|
end
|
||||||
local Pokemon = require("src.pokemon.Pokemon")
|
local Pokemon = require("src.pokemon.Pokemon")
|
||||||
local Party = require("src.pokemon.Party")
|
local Party = require("src.pokemon.Party")
|
||||||
|
local Boxes = require("src.pokemon.Boxes")
|
||||||
local mon = Pokemon.new(ctx.game.data, species, level)
|
local mon = Pokemon.new(ctx.game.data, species, level)
|
||||||
if gift.nickname then mon.nickname = gift.nickname end
|
if gift.nickname then mon.nickname = gift.nickname end
|
||||||
ctx.game.stringBuffer = ctx.game.data.pokemon[species].name or species
|
ctx.game.stringBuffer = ctx.game.data.pokemon[species].name or species
|
||||||
ctx.pendingPokemonName = species
|
ctx.pendingPokemonName = species
|
||||||
require("src.battle.BattleState").stampOT(ctx.save, mon)
|
require("src.battle.BattleState").stampOT(ctx.save, mon)
|
||||||
local addedToParty = Party.add(ctx.save.party, mon)
|
local addedToParty = Party.add(ctx.save.party, mon)
|
||||||
|
local boxNum = nil
|
||||||
if not addedToParty then
|
if not addedToParty then
|
||||||
if not require("src.pokemon.Boxes").deposit(ctx.save, mon) then
|
boxNum = Boxes.deposit(ctx.save, mon)
|
||||||
|
if not boxNum then
|
||||||
ctx.lastCheck = false
|
ctx.lastCheck = false
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
@@ -558,11 +579,30 @@ function Commands.give_pokemon(ctx, species, level)
|
|||||||
dex.owned[species] = true
|
dex.owned[species] = true
|
||||||
end
|
end
|
||||||
ctx.lastCheck = true
|
ctx.lastCheck = true
|
||||||
-- AddPartyMon AskName: party only; skip box deposits, mod-set nicknames,
|
ctx.addedToParty = addedToParty
|
||||||
-- and callback-style callers that have no script runner to yield on.
|
ctx.boxNum = boxNum
|
||||||
if addedToParty and not gift.nickname and ctx.runner then
|
-- AskName: both AddPartyMon and SendNewMonToBox; skip mod-set nicks
|
||||||
|
-- and callback-style callers with no script runner to yield on.
|
||||||
|
if not gift.nickname and ctx.runner then
|
||||||
askNickname(ctx, mon)
|
askNickname(ctx, mon)
|
||||||
end
|
end
|
||||||
|
if boxNum then
|
||||||
|
local name = mon.nickname
|
||||||
|
or (ctx.game.data.pokemon[species] and ctx.game.data.pokemon[species].name)
|
||||||
|
or species
|
||||||
|
ctx.game.boxMonNicks = name
|
||||||
|
ctx.game.stringBuffer = tostring(boxNum)
|
||||||
|
if ctx.runner then
|
||||||
|
Commands.show_text(ctx, "_SentToBoxText")
|
||||||
|
else
|
||||||
|
local TextBox = require("src.render.TextBox")
|
||||||
|
local raw = ctx.game.data.text and ctx.game.data.text._SentToBoxText
|
||||||
|
ctx.game.stack:push(TextBox.new(ctx.game, raw or (
|
||||||
|
"There's no more\nroom for POKéMON!\v" .. name
|
||||||
|
.. " was\vsent to POKéMON\vBOX " .. boxNum .. " on PC!"),
|
||||||
|
function() end))
|
||||||
|
end
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
function Commands.give_money(ctx, amount)
|
function Commands.give_money(ctx, amount)
|
||||||
@@ -783,6 +823,8 @@ function Commands.trade(ctx, tradeIndex, doneFlag)
|
|||||||
local newMon = Pokemon.new(data, trade.get, sent.level)
|
local newMon = Pokemon.new(data, trade.get, sent.level)
|
||||||
newMon.nickname = trade.nickname
|
newMon.nickname = trade.nickname
|
||||||
newMon.traded = true -- boosted exp + Name Rater refusal (different OT)
|
newMon.traded = true -- boosted exp + Name Rater refusal (different OT)
|
||||||
|
newMon.ot = "TRAINER" -- InGameTrade_TrainerString
|
||||||
|
newMon.otId = (love.math and love.math.random or math.random)(0, 65535)
|
||||||
table.remove(party, slot)
|
table.remove(party, slot)
|
||||||
table.insert(party, newMon)
|
table.insert(party, newMon)
|
||||||
local dex = ctx.save.pokedex
|
local dex = ctx.save.pokedex
|
||||||
@@ -790,9 +832,13 @@ function Commands.trade(ctx, tradeIndex, doneFlag)
|
|||||||
dex.seen[trade.get] = true
|
dex.seen[trade.get] = true
|
||||||
dex.owned[trade.get] = true
|
dex.owned[trade.get] = true
|
||||||
end
|
end
|
||||||
-- the trade machine animation (engine/movie/trade.asm)
|
-- InternalClockTradeAnim (engine/movie/trade.asm)
|
||||||
Screens.push(ctx.game, "TradeAnim", {
|
Screens.push(ctx.game, "TradeAnim", {
|
||||||
sent = sent, received = newMon,
|
sent = sent, received = newMon,
|
||||||
|
enemyName = "TRAINER",
|
||||||
|
playerOt = ctx.save.player.name,
|
||||||
|
playerOtId = sent.otId or ctx.save.player.id,
|
||||||
|
enemyOtId = newMon.otId,
|
||||||
onDone = function() runner:resume() end,
|
onDone = function() runner:resume() end,
|
||||||
})
|
})
|
||||||
runner:yield()
|
runner:yield()
|
||||||
|
|||||||
@@ -214,11 +214,10 @@ local function useOn(game, battle, id, target, list, moveIndex)
|
|||||||
and ow.map.id ~= "AGATHAS_ROOM" then
|
and ow.map.id ~= "AGATHAS_ROOM" then
|
||||||
list:close()
|
list:close()
|
||||||
consume(game, id)
|
consume(game, id)
|
||||||
require("src.core.Sound").play(game.data, "Teleport_Exit1")
|
-- LeaveMapAnim spin-up + SFX_TELEPORT_EXIT_1, a fade, then land OUTSIDE
|
||||||
ow.player.surfing = false
|
-- the last Pokémon Center town door like Fly (#196), via the shared
|
||||||
-- EnterMapAnim on arrival (BIT_ESCAPE_WARP / special warp path);
|
-- departure helper -- the same path Dig/Teleport take from the party menu
|
||||||
-- blackouts omit arrive="teleport" (HandleBlackOut has no LeaveMapAnim)
|
ow:beginTeleportOut()
|
||||||
ow:warpToHealPoint(nil, { arrive = "teleport" })
|
|
||||||
else
|
else
|
||||||
showMessages(game, { "OAK: " .. game.save.player.name
|
showMessages(game, { "OAK: " .. game.save.player.name
|
||||||
.. "!\nThis isn't the\ntime to use that!" })
|
.. "!\nThis isn't the\ntime to use that!" })
|
||||||
@@ -303,7 +302,8 @@ local function pickTargetAndUse(game, battle, id, list)
|
|||||||
-- the ETHERs and PP UP open the move menu after picking a mon
|
-- the ETHERs and PP UP open the move menu after picking a mon
|
||||||
-- (ItemUsePPRestore / ItemUsePPUp); the ELIXERs hit every move
|
-- (ItemUsePPRestore / ItemUsePPUp); the ELIXERs hit every move
|
||||||
local wantsMove = id == "ETHER" or id == "MAX_ETHER" or id == "PP_UP"
|
local wantsMove = id == "ETHER" or id == "MAX_ETHER" or id == "PP_UP"
|
||||||
require("src.ui.Screens").push(game, "PartyMenu", {
|
local def = game.data.items[id]
|
||||||
|
local opts = {
|
||||||
pickOnly = true,
|
pickOnly = true,
|
||||||
onSwitch = function(mon)
|
onSwitch = function(mon)
|
||||||
if not wantsMove then
|
if not wantsMove then
|
||||||
@@ -326,11 +326,27 @@ local function pickTargetAndUse(game, battle, id, list)
|
|||||||
end,
|
end,
|
||||||
}))
|
}))
|
||||||
end,
|
end,
|
||||||
})
|
}
|
||||||
|
-- TM/HM: open the party menu in Gen 1's TM/HM display mode so each mon
|
||||||
|
-- shows ABLE / NOT ABLE from its learnset and the prompt reads "Use TM on
|
||||||
|
-- which POKeMON?" (engine/items/item_effects.asm ItemUseTMHM ->
|
||||||
|
-- party_menu.asm TM/HM type). Stones and other pickOnly items keep the
|
||||||
|
-- plain HP layout (Gen 1 shows no ABLE/NOT ABLE for them), so gate
|
||||||
|
-- strictly on def.machine. #210
|
||||||
|
if def and def.machine then
|
||||||
|
opts.tmhm = { move = def.machine.move, kind = def.machine.kind }
|
||||||
|
end
|
||||||
|
require("src.ui.Screens").push(game, "PartyMenu", opts)
|
||||||
end
|
end
|
||||||
|
|
||||||
local function useItem(game, battle, id, list)
|
local function useItem(game, battle, id, list)
|
||||||
local def = game.data.items[id]
|
local def = game.data.items[id]
|
||||||
|
-- ItemUseTMHM checks wIsInBattle before BootedUpTMText
|
||||||
|
if battle and def and def.machine then
|
||||||
|
local _, payload = ItemEffects.use(game.data, game.save, id, nil, battle)
|
||||||
|
showMessages(game, payload)
|
||||||
|
return
|
||||||
|
end
|
||||||
if ItemEffects.needsTarget(id, def) and not ItemEffects.isBall(id) then
|
if ItemEffects.needsTarget(id, def) and not ItemEffects.isBall(id) then
|
||||||
-- TMs/HMs boot up and announce their move before the target picker
|
-- TMs/HMs boot up and announce their move before the target picker
|
||||||
-- (ItemUseTMHM: BootedUpTMText / BootedUpHMText + TeachMachineMoveText)
|
-- (ItemUseTMHM: BootedUpTMText / BootedUpHMText + TeachMachineMoveText)
|
||||||
@@ -354,6 +370,7 @@ function BagMenu.new(game, opts)
|
|||||||
local battle = opts.battle
|
local battle = opts.battle
|
||||||
local list
|
local list
|
||||||
list = ListMenu.new(game, "ITEMS", buildItems(game), {
|
list = ListMenu.new(game, "ITEMS", buildItems(game), {
|
||||||
|
kind = "bag",
|
||||||
footer = ("¥%d"):format(game.save.money),
|
footer = ("¥%d"):format(game.save.money),
|
||||||
-- SELECT reorders items like the original bag (swap_items.asm)
|
-- SELECT reorders items like the original bag (swap_items.asm)
|
||||||
onSelectKey = function(item, l)
|
onSelectKey = function(item, l)
|
||||||
|
|||||||
@@ -3,9 +3,11 @@
|
|||||||
-- plus CHANGE BOX.
|
-- plus CHANGE BOX.
|
||||||
|
|
||||||
local Boxes = require("src.pokemon.Boxes")
|
local Boxes = require("src.pokemon.Boxes")
|
||||||
|
local Font = require("src.render.Font")
|
||||||
local ListMenu = require("src.ui.ListMenu")
|
local ListMenu = require("src.ui.ListMenu")
|
||||||
local Menu = require("src.ui.Menu")
|
local Menu = require("src.ui.Menu")
|
||||||
local Party = require("src.pokemon.Party")
|
local Party = require("src.pokemon.Party")
|
||||||
|
local TextBox = require("src.render.TextBox")
|
||||||
|
|
||||||
local BoxMenu = {}
|
local BoxMenu = {}
|
||||||
|
|
||||||
@@ -14,6 +16,11 @@ local function monLabel(game, mon)
|
|||||||
return ("%s :L%d"):format(mon.nickname or def.name, mon.level)
|
return ("%s :L%d"):format(mon.nickname or def.name, mon.level)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
local function monName(game, mon)
|
||||||
|
local def = game.data.pokemon[mon.species]
|
||||||
|
return mon.nickname or def.name
|
||||||
|
end
|
||||||
|
|
||||||
-- Per-mon submenu (bills_pc.asm DisplayDepositWithdrawMenu): the chosen
|
-- Per-mon submenu (bills_pc.asm DisplayDepositWithdrawMenu): the chosen
|
||||||
-- action + STATS + CANCEL. STATS shows the status screen and returns
|
-- action + STATS + CANCEL. STATS shows the status screen and returns
|
||||||
-- here; CANCEL/B goes back to the list.
|
-- here; CANCEL/B goes back to the list.
|
||||||
@@ -31,8 +38,26 @@ local function monSubmenu(game, action, mon, onAction)
|
|||||||
}, { tx = 9, ty = 10, tw = 11, th = 8, noSound = true }))
|
}, { tx = 9, ty = 10, tw = 11, th = 8, noSound = true }))
|
||||||
end
|
end
|
||||||
|
|
||||||
|
-- After a successful transfer: close the mon list (BoxMenu stays beneath
|
||||||
|
-- via keepOpen) and show the taken-out / stored text (jp BillsPCMenu).
|
||||||
|
local function afterTransfer(game, list, text)
|
||||||
|
list:close()
|
||||||
|
game.stack:push(TextBox.new(game, text))
|
||||||
|
end
|
||||||
|
|
||||||
local function withdraw(game)
|
local function withdraw(game)
|
||||||
local box = Boxes.active(game.save)
|
local box = Boxes.active(game.save)
|
||||||
|
local t = game.data.text
|
||||||
|
if #box == 0 then
|
||||||
|
game.stack:push(TextBox.new(game, t._NoMonText
|
||||||
|
or "What? There are\nno POKéMON here!"))
|
||||||
|
return
|
||||||
|
end
|
||||||
|
if #game.save.party >= Party.MAX then
|
||||||
|
game.stack:push(TextBox.new(game, t._CantTakeMonText
|
||||||
|
or "You can't take\nany more POKéMON.\fDeposit POKéMON\nfirst."))
|
||||||
|
return
|
||||||
|
end
|
||||||
local items = {}
|
local items = {}
|
||||||
for i, mon in ipairs(box) do
|
for i, mon in ipairs(box) do
|
||||||
table.insert(items, { label = monLabel(game, mon), value = i })
|
table.insert(items, { label = monLabel(game, mon), value = i })
|
||||||
@@ -49,13 +74,29 @@ local function withdraw(game)
|
|||||||
end
|
end
|
||||||
table.remove(box, item.value)
|
table.remove(box, item.value)
|
||||||
table.insert(game.save.party, mon)
|
table.insert(game.save.party, mon)
|
||||||
list:close()
|
local name = monName(game, mon)
|
||||||
|
game.stringBuffer = name
|
||||||
|
require("src.core.Sound").playCry(game.data, mon.species)
|
||||||
|
afterTransfer(game, list, t._MonIsTakenOutText
|
||||||
|
or (name .. " is\ntaken out.\vGot " .. name .. "."))
|
||||||
end)
|
end)
|
||||||
end,
|
end,
|
||||||
}))
|
}))
|
||||||
end
|
end
|
||||||
|
|
||||||
local function deposit(game)
|
local function deposit(game)
|
||||||
|
local t = game.data.text
|
||||||
|
if #game.save.party <= 1 then
|
||||||
|
game.stack:push(TextBox.new(game, t._CantDepositLastMonText
|
||||||
|
or "You can't deposit\nthe last POKéMON!"))
|
||||||
|
return
|
||||||
|
end
|
||||||
|
local box = Boxes.active(game.save)
|
||||||
|
if #box >= Boxes.CAPACITY then
|
||||||
|
game.stack:push(TextBox.new(game, t._BoxFullText
|
||||||
|
or "Oops! This Box is\nfull of POKéMON."))
|
||||||
|
return
|
||||||
|
end
|
||||||
local items = {}
|
local items = {}
|
||||||
for i, mon in ipairs(game.save.party) do
|
for i, mon in ipairs(game.save.party) do
|
||||||
table.insert(items, { label = monLabel(game, mon), value = i })
|
table.insert(items, { label = monLabel(game, mon), value = i })
|
||||||
@@ -69,40 +110,50 @@ local function deposit(game)
|
|||||||
list.footer = "You need at least\none POKéMON!"
|
list.footer = "You need at least\none POKéMON!"
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
local box = Boxes.active(game.save)
|
local active = Boxes.active(game.save)
|
||||||
if #box >= Boxes.CAPACITY then
|
if #active >= Boxes.CAPACITY then
|
||||||
list.footer = ("BOX %d is full!"):format(game.save.currentBox)
|
list.footer = ("BOX %d is full!"):format(game.save.currentBox)
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
table.remove(game.save.party, item.value)
|
table.remove(game.save.party, item.value)
|
||||||
table.insert(box, mon)
|
table.insert(active, mon)
|
||||||
list:close()
|
local name = monName(game, mon)
|
||||||
|
game.stringBuffer = name
|
||||||
|
game.boxNumString = tostring(game.save.currentBox)
|
||||||
|
require("src.core.Sound").playCry(game.data, mon.species)
|
||||||
|
afterTransfer(game, list, t._MonWasStoredText
|
||||||
|
or (name .. " was\nstored in Box " .. game.boxNumString .. "."))
|
||||||
end)
|
end)
|
||||||
end,
|
end,
|
||||||
}))
|
}))
|
||||||
end
|
end
|
||||||
|
|
||||||
-- RELEASE POKéMON (bills_pc.asm .release): confirm, then "Bye [MON]!"
|
-- RELEASE POKéMON (bills_pc.asm BillsPCRelease): confirm, then "Bye [MON]!".
|
||||||
|
-- Index by list.index (not stale item.value) after removeCurrent (#171).
|
||||||
local function release(game)
|
local function release(game)
|
||||||
local box = Boxes.active(game.save)
|
local box = Boxes.active(game.save)
|
||||||
|
local t = game.data.text
|
||||||
|
if #box == 0 then
|
||||||
|
game.stack:push(TextBox.new(game, t._NoMonText
|
||||||
|
or "What? There are\nno POKéMON here!"))
|
||||||
|
return
|
||||||
|
end
|
||||||
local items = {}
|
local items = {}
|
||||||
for i, mon in ipairs(box) do
|
for i, mon in ipairs(box) do
|
||||||
table.insert(items, { label = monLabel(game, mon), value = i })
|
table.insert(items, { label = monLabel(game, mon), value = i })
|
||||||
end
|
end
|
||||||
game.stack:push(ListMenu.new(game,
|
game.stack:push(ListMenu.new(game,
|
||||||
("BOX %d (RELEASE)"):format(game.save.currentBox), items, {
|
("BOX %d (RELEASE)"):format(game.save.currentBox), items, {
|
||||||
onChoose = function(item, list)
|
onChoose = function(_, list)
|
||||||
local mon = box[item.value]
|
local mon = box[list.index]
|
||||||
if not mon then return end
|
if not mon then return end
|
||||||
local def = game.data.pokemon[mon.species]
|
local name = monName(game, mon)
|
||||||
local name = mon.nickname or def.name
|
|
||||||
local ChoiceBox = require("src.ui.ChoiceBox")
|
local ChoiceBox = require("src.ui.ChoiceBox")
|
||||||
local TextBox = require("src.render.TextBox")
|
|
||||||
game.stack:push(TextBox.new(game,
|
game.stack:push(TextBox.new(game,
|
||||||
"Once released,\n" .. name .. " is\ngone forever. OK?", function()
|
"Once released,\n" .. name .. " is\ngone forever. OK?", function()
|
||||||
game.stack:push(ChoiceBox.new(game, function(yes)
|
game.stack:push(ChoiceBox.new(game, function(yes)
|
||||||
if not yes then return end
|
if not yes then return end
|
||||||
table.remove(box, item.value)
|
table.remove(box, list.index)
|
||||||
require("src.core.Sound").playCry(game.data, mon.species)
|
require("src.core.Sound").playCry(game.data, mon.species)
|
||||||
game.stack:push(TextBox.new(game,
|
game.stack:push(TextBox.new(game,
|
||||||
("%s was\nreleased outside.\fBye %s!"):format(name, name)))
|
("%s was\nreleased outside.\fBye %s!"):format(name, name)))
|
||||||
@@ -129,7 +180,6 @@ local function changeBox(game)
|
|||||||
-- the original asks BEFORE switching ("When you change a #MON
|
-- the original asks BEFORE switching ("When you change a #MON
|
||||||
-- BOX, data will be saved. OK?"); declining aborts the change
|
-- BOX, data will be saved. OK?"); declining aborts the change
|
||||||
local ChoiceBox = require("src.ui.ChoiceBox")
|
local ChoiceBox = require("src.ui.ChoiceBox")
|
||||||
local TextBox = require("src.render.TextBox")
|
|
||||||
game.stack:push(TextBox.new(game,
|
game.stack:push(TextBox.new(game,
|
||||||
"When you change a\nPOKéMON BOX, data\nwill be saved. OK?", function()
|
"When you change a\nPOKéMON BOX, data\nwill be saved. OK?", function()
|
||||||
game.stack:push(ChoiceBox.new(game, function(yes)
|
game.stack:push(ChoiceBox.new(game, function(yes)
|
||||||
@@ -143,21 +193,48 @@ local function changeBox(game)
|
|||||||
}))
|
}))
|
||||||
end
|
end
|
||||||
|
|
||||||
|
-- bills_pc.asm BillsPCMenu chrome: What? text box + BOX No. overlay
|
||||||
|
local function drawChrome(game)
|
||||||
|
Font.drawBox(0, 12, 20, 6)
|
||||||
|
love.graphics.setColor(0, 0, 0, 1)
|
||||||
|
Font.draw("What?", 8, 112)
|
||||||
|
Font.drawBox(9, 14, 11, 4)
|
||||||
|
love.graphics.setColor(0, 0, 0, 1)
|
||||||
|
Font.draw("BOX No.", 80, 128)
|
||||||
|
local n = game.save.currentBox or 1
|
||||||
|
if n >= 10 then
|
||||||
|
Font.draw(tostring(n), 136, 128)
|
||||||
|
else
|
||||||
|
Font.draw(tostring(n), 144, 128)
|
||||||
|
end
|
||||||
|
love.graphics.setColor(1, 1, 1, 1)
|
||||||
|
end
|
||||||
|
|
||||||
function BoxMenu.new(game)
|
function BoxMenu.new(game)
|
||||||
Boxes.ensure(game.save)
|
Boxes.ensure(game.save)
|
||||||
-- bills_pc.asm BillsPCMenu: TextBoxBorder at (0,0) with interior
|
-- bills_pc.asm BillsPCMenu: TextBoxBorder at (0,0) with interior
|
||||||
-- 12x10 → total 14x12. "CHANGE BOX" is 10 tiles and needs the
|
-- 12x10 → total 14x12. "CHANGE BOX" / "WITHDRAW <PK><MN>" need the
|
||||||
-- full interior (cursor col + label); the old tw=12 right-side box
|
-- full interior (cursor col + label). keepOpen so WITHDRAW/DEPOSIT/
|
||||||
-- drew the final glyph on the border.
|
-- RELEASE/CHANGE BOX leave this menu underneath (jp BillsPCMenu).
|
||||||
return Menu.new(game, {
|
local menu = Menu.new(game, {
|
||||||
{ label = "WITHDRAW", onSelect = function() withdraw(game) end },
|
{ label = "WITHDRAW <PK><MN>", keepOpen = true,
|
||||||
{ label = "DEPOSIT", onSelect = function() deposit(game) end },
|
onSelect = function() withdraw(game) end },
|
||||||
{ label = "RELEASE", onSelect = function() release(game) end },
|
{ label = "DEPOSIT <PK><MN>", keepOpen = true,
|
||||||
{ label = "CHANGE BOX", onSelect = function() changeBox(game) end },
|
onSelect = function() deposit(game) end },
|
||||||
|
{ label = "RELEASE <PK><MN>", keepOpen = true,
|
||||||
|
onSelect = function() release(game) end },
|
||||||
|
{ label = "CHANGE BOX", keepOpen = true,
|
||||||
|
onSelect = function() changeBox(game) end },
|
||||||
{ label = "SEE YA!" },
|
{ label = "SEE YA!" },
|
||||||
-- Bill's PC runs silent end to end (BIT_NO_MENU_BUTTON_SOUND,
|
-- Bill's PC runs silent end to end (BIT_NO_MENU_BUTTON_SOUND,
|
||||||
-- engine/menus/pokemon_pc.asm)
|
-- engine/menus/pokemon_pc.asm)
|
||||||
}, { tx = 0, ty = 0, tw = 14, th = 12, noSound = true })
|
}, { tx = 0, ty = 0, tw = 14, th = 12, noSound = true })
|
||||||
|
local baseDraw = menu.draw
|
||||||
|
function menu:draw()
|
||||||
|
baseDraw(self)
|
||||||
|
drawChrome(game)
|
||||||
|
end
|
||||||
|
return menu
|
||||||
end
|
end
|
||||||
|
|
||||||
return BoxMenu
|
return BoxMenu
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
-- YES/NO choice box (top-left of the text box area, like the original).
|
-- YES/NO choice box (InitYesNoTextBoxParameters: above the text box, right).
|
||||||
|
|
||||||
local Font = require("src.render.Font")
|
local Font = require("src.render.Font")
|
||||||
local Theme = require("src.ui.Theme")
|
local Theme = require("src.ui.Theme")
|
||||||
@@ -14,6 +14,11 @@ function ChoiceBox.new(game, onChoose, opts)
|
|||||||
self.index = (opts and opts.defaultNo) and 2 or 1
|
self.index = (opts and opts.defaultNo) and 2 or 1
|
||||||
-- BIT_NO_MENU_BUTTON_SOUND: PC-session prompts stay silent
|
-- BIT_NO_MENU_BUTTON_SOUND: PC-session prompts stay silent
|
||||||
self.noSound = (opts and opts.noSound) or false
|
self.noSound = (opts and opts.noSound) or false
|
||||||
|
local box = Theme.choiceBox
|
||||||
|
self.tx = (opts and opts.tx) or box.tx
|
||||||
|
self.ty = (opts and opts.ty) or box.ty
|
||||||
|
self.tw = (opts and opts.tw) or box.tw
|
||||||
|
self.th = (opts and opts.th) or box.th
|
||||||
return self
|
return self
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -38,13 +43,13 @@ function ChoiceBox:update(dt)
|
|||||||
end
|
end
|
||||||
|
|
||||||
function ChoiceBox:draw()
|
function ChoiceBox:draw()
|
||||||
local box = Theme.choiceBox
|
local tx, ty, tw, th = self.tx, self.ty, self.tw, self.th
|
||||||
Font.drawBox(box.tx, box.ty, box.tw, box.th)
|
Font.drawBox(tx, ty, tw, th)
|
||||||
love.graphics.setColor(0, 0, 0, 1)
|
love.graphics.setColor(0, 0, 0, 1)
|
||||||
Font.draw("YES", (box.tx + 2) * 8, (box.ty + 1) * 8)
|
Font.draw("YES", (tx + 2) * 8, (ty + 1) * 8)
|
||||||
Font.draw("NO", (box.tx + 2) * 8, (box.ty + 3) * 8)
|
Font.draw("NO", (tx + 2) * 8, (ty + 3) * 8)
|
||||||
Font.drawCode(Theme.cursor, (box.tx + 1) * 8,
|
Font.drawCode(Theme.cursor, (tx + 1) * 8,
|
||||||
(box.ty + (self.index == 1 and 1 or 3)) * 8)
|
(ty + (self.index == 1 and 1 or 3)) * 8)
|
||||||
love.graphics.setColor(1, 1, 1, 1)
|
love.graphics.setColor(1, 1, 1, 1)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|||||||
@@ -140,8 +140,9 @@ function Credits:enter()
|
|||||||
end
|
end
|
||||||
|
|
||||||
function Credits:monSprite(species)
|
function Credits:monSprite(species)
|
||||||
local def = self.game.data.pokemon and self.game.data.pokemon[species]
|
local path = require("src.pokemon.Sprites").path(
|
||||||
return silhouette(def and def.spriteFront)
|
self.game.data, species, "front", { kind = "credits" })
|
||||||
|
return silhouette(path)
|
||||||
end
|
end
|
||||||
|
|
||||||
-- advance to the next CreditsOrder screen (Credits .nextCreditsScreen);
|
-- advance to the next CreditsOrder screen (Credits .nextCreditsScreen);
|
||||||
|
|||||||