Compare commits
18 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 3b2e489325 | |||
| 10acfe0710 | |||
| 5113cfd951 | |||
| 43158d724b | |||
| f0a88ea473 | |||
| 31365d8dfd | |||
| 531a719794 | |||
| ff90062741 | |||
| 803f86d5b3 | |||
| 3448fae3f5 | |||
| 25df838b45 | |||
| be90e5b42c | |||
| 08e8dfc416 | |||
| 6a27be54de | |||
| d4055209e6 | |||
| 043f6b2426 | |||
| f7695308b7 | |||
| a8936e79d6 |
@@ -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:
|
||||||
|
|||||||
@@ -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,13 +1,21 @@
|
|||||||
-- pokered/scripts/PewterNidoranHouse.asm
|
-- pokered/scripts/PewterNidoranHouse.asm
|
||||||
-- PewterNidoranHouseNidoranText: text_far _PewterNidoranHouseNidoranText, then
|
-- PewterNidoranHouseNidoranText: text_far _PewterNidoranHouseNidoranText, then
|
||||||
-- text_asm plays the NIDORAN_M cry (PlayCry/WaitForSoundToFinish) before ending.
|
-- text_asm plays the NIDORAN_M cry (PlayCry/WaitForSoundToFinish) before ending.
|
||||||
-- No sound-effect command exists in Commands.lua, so we port the dialogue line
|
-- play_cry only stashes ctx.pendingCry for the very next show_text row, so it
|
||||||
-- only; the cry SFX is cosmetic and has no gameplay effect.
|
-- has to sit immediately before that row (src/script/Commands.lua); the cry
|
||||||
|
-- then sounds once the box has typed out, matching the asm's print-then-cry
|
||||||
|
-- order. Its waitForButton form keeps DisplayTextID's trailing
|
||||||
|
-- WaitForTextScrollButtonPress -- PewterNidoranHouse_Script is
|
||||||
|
-- `jp EnableAutoTextBoxDrawing`, which zeroes
|
||||||
|
-- wDoNotWaitForButtonPressAfterDisplayingText -- so the box still holds until
|
||||||
|
-- A/B. The cry row was missing, leaving the NIDORAN silent (#247).
|
||||||
return {
|
return {
|
||||||
PEWTER_NIDORAN_HOUSE = {
|
PEWTER_NIDORAN_HOUSE = {
|
||||||
talk = {
|
talk = {
|
||||||
TEXT_PEWTERNIDORANHOUSE_NIDORAN = {
|
TEXT_PEWTERNIDORANHOUSE_NIDORAN = {
|
||||||
{ "face_player" },
|
{ "face_player" },
|
||||||
|
-- text_asm: ld a, NIDORAN_M / call PlayCry
|
||||||
|
{ "play_cry", "NIDORAN_M", true },
|
||||||
{ "show_text", "_PewterNidoranHouseNidoranText" },
|
{ "show_text", "_PewterNidoranHouseNidoranText" },
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -59,13 +59,17 @@ M.VIRIDIAN_CITY = {
|
|||||||
-- ViridianCityYoungster2CaterpieAndWeedleDescriptionText are
|
-- ViridianCityYoungster2CaterpieAndWeedleDescriptionText are
|
||||||
-- defined without a leading underscore in pokered/text/ViridianCity.asm
|
-- defined without a leading underscore in pokered/text/ViridianCity.asm
|
||||||
-- and aren't present in data/generated/text.lua, so we fall back to
|
-- and aren't present in data/generated/text.lua, so we fall back to
|
||||||
-- the literal strings from pokered.
|
-- the literal strings from pokered. Those fallbacks have to carry the
|
||||||
|
-- extractor's markers, not plain newlines: line -> \n, cont -> \v,
|
||||||
|
-- para -> \f. Spelling cont/para as \n and \n\n put all six lines on
|
||||||
|
-- one page with nothing to wait on, so the whole speech scrolled past
|
||||||
|
-- without a button press (#250).
|
||||||
TEXT_VIRIDIANCITY_YOUNGSTER2 = function(game, ow, npc, done)
|
TEXT_VIRIDIANCITY_YOUNGSTER2 = function(game, ow, npc, done)
|
||||||
local t = text(game)
|
local t = text(game)
|
||||||
ask(game, t._ViridianCityYoungster2YouWantToKnowAboutText
|
ask(game, t._ViridianCityYoungster2YouWantToKnowAboutText
|
||||||
or "You want to know\nabout the 2 kinds\nof caterpillar\nPOKéMON?", function(yes)
|
or "You want to know\nabout the 2 kinds\vof caterpillar\vPOKéMON?", function(yes)
|
||||||
if yes then
|
if yes then
|
||||||
push(game, "CATERPIE has no\npoison, but\nWEEDLE does.\n\nWatch out for its\nPOISON STING!", done)
|
push(game, "CATERPIE has no\npoison, but\vWEEDLE does.\fWatch out for its\nPOISON STING!", done)
|
||||||
else
|
else
|
||||||
push(game, "Oh, OK then!", done)
|
push(game, "Oh, OK then!", done)
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -3,12 +3,16 @@
|
|||||||
return {
|
return {
|
||||||
VIRIDIAN_NICKNAME_HOUSE = {
|
VIRIDIAN_NICKNAME_HOUSE = {
|
||||||
talk = {
|
talk = {
|
||||||
-- ViridianNicknameHouseSpearowText: text_asm that shows the text
|
-- ViridianNicknameHouseSpearowText: text_asm that PrintTexts the
|
||||||
-- then plays a SPEAROW cry (PlayCry/WaitForSoundToFinish). The
|
-- line, then plays the SPEAROW cry (PlayCry/WaitForSoundToFinish)
|
||||||
-- cry playback isn't in the talk-script command vocabulary, so
|
-- before TextScriptEnd. play_cry only arms the show_text row that
|
||||||
-- only the flavor line is ported here.
|
-- follows it, so the cry sounds once the box has typed out; its
|
||||||
|
-- waitForButton form keeps DisplayTextID's trailing
|
||||||
|
-- WaitForTextScrollButtonPress, so the box then holds until A/B like
|
||||||
|
-- any other NPC line rather than popping itself (#251).
|
||||||
TEXT_VIRIDIANNICKNAMEHOUSE_SPEAROW = {
|
TEXT_VIRIDIANNICKNAMEHOUSE_SPEAROW = {
|
||||||
{ "show_text", "_ViridianNicknameHouseSpearowText" },
|
{ "play_cry", "SPEAROW", true }, -- 1 PlayCry
|
||||||
|
{ "show_text", "_ViridianNicknameHouseSpearowText" }, -- 2 PrintText
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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);
|
||||||
@@ -162,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
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -213,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 },
|
||||||
@@ -254,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 })
|
||||||
|
|||||||
@@ -16,6 +16,14 @@ M.VIRIDIAN_MART = {
|
|||||||
-- StartSimulatingJoypadStates walks the player to the counter
|
-- StartSimulatingJoypadStates walks the player to the counter
|
||||||
-- (.PlayerMovement: PAD_LEFT 1, PAD_UP 2, door (3,7) -> counter (2,5))
|
-- (.PlayerMovement: PAD_LEFT 1, PAD_UP 2, door (3,7) -> counter (2,5))
|
||||||
-- and ViridianMartOaksParcelScript hands the parcel over. The player
|
-- and ViridianMartOaksParcelScript hands the parcel over. The player
|
||||||
|
-- never presses A.
|
||||||
|
--
|
||||||
|
-- The RLE list is consumed back to front: DecodeRLEList fills forward
|
||||||
|
-- from wSimulatedJoypadStatesEnd, the script seeds wSimulatedJoypadStates-
|
||||||
|
-- Index with the count, and home/overworld.asm decrements that index
|
||||||
|
-- before each read. So PAD_LEFT 1, PAD_UP 2 actually plays up, up, left,
|
||||||
|
-- and the walk ends facing the clerk. Replaying the list front to back
|
||||||
|
-- put the player at the counter facing up instead (#235). The player
|
||||||
-- never presses A. This matters beyond convenience: the parcel gates
|
-- never presses A. This matters beyond convenience: the parcel gates
|
||||||
-- Oak's Pokedex and the old man clearing Route 2, so vanilla guarantees
|
-- Oak's Pokedex and the old man clearing Route 2, so vanilla guarantees
|
||||||
-- it on entry rather than letting you walk out without it.
|
-- it on entry rather than letting you walk out without it.
|
||||||
@@ -28,8 +36,8 @@ M.VIRIDIAN_MART = {
|
|||||||
if not f.EVENT_GOT_STARTER then return end
|
if not f.EVENT_GOT_STARTER then return end
|
||||||
ow:queueScript({
|
ow:queueScript({
|
||||||
{ "show_text", "_ViridianMartClerkYouCameFromPalletTownText" },
|
{ "show_text", "_ViridianMartClerkYouCameFromPalletTownText" },
|
||||||
{ "move_player", "left", 1 },
|
|
||||||
{ "move_player", "up", 2 },
|
{ "move_player", "up", 2 },
|
||||||
|
{ "move_player", "left", 1 },
|
||||||
-- the quest text's last page is "{PLAYER} got\nOAK's PARCEL!"
|
-- the quest text's last page is "{PLAYER} got\nOAK's PARCEL!"
|
||||||
{ "give_item", "OAKS_PARCEL", 1, "_ViridianMartClerkParcelQuestText" },
|
{ "give_item", "OAKS_PARCEL", 1, "_ViridianMartClerkParcelQuestText" },
|
||||||
{ "set_flag", "EVENT_GOT_OAKS_PARCEL" },
|
{ "set_flag", "EVENT_GOT_OAKS_PARCEL" },
|
||||||
@@ -395,7 +403,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
|
||||||
|
|||||||
@@ -528,7 +528,9 @@ M.MT_MOON_B2F = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
-- 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")
|
||||||
@@ -563,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
|
||||||
|
|||||||
@@ -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,
|
||||||
}
|
}
|
||||||
|
|
||||||
-- -------------------------------------------------------------------
|
-- -------------------------------------------------------------------
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -114,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)
|
||||||
|
|||||||
@@ -5,19 +5,39 @@ 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
|
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.
|
file documents the runtime model; the visual spec lives separately.
|
||||||
|
|
||||||
## Android multi-ROM import
|
## Android multi-ROM / mod / save import
|
||||||
|
|
||||||
On Android, `love.system.pickFile()` opens the Storage Access Framework
|
On Android, `love.system.pickFile([kind])` opens the Storage Access Framework
|
||||||
picker (`GameActivity.showRomFilePicker`); the chosen file is copied to
|
picker (`GameActivity.showFilePicker`); the chosen file is copied into the app
|
||||||
`picked_rom.gb` in the app save directory. `RomImporter` then imports on
|
save directory as:
|
||||||
refocus / Choose via `findPendingRom`: only a 1 MiB `.gb` whose SHA-1 maps
|
|
||||||
to a version that is **not** yet ready counts as pending. A leftover
|
| `kind` | Destination |
|
||||||
`picked_rom.gb` from Red therefore cannot block Blue's Choose (issue #167).
|
| --- | --- |
|
||||||
After a successful import the consumed save-dir `.gb` is removed.
|
| 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 →
|
**Manual check (device/emulator):** import Red → switch to Blue → Choose →
|
||||||
system file picker must appear (not a silent Red re-extract) → pick Blue →
|
system file picker must appear (not a silent Red re-extract) → pick Blue →
|
||||||
Blue becomes ready beside Red.
|
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
|
## Tab structure
|
||||||
|
|
||||||
@@ -78,6 +98,10 @@ The launcher-facing API:
|
|||||||
- `SaveData.createSlot(version)` -> new slot id, registered but with **no
|
- `SaveData.createSlot(version)` -> new slot id, registered but with **no
|
||||||
save file written**. An empty slot means the title screen offers NEW GAME
|
save file written**. An empty slot means the title screen offers NEW GAME
|
||||||
only, which needs no further changes.
|
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
|
## Launcher mod manager
|
||||||
|
|
||||||
@@ -114,6 +138,9 @@ before `Game:load`, so **it never loads a mod's entry chunk**; only
|
|||||||
temp first (mount only reaches save-dir-relative paths), the same way
|
temp first (mount only reaches save-dir-relative paths), the same way
|
||||||
`RomImporter` handles a dropped ROM. A failed copy rolls its partial tree
|
`RomImporter` handles a dropped ROM. A failed copy rolls its partial tree
|
||||||
back, and every path unmounts and clears the staged temp file.
|
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
|
## Import / Export save
|
||||||
|
|
||||||
@@ -122,9 +149,10 @@ through `src/import/SaveFileIO.lua`, which sits on top of
|
|||||||
`src/save_convert/SaveConvert.lua` and the slot API in `SaveData`.
|
`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
|
- **Import save** is live once the game's ROM is imported (playable). It opens
|
||||||
a native `.sav` picker (`chooseSav`, the per-OS dialogs mirror `chooseZip`;
|
a native `.sav` picker (`chooseSav` on desktop; on Android,
|
||||||
Android has no picker and shows a drop hint). `SaveFileIO.importToSlot`
|
`love.system.pickFile("sav")` → `picked_save.sav`, same SAF path as ROMs).
|
||||||
reads the bytes (an absolute path, a dropped LOVE file, or raw bytes),
|
`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
|
guards the 32768-byte size, runs `SaveConvert.importSav` (which also rejects
|
||||||
a bad main-data checksum), then registers a fresh slot (`SaveData.createSlot`),
|
a bad main-data checksum), then registers a fresh slot (`SaveData.createSlot`),
|
||||||
writes it (`SaveData.writeSlot`), and makes it active (`SaveData.setActiveSlot`).
|
writes it (`SaveData.writeSlot`), and makes it active (`SaveData.setActiveSlot`).
|
||||||
@@ -136,9 +164,13 @@ through `src/import/SaveFileIO.lua`, which sits on top of
|
|||||||
slot, encodes it back with `SaveConvert.exportSav` (a slot never keeps
|
slot, encodes it back with `SaveConvert.exportSav` (a slot never keeps
|
||||||
`rawImport`, so this is a zero-filled template export, which is valid), and
|
`rawImport`, so this is a zero-filled template export, which is valid), and
|
||||||
writes `exports/gen1recomp-<version>-<slotId>.sav` in the save directory
|
writes `exports/gen1recomp-<version>-<slotId>.sav` in the save directory
|
||||||
(`love.filesystem.createDirectory("exports")`). It returns the absolute path
|
(`love.filesystem.createDirectory("exports")`). On desktop it returns the
|
||||||
(`love.filesystem.getSaveDirectory()`), which the notice line shows with a
|
absolute path (`love.filesystem.getSaveDirectory()`), which the notice line
|
||||||
desktop "Open folder" affordance (`love.system.openURL("file://" .. dir)`).
|
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
|
- **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
|
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
|
placeholder) is showing it defaults to red, the always-present first game
|
||||||
|
|||||||
@@ -180,3 +180,51 @@ 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).
|
||||||
|
## Translation support
|
||||||
|
|
||||||
|
Every string the player can read is now reachable from a mod, so a
|
||||||
|
translation is an ordinary content mod rather than a fork.
|
||||||
|
|
||||||
|
Two things had to change. Text layout stopped counting bytes: the dialogue
|
||||||
|
box measures a line in glyphs (charmap sequences), so a 3-byte character
|
||||||
|
costs one column, a cut never lands inside a character, and a page with a
|
||||||
|
non-default `advance` re-measures instead of overflowing. That also fixed
|
||||||
|
25 vanilla English lines that were wrapping early because `é` in POKéMON
|
||||||
|
and POKéDEX costs two bytes ("I study POKéMON as" is 19 bytes and 18
|
||||||
|
glyphs, and the box was breaking it).
|
||||||
|
|
||||||
|
Second, the text the engine writes itself - battle messages, item results,
|
||||||
|
menu labels, the link-play screens - moved behind `src/core/Strings.lua`
|
||||||
|
and the new `strings` registry. Extracted script text was already
|
||||||
|
overridable through `text`; this covers the other half. Entries are keyed
|
||||||
|
by the English source, so a translation that has not reached a string yet
|
||||||
|
keeps rendering in English and a half-finished translation stays playable.
|
||||||
|
|
||||||
|
Authors generate the whole thing:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
python3 tools/modkit.py translation francais --language "Francais"
|
||||||
|
```
|
||||||
|
|
||||||
|
That scaffolds a mod with every translatable string as an empty catalog,
|
||||||
|
plus a glyph-page and charmap stub, a naming-grid stub, and a
|
||||||
|
`francais-worksheet/` directory holding the English to translate from
|
||||||
|
(deliberately outside the mod: extracted text is ROM content and must not
|
||||||
|
be packed). `--refresh` re-harvests after an engine update, keeping
|
||||||
|
existing translations and parking orphaned keys rather than dropping them.
|
||||||
|
|
||||||
|
See the wiki's Translations guide.
|
||||||
|
|||||||
@@ -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.
|
||||||
@@ -274,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)
|
||||||
@@ -281,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,11 +43,11 @@ 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`.
|
||||||
|
|
||||||
ROM import on Android uses `love.system.pickFile()` →
|
ROM / mod / save import on Android uses `love.system.pickFile([kind])` →
|
||||||
`GameActivity.showRomFilePicker` (Storage Access Framework), which copies the
|
`GameActivity.showFilePicker` (Storage Access Framework), which copies the
|
||||||
chosen file to `picked_rom.gb` under the app save directory. `RomImporter`
|
chosen file under the app save directory as `picked_rom.gb`,
|
||||||
imports pending (not-yet-ready) `.gb` files from that folder on Choose /
|
`picked_mod.zip`, or `picked_save.sav`. `RomImporter` imports pending files
|
||||||
refocus; see `docs/launcher.md` (Android multi-ROM import). The APK payload
|
from that folder on Choose / refocus; see `docs/launcher.md`. The APK payload
|
||||||
itself remains data-free (no embedded ROM or generated cache).
|
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.
|
||||||
@@ -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")
|
||||||
@@ -8,6 +8,7 @@
|
|||||||
local MoveEffects = require("src.battle.MoveEffects")
|
local MoveEffects = require("src.battle.MoveEffects")
|
||||||
local Runtime = require("src.mods.Runtime")
|
local Runtime = require("src.mods.Runtime")
|
||||||
local StatusRegistry = require("src.battle.StatusRegistry")
|
local StatusRegistry = require("src.battle.StatusRegistry")
|
||||||
|
local Strings = require("src.core.Strings")
|
||||||
|
|
||||||
local EffectRegistry = {}
|
local EffectRegistry = {}
|
||||||
|
|
||||||
@@ -91,7 +92,7 @@ function EffectRegistry.runDamaging(battle, ctx, record)
|
|||||||
if target.invulnerable and not neverMiss then
|
if target.invulnerable and not neverMiss then
|
||||||
-- Explosion/Selfdestruct still animate on a miss (HandleIfPlayerMoveMissed)
|
-- Explosion/Selfdestruct still animate on a miss (HandleIfPlayerMoveMissed)
|
||||||
if not (record and record.explode) then battle:cancelMoveAnim() end
|
if not (record and record.explode) then battle:cancelMoveAnim() end
|
||||||
battle:sayNext(("%s's\nattack missed!"):format(displayName(user)))
|
battle:sayNext(Strings("%s's\nattack missed!", displayName(user)))
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -113,7 +114,7 @@ function EffectRegistry.runDamaging(battle, ctx, record)
|
|||||||
if not battle:accuracyRoll(move, user, target) then
|
if not battle:accuracyRoll(move, user, target) then
|
||||||
-- Explosion/Selfdestruct still animate on a miss (HandleIfPlayerMoveMissed)
|
-- Explosion/Selfdestruct still animate on a miss (HandleIfPlayerMoveMissed)
|
||||||
if not (record and record.explode) then battle:cancelMoveAnim() end
|
if not (record and record.explode) then battle:cancelMoveAnim() end
|
||||||
battle:sayNext(("%s's\nattack missed!"):format(displayName(user)))
|
battle:sayNext(Strings("%s's\nattack missed!", displayName(user)))
|
||||||
-- Jump Kick crash, Explode self-destruct
|
-- Jump Kick crash, Explode self-destruct
|
||||||
if record and record.onMiss then record.onMiss(ctx, "accuracy") end
|
if record and record.onMiss then record.onMiss(ctx, "accuracy") end
|
||||||
user.trappingTurns = nil
|
user.trappingTurns = nil
|
||||||
@@ -140,7 +141,7 @@ function EffectRegistry.runDamaging(battle, ctx, record)
|
|||||||
end
|
end
|
||||||
if not counterable or (battle.lastDamage or 0) == 0 then
|
if not counterable or (battle.lastDamage or 0) == 0 then
|
||||||
battle:cancelMoveAnim()
|
battle:cancelMoveAnim()
|
||||||
battle:sayNext(("%s's\nattack missed!"):format(displayName(user)))
|
battle:sayNext(Strings("%s's\nattack missed!", displayName(user)))
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
dmg = math.min(65535, battle.lastDamage * 2)
|
dmg = math.min(65535, battle.lastDamage * 2)
|
||||||
@@ -163,14 +164,14 @@ function EffectRegistry.runDamaging(battle, ctx, record)
|
|||||||
if info.typeMult == 0 then
|
if info.typeMult == 0 then
|
||||||
-- type immunity zeros damage and sets wMoveMissed in Gen 1, so no anim
|
-- type immunity zeros damage and sets wMoveMissed in Gen 1, so no anim
|
||||||
if not (record and record.explode) then battle:cancelMoveAnim() end
|
if not (record and record.explode) then battle:cancelMoveAnim() end
|
||||||
battle:sayNext(("It doesn't affect\n%s!"):format(displayName(target)))
|
battle:sayNext(Strings("It doesn't affect\n%s!", displayName(target)))
|
||||||
if record and record.onMiss then record.onMiss(ctx, "immune") end
|
if record and record.onMiss then record.onMiss(ctx, "immune") end
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
if info.missed then
|
if info.missed then
|
||||||
-- 0.25x floored the damage to zero: the original registers a miss
|
-- 0.25x floored the damage to zero: the original registers a miss
|
||||||
if not (record and record.explode) then battle:cancelMoveAnim() end
|
if not (record and record.explode) then battle:cancelMoveAnim() end
|
||||||
battle:sayNext(("%s's\nattack missed!"):format(displayName(user)))
|
battle:sayNext(Strings("%s's\nattack missed!", displayName(user)))
|
||||||
if record and record.onMiss then record.onMiss(ctx, "floored") end
|
if record and record.onMiss then record.onMiss(ctx, "floored") end
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
@@ -214,12 +215,12 @@ function EffectRegistry.runDamaging(battle, ctx, record)
|
|||||||
-- multi-hit loop (core.asm .moveDidNotMiss before the jump back
|
-- multi-hit loop (core.asm .moveDidNotMiss before the jump back
|
||||||
-- to GetPlayerAnimationType), so crit/effectiveness reprint on
|
-- to GetPlayerAnimationType), so crit/effectiveness reprint on
|
||||||
-- every strike -- damage was only rolled once
|
-- every strike -- damage was only rolled once
|
||||||
if info.crit then battle:sayNext("Critical hit!") end
|
if info.crit then battle:sayNext(Strings("Critical hit!")) end
|
||||||
if info.ohko then battle:sayNext("One-hit KO!") end
|
if info.ohko then battle:sayNext(Strings("One-hit KO!")) end
|
||||||
if info.typeMult > 10 then
|
if info.typeMult > 10 then
|
||||||
battle:sayNext("It's super\neffective!")
|
battle:sayNext(Strings("It's super\neffective!"))
|
||||||
elseif info.typeMult < 10 then
|
elseif info.typeMult < 10 then
|
||||||
battle:sayNext("It's not very\neffective...")
|
battle:sayNext(Strings("It's not very\neffective..."))
|
||||||
end
|
end
|
||||||
if Runtime.wants("battle.damage_dealt") then
|
if Runtime.wants("battle.damage_dealt") then
|
||||||
Runtime.emit("battle.damage_dealt", {
|
Runtime.emit("battle.damage_dealt", {
|
||||||
@@ -237,9 +238,9 @@ function EffectRegistry.runDamaging(battle, ctx, record)
|
|||||||
if hits > 1 then
|
if hits > 1 then
|
||||||
-- player: _MultiHitText; enemy: _HitXTimesText (always plural)
|
-- player: _MultiHitText; enemy: _HitXTimesText (always plural)
|
||||||
if user.isPlayer then
|
if user.isPlayer then
|
||||||
battle:sayNext(("Hit the enemy\n%d times!"):format(hits))
|
battle:sayNext(Strings("Hit the enemy\n%d times!", hits))
|
||||||
else
|
else
|
||||||
battle:sayNext(("Hit %d times!"):format(hits))
|
battle:sayNext(Strings("Hit %d times!", hits))
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -251,7 +252,7 @@ function EffectRegistry.runDamaging(battle, ctx, record)
|
|||||||
elseif moveInst.struggle then
|
elseif moveInst.struggle then
|
||||||
-- struggle recoils even when its effect id resolves to no record
|
-- struggle recoils even when its effect id resolves to no record
|
||||||
local recoil = math.max(1, math.floor(dmg / 2))
|
local recoil = math.max(1, math.floor(dmg / 2))
|
||||||
battle:sayNext(("%s's\nhit with recoil!"):format(displayName(user)))
|
battle:sayNext(Strings("%s's\nhit with recoil!", displayName(user)))
|
||||||
battle:applyDamage(user, recoil)
|
battle:applyDamage(user, recoil)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ local Logger = require("src.core.Logger")
|
|||||||
local StatusRegistry = require("src.battle.StatusRegistry")
|
local StatusRegistry = require("src.battle.StatusRegistry")
|
||||||
local TurnOrder = require("src.battle.TurnOrder")
|
local TurnOrder = require("src.battle.TurnOrder")
|
||||||
local TypeChart = require("src.battle.TypeChart")
|
local TypeChart = require("src.battle.TypeChart")
|
||||||
|
local Strings = require("src.core.Strings")
|
||||||
|
|
||||||
local MoveEffects = {}
|
local MoveEffects = {}
|
||||||
|
|
||||||
@@ -36,14 +37,14 @@ local STAT_LABEL = {
|
|||||||
local function changeStage(battle, who, stat, delta, fromEnemy)
|
local function changeStage(battle, who, stat, delta, fromEnemy)
|
||||||
if fromEnemy and (who.substituteHP or who.mist) then
|
if fromEnemy and (who.substituteHP or who.mist) then
|
||||||
if who.mist then
|
if who.mist then
|
||||||
return { displayName(who) .. " is\nprotected by MIST!" }
|
return { Strings("%s is\nprotected by MIST!", displayName(who)) }
|
||||||
end
|
end
|
||||||
return { "But, it failed!" }
|
return { Strings("But, it failed!") }
|
||||||
end
|
end
|
||||||
local cur = who.stages[stat] or 0
|
local cur = who.stages[stat] or 0
|
||||||
local new = math.max(-6, math.min(6, cur + delta))
|
local new = math.max(-6, math.min(6, cur + delta))
|
||||||
if new == cur then
|
if new == cur then
|
||||||
return { ("Nothing happened!") }
|
return { Strings("Nothing happened!") }
|
||||||
end
|
end
|
||||||
who.stages[stat] = new
|
who.stages[stat] = new
|
||||||
-- effects.asm:505-506: after any stat-stage change, modified stats are
|
-- effects.asm:505-506: after any stat-stage change, modified stats are
|
||||||
@@ -53,13 +54,13 @@ local function changeStage(battle, who, stat, delta, fromEnemy)
|
|||||||
-- _MonsStatsRoseText/_MonsStatsFellText: "X's / STAT rose!"; the
|
-- _MonsStatsRoseText/_MonsStatsFellText: "X's / STAT rose!"; the
|
||||||
-- two-stage variants scroll "greatly" onto a third line
|
-- two-stage variants scroll "greatly" onto a third line
|
||||||
if delta >= 2 then
|
if delta >= 2 then
|
||||||
return { ("%s's\n%s\ngreatly rose!"):format(displayName(who), STAT_LABEL[stat]) }
|
return { Strings("%s's\n%s\ngreatly rose!", displayName(who), STAT_LABEL[stat]) }
|
||||||
elseif delta == 1 then
|
elseif delta == 1 then
|
||||||
return { ("%s's\n%s rose!"):format(displayName(who), STAT_LABEL[stat]) }
|
return { Strings("%s's\n%s rose!", displayName(who), STAT_LABEL[stat]) }
|
||||||
elseif delta == -1 then
|
elseif delta == -1 then
|
||||||
return { ("%s's\n%s fell!"):format(displayName(who), STAT_LABEL[stat]) }
|
return { Strings("%s's\n%s fell!", displayName(who), STAT_LABEL[stat]) }
|
||||||
end
|
end
|
||||||
return { ("%s's\n%s\ngreatly fell!"):format(displayName(who), STAT_LABEL[stat]) }
|
return { Strings("%s's\n%s\ngreatly fell!", displayName(who), STAT_LABEL[stat]) }
|
||||||
end
|
end
|
||||||
MoveEffects.changeStage = changeStage
|
MoveEffects.changeStage = changeStage
|
||||||
|
|
||||||
@@ -88,10 +89,10 @@ end
|
|||||||
local function statusMove(status)
|
local function statusMove(status)
|
||||||
return function(battle, user, target, move)
|
return function(battle, user, target, move)
|
||||||
if target.mon.status then
|
if target.mon.status then
|
||||||
return { "But, it failed!" }
|
return { Strings("But, it failed!") }
|
||||||
end
|
end
|
||||||
if status == "PSN" and target.substituteHP then
|
if status == "PSN" and target.substituteHP then
|
||||||
return { "But, it failed!" }
|
return { Strings("But, it failed!") }
|
||||||
end
|
end
|
||||||
local msgs = inflictStatus(battle, target, status, {
|
local msgs = inflictStatus(battle, target, status, {
|
||||||
toxic = move and move.id == "TOXIC",
|
toxic = move and move.id == "TOXIC",
|
||||||
@@ -99,7 +100,7 @@ local function statusMove(status)
|
|||||||
source = move and move.id,
|
source = move and move.id,
|
||||||
})
|
})
|
||||||
if #msgs == 0 then
|
if #msgs == 0 then
|
||||||
return { "But, it failed!" }
|
return { Strings("But, it failed!") }
|
||||||
end
|
end
|
||||||
return msgs
|
return msgs
|
||||||
end
|
end
|
||||||
@@ -111,7 +112,7 @@ local function statusSide(status, chance)
|
|||||||
-- target (regardless of the burn roll)
|
-- target (regardless of the burn roll)
|
||||||
if move and move.type == "FIRE" and target.mon.status == "FRZ" then
|
if move and move.type == "FIRE" and target.mon.status == "FRZ" then
|
||||||
target.mon.status = nil
|
target.mon.status = nil
|
||||||
return { ("Fire defrosted\n%s!"):format(displayName(target)) }
|
return { Strings("Fire defrosted\n%s!", displayName(target)) }
|
||||||
end
|
end
|
||||||
if battle.rng(0, 255) >= chance then return {} end
|
if battle.rng(0, 255) >= chance then return {} end
|
||||||
return inflictStatus(battle, target, status, {
|
return inflictStatus(battle, target, status, {
|
||||||
@@ -144,10 +145,10 @@ end
|
|||||||
|
|
||||||
local function confuse(battle, target, pierceSub)
|
local function confuse(battle, target, pierceSub)
|
||||||
if target.confusedTurns or (target.substituteHP and not pierceSub) then
|
if target.confusedTurns or (target.substituteHP and not pierceSub) then
|
||||||
return { "But, it failed!" }
|
return { Strings("But, it failed!") }
|
||||||
end
|
end
|
||||||
target.confusedTurns = battle.rng(2, 5)
|
target.confusedTurns = battle.rng(2, 5)
|
||||||
return { ("%s\nbecame confused!"):format(displayName(target)) }
|
return { Strings("%s\nbecame confused!", displayName(target)) }
|
||||||
end
|
end
|
||||||
|
|
||||||
-- ---------------------------------------------------------------------
|
-- ---------------------------------------------------------------------
|
||||||
@@ -181,53 +182,53 @@ MoveEffects.primary = {
|
|||||||
LEECH_SEED_EFFECT = function(battle, user, target)
|
LEECH_SEED_EFFECT = function(battle, user, target)
|
||||||
-- leech_seed.asm has no substitute check: seeding lands through one
|
-- leech_seed.asm has no substitute check: seeding lands through one
|
||||||
if target.leechSeeded then
|
if target.leechSeeded then
|
||||||
return { "But, it failed!" }
|
return { Strings("But, it failed!") }
|
||||||
end
|
end
|
||||||
for _, t in ipairs(target.curTypes) do
|
for _, t in ipairs(target.curTypes) do
|
||||||
if t == "GRASS" then return { "But, it failed!" } end
|
if t == "GRASS" then return { Strings("But, it failed!") } end
|
||||||
end
|
end
|
||||||
target.leechSeeded = true
|
target.leechSeeded = true
|
||||||
return { ("%s\nwas seeded!"):format(displayName(target)) }
|
return { Strings("%s\nwas seeded!", displayName(target)) }
|
||||||
end,
|
end,
|
||||||
|
|
||||||
HEAL_EFFECT = function(battle, user, target, move)
|
HEAL_EFFECT = function(battle, user, target, move)
|
||||||
local mon = user.mon
|
local mon = user.mon
|
||||||
if move.id == "REST" then
|
if move.id == "REST" then
|
||||||
if mon.hp == mon.stats.hp then return { "But, it failed!" } end
|
if mon.hp == mon.stats.hp then return { Strings("But, it failed!") } end
|
||||||
mon.hp = mon.stats.hp
|
mon.hp = mon.stats.hp
|
||||||
mon.status = "SLP"
|
mon.status = "SLP"
|
||||||
user.sleepTurns = 2
|
user.sleepTurns = 2
|
||||||
user.toxicCounter = nil
|
user.toxicCounter = nil
|
||||||
return { ("%s\nstarted sleeping!"):format(displayName(user)) }
|
return { Strings("%s\nstarted sleeping!", displayName(user)) }
|
||||||
end
|
end
|
||||||
if mon.hp == mon.stats.hp then return { "But, it failed!" } end
|
if mon.hp == mon.stats.hp then return { Strings("But, it failed!") } end
|
||||||
mon.hp = math.min(mon.stats.hp, mon.hp + math.floor(mon.stats.hp / 2))
|
mon.hp = math.min(mon.stats.hp, mon.hp + math.floor(mon.stats.hp / 2))
|
||||||
return { ("%s\nregained health!"):format(displayName(user)) }
|
return { Strings("%s\nregained health!", displayName(user)) }
|
||||||
end,
|
end,
|
||||||
|
|
||||||
LIGHT_SCREEN_EFFECT = function(battle, user)
|
LIGHT_SCREEN_EFFECT = function(battle, user)
|
||||||
if user.lightScreen then return { "But, it failed!" } end
|
if user.lightScreen then return { Strings("But, it failed!") } end
|
||||||
user.lightScreen = true
|
user.lightScreen = true
|
||||||
return { ("%s's\nprotected against\nspecial attacks!"):format(displayName(user)) }
|
return { Strings("%s's\nprotected against\nspecial attacks!", displayName(user)) }
|
||||||
end,
|
end,
|
||||||
|
|
||||||
REFLECT_EFFECT = function(battle, user)
|
REFLECT_EFFECT = function(battle, user)
|
||||||
if user.reflect then return { "But, it failed!" } end
|
if user.reflect then return { Strings("But, it failed!") } end
|
||||||
user.reflect = true
|
user.reflect = true
|
||||||
return { ("%s\ngained armor!"):format(displayName(user)) }
|
return { Strings("%s\ngained armor!", displayName(user)) }
|
||||||
end,
|
end,
|
||||||
|
|
||||||
MIST_EFFECT = function(battle, user)
|
MIST_EFFECT = function(battle, user)
|
||||||
if user.mist then return { "But, it failed!" } end
|
if user.mist then return { Strings("But, it failed!") } end
|
||||||
user.mist = true
|
user.mist = true
|
||||||
-- _ShroudedInMistText (lowercase "mist")
|
-- _ShroudedInMistText (lowercase "mist")
|
||||||
return { ("%s's\nshrouded in mist!"):format(displayName(user)) }
|
return { Strings("%s's\nshrouded in mist!", displayName(user)) }
|
||||||
end,
|
end,
|
||||||
|
|
||||||
FOCUS_ENERGY_EFFECT = function(battle, user)
|
FOCUS_ENERGY_EFFECT = function(battle, user)
|
||||||
if user.focusEnergy then return { "But, it failed!" } end
|
if user.focusEnergy then return { Strings("But, it failed!") } end
|
||||||
user.focusEnergy = true
|
user.focusEnergy = true
|
||||||
return { ("%s's\ngetting pumped!"):format(displayName(user)) }
|
return { Strings("%s's\ngetting pumped!", displayName(user)) }
|
||||||
end,
|
end,
|
||||||
|
|
||||||
HAZE_EFFECT = function(battle, user, target)
|
HAZE_EFFECT = function(battle, user, target)
|
||||||
@@ -254,33 +255,33 @@ MoveEffects.primary = {
|
|||||||
target.skipMove = true
|
target.skipMove = true
|
||||||
end
|
end
|
||||||
target.mon.status = nil
|
target.mon.status = nil
|
||||||
return { "All STATUS changes\nare eliminated!" }
|
return { Strings("All STATUS changes\nare eliminated!") }
|
||||||
end,
|
end,
|
||||||
|
|
||||||
SUBSTITUTE_EFFECT = function(battle, user)
|
SUBSTITUTE_EFFECT = function(battle, user)
|
||||||
if user.substituteHP then return { ("%s\nhas a SUBSTITUTE!"):format(displayName(user)) } end
|
if user.substituteHP then return { Strings("%s\nhas a SUBSTITUTE!", displayName(user)) } end
|
||||||
local cost = math.floor(user.mon.stats.hp / 4)
|
local cost = math.floor(user.mon.stats.hp / 4)
|
||||||
-- substitute.asm only fails on subtraction underflow (current HP
|
-- substitute.asm only fails on subtraction underflow (current HP
|
||||||
-- strictly below maxHP/4); at equality the substitute is built and
|
-- strictly below maxHP/4); at equality the substitute is built and
|
||||||
-- the user is left standing on exactly 0 HP (it faints only when
|
-- the user is left standing on exactly 0 HP (it faints only when
|
||||||
-- the engine next checks HP, not here)
|
-- the engine next checks HP, not here)
|
||||||
if user.mon.hp < cost then
|
if user.mon.hp < cost then
|
||||||
return { "Too weak to make\na SUBSTITUTE!" }
|
return { Strings("Too weak to make\na SUBSTITUTE!") }
|
||||||
end
|
end
|
||||||
user.mon.hp = user.mon.hp - cost
|
user.mon.hp = user.mon.hp - cost
|
||||||
user.substituteHP = cost + 1
|
user.substituteHP = cost + 1
|
||||||
-- _SubstituteText
|
-- _SubstituteText
|
||||||
return { "It created a\nSUBSTITUTE!" }
|
return { Strings("It created a\nSUBSTITUTE!") }
|
||||||
end,
|
end,
|
||||||
|
|
||||||
CONVERSION_EFFECT = function(battle, user, target)
|
CONVERSION_EFFECT = function(battle, user, target)
|
||||||
-- conversion.asm fails against a mid-Fly/Dig target (INVULNERABLE)
|
-- conversion.asm fails against a mid-Fly/Dig target (INVULNERABLE)
|
||||||
if target.invulnerable then
|
if target.invulnerable then
|
||||||
return { "But, it failed!" }
|
return { Strings("But, it failed!") }
|
||||||
end
|
end
|
||||||
user.curTypes = { target.curTypes[1], target.curTypes[2] }
|
user.curTypes = { target.curTypes[1], target.curTypes[2] }
|
||||||
-- _ConvertedTypeText
|
-- _ConvertedTypeText
|
||||||
return { ("Converted type to\n%s's!"):format(displayName(target)) }
|
return { Strings("Converted type to\n%s's!", displayName(target)) }
|
||||||
end,
|
end,
|
||||||
|
|
||||||
-- MIMIC_EFFECT lives in BattleState:resolveMimic: MimicEffect
|
-- MIMIC_EFFECT lives in BattleState:resolveMimic: MimicEffect
|
||||||
@@ -311,27 +312,27 @@ MoveEffects.primary = {
|
|||||||
table.insert(user.curMoves, { id = mv.id, pp = 5, mimic = true })
|
table.insert(user.curMoves, { id = mv.id, pp = 5, mimic = true })
|
||||||
end
|
end
|
||||||
-- _TransformedText: the copied name prints bare (wNameBuffer)
|
-- _TransformedText: the copied name prints bare (wNameBuffer)
|
||||||
return { ("%s\ntransformed into\n%s!"):format(displayName(user), target.name) }
|
return { Strings("%s\ntransformed into\n%s!", displayName(user), target.name) }
|
||||||
end,
|
end,
|
||||||
|
|
||||||
DISABLE_EFFECT = function(battle, user, target)
|
DISABLE_EFFECT = function(battle, user, target)
|
||||||
if target.disabledSlot then return { "But, it failed!" } end
|
if target.disabledSlot then return { Strings("But, it failed!") } end
|
||||||
local usable = {}
|
local usable = {}
|
||||||
for i, mv in ipairs(target.curMoves) do
|
for i, mv in ipairs(target.curMoves) do
|
||||||
if mv.pp > 0 then table.insert(usable, i) end
|
if mv.pp > 0 then table.insert(usable, i) end
|
||||||
end
|
end
|
||||||
if #usable == 0 then return { "But, it failed!" } end
|
if #usable == 0 then return { Strings("But, it failed!") } end
|
||||||
local slot = usable[battle.rng(1, #usable)]
|
local slot = usable[battle.rng(1, #usable)]
|
||||||
target.disabledSlot = slot
|
target.disabledSlot = slot
|
||||||
target.disabledTurns = battle.rng(1, 8)
|
target.disabledTurns = battle.rng(1, 8)
|
||||||
local id = target.curMoves[slot].id
|
local id = target.curMoves[slot].id
|
||||||
-- _MoveWasDisabledText: "X's / MOVE was / disabled!"
|
-- _MoveWasDisabledText: "X's / MOVE was / disabled!"
|
||||||
return { ("%s's\n%s was\ndisabled!"):format(displayName(target),
|
return { Strings("%s's\n%s was\ndisabled!", displayName(target),
|
||||||
battle.data.moves[id].name) }
|
battle.data.moves[id].name) }
|
||||||
end,
|
end,
|
||||||
|
|
||||||
SPLASH_EFFECT = function()
|
SPLASH_EFFECT = function()
|
||||||
return { "No effect!" }
|
return { Strings("No effect!") }
|
||||||
end,
|
end,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -428,7 +429,9 @@ local function drainHalf(text)
|
|||||||
local mon = ctx.user.mon
|
local mon = ctx.user.mon
|
||||||
mon.hp = math.min(mon.stats.hp, mon.hp + heal)
|
mon.hp = math.min(mon.stats.hp, mon.hp + heal)
|
||||||
ctx.drain()
|
ctx.drain()
|
||||||
ctx.say(text:format(displayName(ctx.target)))
|
-- `text` arrives as a source string (Strings.source at the call
|
||||||
|
-- site keeps it in the catalog); look it up here, at use time
|
||||||
|
ctx.say(Strings(text, displayName(ctx.target)))
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -436,7 +439,7 @@ end
|
|||||||
-- flags the miss before the special-damage override)
|
-- flags the miss before the special-damage override)
|
||||||
local function immuneMsg(ctx)
|
local function immuneMsg(ctx)
|
||||||
if TypeChart.effectiveness(ctx.move.type, ctx.target.curTypes) == 0 then
|
if TypeChart.effectiveness(ctx.move.type, ctx.target.curTypes) == 0 then
|
||||||
return ("It doesn't affect\n%s!"):format(displayName(ctx.target))
|
return Strings("It doesn't affect\n%s!", displayName(ctx.target))
|
||||||
end
|
end
|
||||||
return nil
|
return nil
|
||||||
end
|
end
|
||||||
@@ -498,12 +501,12 @@ MoveEffects.full = {
|
|||||||
-- removed): overkill and substitute hits recoil at full strength
|
-- removed): overkill and substitute hits recoil at full strength
|
||||||
local recoil = math.max(1, math.floor(ctx.rawDamage
|
local recoil = math.max(1, math.floor(ctx.rawDamage
|
||||||
/ (ctx.moveInst.struggle and 2 or 4)))
|
/ (ctx.moveInst.struggle and 2 or 4)))
|
||||||
ctx.say(("%s's\nhit with recoil!"):format(displayName(ctx.user)))
|
ctx.say(Strings("%s's\nhit with recoil!", displayName(ctx.user)))
|
||||||
ctx.battle:applyDamage(ctx.user, recoil)
|
ctx.battle:applyDamage(ctx.user, recoil)
|
||||||
end,
|
end,
|
||||||
},
|
},
|
||||||
DRAIN_HP_EFFECT = {
|
DRAIN_HP_EFFECT = {
|
||||||
afterDamage = drainHalf("Sucked health from\n%s!"),
|
afterDamage = drainHalf(Strings.source("Sucked health from\n%s!")),
|
||||||
},
|
},
|
||||||
DREAM_EATER_EFFECT = {
|
DREAM_EATER_EFFECT = {
|
||||||
-- only works on sleeping targets (checked before damage)
|
-- only works on sleeping targets (checked before damage)
|
||||||
@@ -511,7 +514,7 @@ MoveEffects.full = {
|
|||||||
if ctx.target.mon.status ~= "SLP" then return false, "But, it failed!" end
|
if ctx.target.mon.status ~= "SLP" then return false, "But, it failed!" end
|
||||||
return true
|
return true
|
||||||
end,
|
end,
|
||||||
afterDamage = drainHalf("%s's\ndream was eaten!"),
|
afterDamage = drainHalf(Strings.source("%s's\ndream was eaten!")),
|
||||||
},
|
},
|
||||||
|
|
||||||
-- charge moves: first turn just charges; Fly AND Dig go
|
-- charge moves: first turn just charges; Fly AND Dig go
|
||||||
@@ -557,7 +560,7 @@ MoveEffects.full = {
|
|||||||
user.thrashTurns, user.thrashMove, user.thrashAnnounced = nil, nil, nil
|
user.thrashTurns, user.thrashMove, user.thrashAnnounced = nil, nil, nil
|
||||||
if not user.confusedTurns then
|
if not user.confusedTurns then
|
||||||
user.confusedTurns = ctx.rng(2, 5)
|
user.confusedTurns = ctx.rng(2, 5)
|
||||||
ctx.say(("%s\nbecame confused!"):format(displayName(user)))
|
ctx.say(Strings("%s\nbecame confused!", displayName(user)))
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
@@ -566,7 +569,7 @@ MoveEffects.full = {
|
|||||||
JUMP_KICK_EFFECT = {
|
JUMP_KICK_EFFECT = {
|
||||||
onMiss = function(ctx, reason)
|
onMiss = function(ctx, reason)
|
||||||
if reason ~= "accuracy" then return end
|
if reason ~= "accuracy" then return end
|
||||||
ctx.say(("%s\nkept going and\ncrashed!"):format(displayName(ctx.user)))
|
ctx.say(Strings("%s\nkept going and\ncrashed!", displayName(ctx.user)))
|
||||||
ctx.damage(ctx.user, 1)
|
ctx.damage(ctx.user, 1)
|
||||||
end,
|
end,
|
||||||
},
|
},
|
||||||
@@ -581,8 +584,12 @@ MoveEffects.full = {
|
|||||||
},
|
},
|
||||||
HYPER_BEAM_EFFECT = {
|
HYPER_BEAM_EFFECT = {
|
||||||
afterDamage = function(ctx)
|
afterDamage = function(ctx)
|
||||||
-- no recharge when the target faints OR its substitute breaks
|
-- Gen 1: no recharge when the target faints OR its substitute breaks.
|
||||||
if ctx.target.mon.hp > 0 and not ctx.brokeSub then
|
-- Ruleset hyperBeamSkipRechargeOnKO=false forces Gen 2+ always-recharge.
|
||||||
|
local ruleset = ctx.battle and ctx.battle.ruleset
|
||||||
|
local skipOnKO = not ruleset or ruleset.hyperBeamSkipRechargeOnKO ~= false
|
||||||
|
local targetDown = ctx.target.mon.hp <= 0 or ctx.brokeSub
|
||||||
|
if not skipOnKO or not targetDown then
|
||||||
ctx.user.mustRecharge = true
|
ctx.user.mustRecharge = true
|
||||||
end
|
end
|
||||||
end,
|
end,
|
||||||
@@ -591,7 +598,7 @@ MoveEffects.full = {
|
|||||||
afterDamage = function(ctx)
|
afterDamage = function(ctx)
|
||||||
local battle = ctx.battle
|
local battle = ctx.battle
|
||||||
battle.payDay = (battle.payDay or 0) + 2 * ctx.user.mon.level
|
battle.payDay = (battle.payDay or 0) + 2 * ctx.user.mon.level
|
||||||
ctx.say("Coins scattered\neverywhere!")
|
ctx.say(Strings("Coins scattered\neverywhere!"))
|
||||||
end,
|
end,
|
||||||
},
|
},
|
||||||
SWIFT_EFFECT = { neverMiss = true },
|
SWIFT_EFFECT = { neverMiss = true },
|
||||||
@@ -606,7 +613,7 @@ MoveEffects.full = {
|
|||||||
local user = ctx.user
|
local user = ctx.user
|
||||||
user.bideTurns = ctx.rng(2, 3)
|
user.bideTurns = ctx.rng(2, 3)
|
||||||
user.bideDamage = 0
|
user.bideDamage = 0
|
||||||
ctx.say(("%s\nis storing energy!"):format(displayName(user)))
|
ctx.say(Strings("%s\nis storing energy!", displayName(user)))
|
||||||
end,
|
end,
|
||||||
},
|
},
|
||||||
SWITCH_AND_TELEPORT_EFFECT = {
|
SWITCH_AND_TELEPORT_EFFECT = {
|
||||||
@@ -627,27 +634,27 @@ MoveEffects.full = {
|
|||||||
end
|
end
|
||||||
if ok then
|
if ok then
|
||||||
if move.id == "ROAR" then
|
if move.id == "ROAR" then
|
||||||
ctx.say(("%s\nran away scared!"):format(displayName(target)))
|
ctx.say(Strings("%s\nran away scared!", displayName(target)))
|
||||||
elseif move.id == "WHIRLWIND" then
|
elseif move.id == "WHIRLWIND" then
|
||||||
ctx.say(("%s\nwas blown away!"):format(displayName(target)))
|
ctx.say(Strings("%s\nwas blown away!", displayName(target)))
|
||||||
else
|
else
|
||||||
ctx.say(("%s\nran from battle!"):format(displayName(user)))
|
ctx.say(Strings("%s\nran from battle!", displayName(user)))
|
||||||
end
|
end
|
||||||
battle.result = "run"
|
battle.result = "run"
|
||||||
battle.afterQueue = "finish"
|
battle.afterQueue = "finish"
|
||||||
elseif move.id == "TELEPORT" then
|
elseif move.id == "TELEPORT" then
|
||||||
battle:cancelMoveAnim()
|
battle:cancelMoveAnim()
|
||||||
ctx.say("But, it failed!")
|
ctx.say(Strings("But, it failed!"))
|
||||||
else
|
else
|
||||||
battle:cancelMoveAnim()
|
battle:cancelMoveAnim()
|
||||||
ctx.say(("It didn't affect\n%s!"):format(displayName(target)))
|
ctx.say(Strings("It didn't affect\n%s!", displayName(target)))
|
||||||
end
|
end
|
||||||
elseif move.id == "TELEPORT" then
|
elseif move.id == "TELEPORT" then
|
||||||
battle:cancelMoveAnim()
|
battle:cancelMoveAnim()
|
||||||
ctx.say("But, it failed!")
|
ctx.say(Strings("But, it failed!"))
|
||||||
else
|
else
|
||||||
battle:cancelMoveAnim()
|
battle:cancelMoveAnim()
|
||||||
ctx.say(("%s\nis unaffected!"):format(displayName(target)))
|
ctx.say(Strings("%s\nis unaffected!", displayName(target)))
|
||||||
end
|
end
|
||||||
end,
|
end,
|
||||||
},
|
},
|
||||||
@@ -665,7 +672,7 @@ MoveEffects.full = {
|
|||||||
callsMove = function(ctx)
|
callsMove = function(ctx)
|
||||||
local last = ctx.target.lastMove
|
local last = ctx.target.lastMove
|
||||||
if not last then
|
if not last then
|
||||||
ctx.say("The MIRROR MOVE\nfailed!")
|
ctx.say(Strings("The MIRROR MOVE\nfailed!"))
|
||||||
return nil
|
return nil
|
||||||
end
|
end
|
||||||
return last
|
return last
|
||||||
|
|||||||
@@ -5,6 +5,8 @@
|
|||||||
-- and residual sweep. Callers without a battle (pure-module tests) fall
|
-- and residual sweep. Callers without a battle (pure-module tests) fall
|
||||||
-- back to the vanilla records, which is bit-identical behavior.
|
-- back to the vanilla records, which is bit-identical behavior.
|
||||||
|
|
||||||
|
local Strings = require("src.core.Strings")
|
||||||
|
|
||||||
local Status = {}
|
local Status = {}
|
||||||
|
|
||||||
-- pokered's <USER>/<TARGET> text macros print "Enemy " before the enemy
|
-- pokered's <USER>/<TARGET> text macros print "Enemy " before the enemy
|
||||||
@@ -28,8 +30,11 @@ local function hasType(battler, wanted)
|
|||||||
end
|
end
|
||||||
|
|
||||||
-- shared PSN/BRN residual: 1/16 max HP, multiplied (and advanced) by the
|
-- shared PSN/BRN residual: 1/16 max HP, multiplied (and advanced) by the
|
||||||
-- Toxic counter (HandlePoisonBurnLeechSeed)
|
-- Toxic counter (HandlePoisonBurnLeechSeed). The caller passes the whole
|
||||||
local function damageOverTime(what)
|
-- sentence rather than the noun: "hurt by poison" and "hurt by the burn"
|
||||||
|
-- decline differently once translated, so a shared fragment cannot be the
|
||||||
|
-- translatable unit.
|
||||||
|
local function damageOverTime(template)
|
||||||
return function(battler)
|
return function(battler)
|
||||||
local mon = battler.mon
|
local mon = battler.mon
|
||||||
local base = math.max(1, math.floor(mon.stats.hp / 16))
|
local base = math.max(1, math.floor(mon.stats.hp / 16))
|
||||||
@@ -39,10 +44,15 @@ local function damageOverTime(what)
|
|||||||
battler.toxicCounter = battler.toxicCounter + 1
|
battler.toxicCounter = battler.toxicCounter + 1
|
||||||
end
|
end
|
||||||
mon.hp = math.max(0, mon.hp - dmg)
|
mon.hp = math.max(0, mon.hp - dmg)
|
||||||
return { ("%s's\nhurt by %s!"):format(name(battler), what) }
|
return { Strings(template, name(battler)) }
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
-- Labels stay plain literals on purpose: this table is built at require
|
||||||
|
-- time, before Strings.load has a catalog, so a Strings() here would
|
||||||
|
-- freeze the English. They are already translatable through the
|
||||||
|
-- statuses registry (mod.content.statuses:patch(id, { label = ... })).
|
||||||
|
--
|
||||||
-- The five persistent conditions as records: the beforeMove gauntlet, the
|
-- The five persistent conditions as records: the beforeMove gauntlet, the
|
||||||
-- residual sweep, the inflict text/immunities (StatusRegistry.inflict),
|
-- residual sweep, the inflict text/immunities (StatusRegistry.inflict),
|
||||||
-- the catch/wobble bonuses (Catching.attempt), the HUD label, and the
|
-- the catch/wobble bonuses (Catching.attempt), the HUD label, and the
|
||||||
@@ -57,13 +67,13 @@ Status.RECORDS = {
|
|||||||
battler.sleepTurns = (battler.sleepTurns or 1) - 1
|
battler.sleepTurns = (battler.sleepTurns or 1) - 1
|
||||||
if battler.sleepTurns <= 0 then
|
if battler.sleepTurns <= 0 then
|
||||||
battler.mon.status = nil
|
battler.mon.status = nil
|
||||||
return false, { name(battler) .. "\nwoke up!" } -- wakes but loses the turn
|
return false, { Strings("%s\nwoke up!", name(battler)) } -- wakes, loses the turn
|
||||||
end
|
end
|
||||||
return false, { name(battler) .. "\nis fast asleep!" }
|
return false, { Strings("%s\nis fast asleep!", name(battler)) }
|
||||||
end,
|
end,
|
||||||
onInflict = function(battle, target, opts, display)
|
onInflict = function(battle, target, opts, display)
|
||||||
target.sleepTurns = battle.rng(1, 7)
|
target.sleepTurns = battle.rng(1, 7)
|
||||||
return { ("%s\nfell asleep!"):format(display) }
|
return { Strings("%s\nfell asleep!", display) }
|
||||||
end,
|
end,
|
||||||
},
|
},
|
||||||
FRZ = {
|
FRZ = {
|
||||||
@@ -71,35 +81,35 @@ Status.RECORDS = {
|
|||||||
catchBonus = 25, shakeBonus = 10,
|
catchBonus = 25, shakeBonus = 10,
|
||||||
beforeMovePriority = 30,
|
beforeMovePriority = 30,
|
||||||
beforeMove = function(battler)
|
beforeMove = function(battler)
|
||||||
return false, { name(battler) .. "\nis frozen solid!" }
|
return false, { Strings("%s\nis frozen solid!", name(battler)) }
|
||||||
end,
|
end,
|
||||||
canInflict = function(target) return not hasType(target, "ICE") end,
|
canInflict = function(target) return not hasType(target, "ICE") end,
|
||||||
onInflict = function(_, _, _, display)
|
onInflict = function(_, _, _, display)
|
||||||
return { ("%s\nwas frozen solid!"):format(display) }
|
return { Strings("%s\nwas frozen solid!", display) }
|
||||||
end,
|
end,
|
||||||
},
|
},
|
||||||
PSN = {
|
PSN = {
|
||||||
id = "PSN", label = "PSN", hudLabel = "PSN",
|
id = "PSN", label = "PSN", hudLabel = "PSN",
|
||||||
catchBonus = 12, shakeBonus = 5,
|
catchBonus = 12, shakeBonus = 5,
|
||||||
residual = damageOverTime("poison"),
|
residual = damageOverTime(Strings.source("%s's\nhurt by poison!")),
|
||||||
canInflict = function(target) return not hasType(target, "POISON") end,
|
canInflict = function(target) return not hasType(target, "POISON") end,
|
||||||
onInflict = function(_, target, opts, display)
|
onInflict = function(_, target, opts, display)
|
||||||
if opts.toxic then
|
if opts.toxic then
|
||||||
target.toxicCounter = 1
|
target.toxicCounter = 1
|
||||||
-- _BadlyPoisonedText
|
-- _BadlyPoisonedText
|
||||||
return { ("%s's\nbadly poisoned!"):format(display) }
|
return { Strings("%s's\nbadly poisoned!", display) }
|
||||||
end
|
end
|
||||||
return { ("%s\nwas poisoned!"):format(display) }
|
return { Strings("%s\nwas poisoned!", display) }
|
||||||
end,
|
end,
|
||||||
},
|
},
|
||||||
BRN = {
|
BRN = {
|
||||||
id = "BRN", label = "BRN", hudLabel = "BRN",
|
id = "BRN", label = "BRN", hudLabel = "BRN",
|
||||||
catchBonus = 12, shakeBonus = 5,
|
catchBonus = 12, shakeBonus = 5,
|
||||||
statPenalty = { stat = "attack", div = 2 },
|
statPenalty = { stat = "attack", div = 2 },
|
||||||
residual = damageOverTime("the burn"),
|
residual = damageOverTime(Strings.source("%s's\nhurt by the burn!")),
|
||||||
canInflict = function(target) return not hasType(target, "FIRE") end,
|
canInflict = function(target) return not hasType(target, "FIRE") end,
|
||||||
onInflict = function(_, _, _, display)
|
onInflict = function(_, _, _, display)
|
||||||
return { ("%s\nwas burned!"):format(display) }
|
return { Strings("%s\nwas burned!", display) }
|
||||||
end,
|
end,
|
||||||
},
|
},
|
||||||
PAR = {
|
PAR = {
|
||||||
@@ -110,7 +120,7 @@ Status.RECORDS = {
|
|||||||
beforeMove = function(battler, rng)
|
beforeMove = function(battler, rng)
|
||||||
-- cp 25 percent / jr nc: fully paralyzed on rand < 63 (63/256)
|
-- cp 25 percent / jr nc: fully paralyzed on rand < 63 (63/256)
|
||||||
if rng(0, 255) < 63 then
|
if rng(0, 255) < 63 then
|
||||||
return false, { name(battler) .. "'s\nfully paralyzed!" }
|
return false, { Strings("%s's\nfully paralyzed!", name(battler)) }
|
||||||
end
|
end
|
||||||
return true, {}
|
return true, {}
|
||||||
end,
|
end,
|
||||||
@@ -120,7 +130,7 @@ Status.RECORDS = {
|
|||||||
end,
|
end,
|
||||||
onInflict = function(_, _, _, display)
|
onInflict = function(_, _, _, display)
|
||||||
-- _ParalyzedMayNotAttackText (primary and secondary paralysis)
|
-- _ParalyzedMayNotAttackText (primary and secondary paralysis)
|
||||||
return { ("%s's\nparalyzed! It may\nnot attack!"):format(display) }
|
return { Strings("%s's\nparalyzed! It may\nnot attack!", display) }
|
||||||
end,
|
end,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
@@ -156,7 +166,7 @@ function Status.beforeMove(battler, rng, battle)
|
|||||||
end
|
end
|
||||||
if battler.flinched then
|
if battler.flinched then
|
||||||
battler.flinched = false
|
battler.flinched = false
|
||||||
return false, { name(battler) .. "\nflinched!" }
|
return false, { Strings("%s\nflinched!", name(battler)) }
|
||||||
end
|
end
|
||||||
local record = Status.recordFor(battleStatuses(battle), mon.status)
|
local record = Status.recordFor(battleStatuses(battle), mon.status)
|
||||||
local handler = record and record.beforeMove
|
local handler = record and record.beforeMove
|
||||||
@@ -174,23 +184,23 @@ function Status.beforeMove(battler, rng, battle)
|
|||||||
end
|
end
|
||||||
if battler.boundTurns and battler.boundTurns > 0 then
|
if battler.boundTurns and battler.boundTurns > 0 then
|
||||||
battler.boundTurns = battler.boundTurns - 1
|
battler.boundTurns = battler.boundTurns - 1
|
||||||
msgs[#msgs + 1] = name(battler) .. "\ncan't move!"
|
msgs[#msgs + 1] = Strings("%s\ncan't move!", name(battler))
|
||||||
return false, msgs
|
return false, msgs
|
||||||
end
|
end
|
||||||
if battler.disabledTurns then
|
if battler.disabledTurns then
|
||||||
battler.disabledTurns = battler.disabledTurns - 1
|
battler.disabledTurns = battler.disabledTurns - 1
|
||||||
if battler.disabledTurns <= 0 then
|
if battler.disabledTurns <= 0 then
|
||||||
battler.disabledTurns, battler.disabledSlot = nil, nil
|
battler.disabledTurns, battler.disabledSlot = nil, nil
|
||||||
table.insert(msgs, name(battler) .. "'s\ndisabled no more!")
|
table.insert(msgs, Strings("%s's\ndisabled no more!", name(battler)))
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
if battler.confusedTurns then
|
if battler.confusedTurns then
|
||||||
battler.confusedTurns = battler.confusedTurns - 1
|
battler.confusedTurns = battler.confusedTurns - 1
|
||||||
if battler.confusedTurns <= 0 then
|
if battler.confusedTurns <= 0 then
|
||||||
battler.confusedTurns = nil
|
battler.confusedTurns = nil
|
||||||
table.insert(msgs, name(battler) .. "\nsnapped out of\nconfusion!")
|
table.insert(msgs, Strings("%s\nsnapped out of\nconfusion!", name(battler)))
|
||||||
else
|
else
|
||||||
table.insert(msgs, name(battler) .. "\nis confused!")
|
table.insert(msgs, Strings("%s\nis confused!", name(battler)))
|
||||||
-- cp 50 percent + 1 / jr c: hurt itself on rand >= 128 (128/256)
|
-- cp 50 percent + 1 / jr c: hurt itself on rand >= 128 (128/256)
|
||||||
if rng(0, 255) < 128 then
|
if rng(0, 255) < 128 then
|
||||||
return false, msgs, true -- hurt itself
|
return false, msgs, true -- hurt itself
|
||||||
@@ -231,7 +241,7 @@ function Status.residual(battler, opponent, battle)
|
|||||||
dmg = math.min(dmg, mon.hp)
|
dmg = math.min(dmg, mon.hp)
|
||||||
mon.hp = mon.hp - dmg
|
mon.hp = mon.hp - dmg
|
||||||
opponent.mon.hp = math.min(opponent.mon.stats.hp, opponent.mon.hp + dmg)
|
opponent.mon.hp = math.min(opponent.mon.stats.hp, opponent.mon.hp + dmg)
|
||||||
table.insert(msgs, ("LEECH SEED saps\n%s!"):format(name(battler)))
|
table.insert(msgs, Strings("LEECH SEED saps\n%s!", name(battler)))
|
||||||
end
|
end
|
||||||
return msgs
|
return msgs
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -5,6 +5,7 @@
|
|||||||
|
|
||||||
local Runtime = require("src.mods.Runtime")
|
local Runtime = require("src.mods.Runtime")
|
||||||
local Status = require("src.battle.Status")
|
local Status = require("src.battle.Status")
|
||||||
|
local Strings = require("src.core.Strings")
|
||||||
|
|
||||||
local StatusRegistry = {}
|
local StatusRegistry = {}
|
||||||
|
|
||||||
@@ -45,7 +46,7 @@ function StatusRegistry.inflict(battle, target, status, opts)
|
|||||||
if record and record.onInflict then
|
if record and record.onInflict then
|
||||||
msgs = record.onInflict(battle, target, opts, display)
|
msgs = record.onInflict(battle, target, opts, display)
|
||||||
else
|
else
|
||||||
msgs = { ("%s\nwas afflicted\nby %s!"):format(display,
|
msgs = { Strings("%s\nwas afflicted\nby %s!", display,
|
||||||
record and record.label or tostring(status)) }
|
record and record.label or tostring(status)) }
|
||||||
end
|
end
|
||||||
Runtime.emit("battle.status_inflicted", {
|
Runtime.emit("battle.status_inflicted", {
|
||||||
|
|||||||
@@ -19,6 +19,7 @@
|
|||||||
-- enemy PP — Gen 1 AI never reads wEnemyMonPP).
|
-- enemy PP — Gen 1 AI never reads wEnemyMonPP).
|
||||||
|
|
||||||
local TypeChart = require("src.battle.TypeChart")
|
local TypeChart = require("src.battle.TypeChart")
|
||||||
|
local Strings = require("src.core.Strings")
|
||||||
|
|
||||||
local TrainerAI = {}
|
local TrainerAI = {}
|
||||||
|
|
||||||
@@ -99,7 +100,7 @@ function TrainerAI.useItem(battle, item)
|
|||||||
local enemy = battle.enemy
|
local enemy = battle.enemy
|
||||||
local trainerName = battle.trainer.name
|
local trainerName = battle.trainer.name
|
||||||
local itemName = battle.data.items[item] and battle.data.items[item].name or item
|
local itemName = battle.data.items[item] and battle.data.items[item].name or item
|
||||||
local msgs = { ("%s\nused %s!"):format(trainerName, itemName) }
|
local msgs = { Strings("%s\nused %s!", trainerName, itemName) }
|
||||||
if item == "FULL_HEAL" then
|
if item == "FULL_HEAL" then
|
||||||
enemy.mon.status = nil
|
enemy.mon.status = nil
|
||||||
enemy.toxicCounter = nil
|
enemy.toxicCounter = nil
|
||||||
@@ -112,10 +113,10 @@ function TrainerAI.useItem(battle, item)
|
|||||||
elseif X_STAT[item] then
|
elseif X_STAT[item] then
|
||||||
local stat = X_STAT[item]
|
local stat = X_STAT[item]
|
||||||
enemy.stages[stat] = math.min(6, (enemy.stages[stat] or 0) + 1)
|
enemy.stages[stat] = math.min(6, (enemy.stages[stat] or 0) + 1)
|
||||||
table.insert(msgs, ("%s's\n%s rose!"):format(enemy.name, stat:upper()))
|
table.insert(msgs, Strings("%s's\n%s rose!", enemy.name, stat:upper()))
|
||||||
elseif item == "GUARD_SPEC" then
|
elseif item == "GUARD_SPEC" then
|
||||||
enemy.mist = true
|
enemy.mist = true
|
||||||
table.insert(msgs, ("%s's\nprotected against\nstat changes!"):format(enemy.name))
|
table.insert(msgs, Strings("%s's\nprotected against\nstat changes!", enemy.name))
|
||||||
end
|
end
|
||||||
return msgs
|
return msgs
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -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,10 +102,41 @@ 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)
|
-- #189: 1F cabin door order vs rooms map (survey zoom)
|
||||||
require("src.world.SsAnneLayout").apply(self.maps)
|
require("src.world.SsAnneLayout").apply(self.maps)
|
||||||
end
|
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
|
||||||
|
|
||||||
function Data:seedCinnabarGymTrainerHeaders()
|
function Data:seedCinnabarGymTrainerHeaders()
|
||||||
local headers = self.trainer_headers
|
local headers = self.trainer_headers
|
||||||
if not headers or headers.CinnabarGym then return end
|
if not headers or headers.CinnabarGym then return end
|
||||||
|
|||||||
@@ -9,6 +9,7 @@
|
|||||||
local Json = require("src.link.Json")
|
local Json = require("src.link.Json")
|
||||||
local Logger = require("src.core.Logger")
|
local Logger = require("src.core.Logger")
|
||||||
local Runtime = require("src.mods.Runtime")
|
local Runtime = require("src.mods.Runtime")
|
||||||
|
local Strings = require("src.core.Strings")
|
||||||
|
|
||||||
local DiscordPresence = {
|
local DiscordPresence = {
|
||||||
APP_ID = "1529183141267374262",
|
APP_ID = "1529183141267374262",
|
||||||
@@ -436,7 +437,7 @@ end
|
|||||||
local function buildActivity()
|
local function buildActivity()
|
||||||
local details, activityState
|
local details, activityState
|
||||||
if state.activity == "battle" then
|
if state.activity == "battle" then
|
||||||
details = state.battleLabel or "In battle"
|
details = state.battleLabel or Strings("In battle")
|
||||||
activityState = state.location and (state.location) or "Kanto"
|
activityState = state.location and (state.location) or "Kanto"
|
||||||
elseif state.activity == "exploring" and state.location then
|
elseif state.activity == "exploring" and state.location then
|
||||||
details = state.location
|
details = state.location
|
||||||
@@ -550,20 +551,20 @@ local function subscribe(game)
|
|||||||
|
|
||||||
track("battle.started", function(ev)
|
track("battle.started", function(ev)
|
||||||
local battle = ev and ev.battle
|
local battle = ev and ev.battle
|
||||||
local label = "In battle"
|
local label = Strings("In battle")
|
||||||
if ev and ev.kind == "wild" then
|
if ev and ev.kind == "wild" then
|
||||||
local name = speciesName(game, ev.species)
|
local name = speciesName(game, ev.species)
|
||||||
if name then
|
if name then
|
||||||
label = "Battling wild " .. name
|
label = "Battling wild " .. name
|
||||||
if ev.level then label = label .. " Lv" .. tostring(ev.level) end
|
if ev.level then label = label .. " Lv" .. tostring(ev.level) end
|
||||||
else
|
else
|
||||||
label = "Wild battle"
|
label = Strings("Wild battle")
|
||||||
end
|
end
|
||||||
elseif ev and ev.kind == "trainer" then
|
elseif ev and ev.kind == "trainer" then
|
||||||
local tname = battle and battle.trainer and battle.trainer.name
|
local tname = battle and battle.trainer and battle.trainer.name
|
||||||
label = tname and ("Battling " .. tname) or "Trainer battle"
|
label = tname and ("Battling " .. tname) or Strings("Trainer battle")
|
||||||
elseif ev and ev.kind == "link" then
|
elseif ev and ev.kind == "link" then
|
||||||
label = "Link battle"
|
label = Strings("Link battle")
|
||||||
end
|
end
|
||||||
local mapId = state.mapId
|
local mapId = state.mapId
|
||||||
or (game.save and game.save.player and game.save.player.map)
|
or (game.save and game.save.player and game.save.player.map)
|
||||||
@@ -594,7 +595,7 @@ local function subscribe(game)
|
|||||||
state.mapId = nil
|
state.mapId = nil
|
||||||
setPresence({
|
setPresence({
|
||||||
activity = "menu",
|
activity = "menu",
|
||||||
location = id == "IntroMovie" and "Watching the intro" or "Title screen",
|
location = id == "IntroMovie" and "Watching the intro" or Strings("Title screen"),
|
||||||
clearBattle = true,
|
clearBattle = true,
|
||||||
})
|
})
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -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")
|
||||||
@@ -44,8 +44,8 @@ function Game:load()
|
|||||||
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()
|
||||||
@@ -53,6 +53,9 @@ function Game:load()
|
|||||||
require("src.render.Font").load(Data)
|
require("src.render.Font").load(Data)
|
||||||
-- menu cursor/border/geometry constants; field.theme restyles them
|
-- menu cursor/border/geometry constants; field.theme restyles them
|
||||||
require("src.ui.Theme").load(Data)
|
require("src.ui.Theme").load(Data)
|
||||||
|
-- the engine's own text, after the merge so a translation mod's catalog
|
||||||
|
-- is already in Data.strings; empty on a mod-free boot and skipped
|
||||||
|
require("src.core.Strings").load(Data)
|
||||||
|
|
||||||
self.stack = StateStack
|
self.stack = StateStack
|
||||||
StateStack:init()
|
StateStack:init()
|
||||||
@@ -96,6 +99,20 @@ function Game:load()
|
|||||||
end
|
end
|
||||||
|
|
||||||
Logger.info("game loaded")
|
Logger.info("game loaded")
|
||||||
|
-- Scaling bug reports (#87, #208) are unanswerable without these three
|
||||||
|
-- numbers: LOVE units, drawable pixels, and the integer physical pixels
|
||||||
|
-- per GB pixel the renderer settled on. Cheap, once, and it turns "it
|
||||||
|
-- looks stretched" into something reproducible.
|
||||||
|
if love.graphics and love.graphics.getDimensions then
|
||||||
|
local ww, wh = love.graphics.getDimensions()
|
||||||
|
local pw, ph = ww, wh
|
||||||
|
if love.graphics.getPixelDimensions then
|
||||||
|
pw, ph = love.graphics.getPixelDimensions()
|
||||||
|
end
|
||||||
|
Logger.info(string.format(
|
||||||
|
"display: %dx%d units, %dx%d px, fit scale %d px/GB px",
|
||||||
|
ww, wh, pw, ph, Renderer:fitScale()))
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
-- the merged field.boot: spawn, names, money and the naming presets a
|
-- the merged field.boot: spawn, names, money and the naming presets a
|
||||||
@@ -180,9 +197,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.
|
||||||
@@ -246,6 +260,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
|
||||||
@@ -375,6 +391,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
|
||||||
@@ -389,6 +408,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
|
||||||
|
|
||||||
@@ -398,12 +419,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
|
||||||
@@ -411,19 +432,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
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -552,6 +552,44 @@ function SaveData.writeSlot(version, slotId, saveTable)
|
|||||||
return true
|
return true
|
||||||
end
|
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
|
-- Test seam: drop the process-global slot cache so a suite can exercise
|
||||||
-- migration/resolution against a freshly injected filesystem. Unused by
|
-- migration/resolution against a freshly injected filesystem. Unused by
|
||||||
-- the game, which resolves each version exactly once per boot.
|
-- the game, which resolves each version exactly once per boot.
|
||||||
@@ -1117,9 +1155,16 @@ function SaveData.defaultHeal(boot)
|
|||||||
end
|
end
|
||||||
|
|
||||||
-- Post-credits home (issue #103). pokered left the player in HALL_OF_FAME
|
-- Post-credits home (issue #103). pokered left the player in HALL_OF_FAME
|
||||||
-- after jp Init; this port places CONTINUE at the NewGameWarp bedroom and
|
-- after jp Init; this port relocates CONTINUE instead, and retargets
|
||||||
-- retargets LAST_MAP exits (Red's house mats) at the heal-point town so
|
-- LAST_MAP exits (Red's house mats) at the heal-point town so leaving the
|
||||||
-- leaving the house does not dump the player back at Indigo Plateau.
|
-- house does not dump the player back at Indigo Plateau.
|
||||||
|
--
|
||||||
|
-- The landing spot is the blackout point, not the NewGameWarp bedroom:
|
||||||
|
-- HallOfFameResetEventsAndSaveScript sets wLastBlackoutMap := PALLET_TOWN,
|
||||||
|
-- so resuming puts the player outside the front door. Spawning them back
|
||||||
|
-- in the upstairs bedroom was a port-only detour (#253). Non-vanilla
|
||||||
|
-- spawns are unaffected -- defaultHeal returns boot's own start map for
|
||||||
|
-- those, which is what the bedroom branch resolved to anyway.
|
||||||
-- Marks postGameHomeOk so a later intentional HoF save is not relocated.
|
-- Marks postGameHomeOk so a later intentional HoF save is not relocated.
|
||||||
function SaveData.applyPostGameHome(save, boot)
|
function SaveData.applyPostGameHome(save, boot)
|
||||||
boot = type(boot) == "table" and boot or {}
|
boot = type(boot) == "table" and boot or {}
|
||||||
@@ -1127,9 +1172,9 @@ function SaveData.applyPostGameHome(save, boot)
|
|||||||
save.lastHeal = { map = heal.map, x = heal.x, y = heal.y }
|
save.lastHeal = { map = heal.map, x = heal.x, y = heal.y }
|
||||||
save.lastOutdoor = { id = heal.map, x = heal.x, y = heal.y }
|
save.lastOutdoor = { id = heal.map, x = heal.x, y = heal.y }
|
||||||
save.player = save.player or {}
|
save.player = save.player or {}
|
||||||
save.player.map = boot.startMap or "REDS_HOUSE_2F"
|
save.player.map = heal.map
|
||||||
save.player.x = boot.startX or 3
|
save.player.x = heal.x
|
||||||
save.player.y = boot.startY or 6
|
save.player.y = heal.y
|
||||||
save.player.facing = boot.startFacing or "down"
|
save.player.facing = boot.startFacing or "down"
|
||||||
save.postGameHomeOk = true
|
save.postGameHomeOk = true
|
||||||
return heal
|
return heal
|
||||||
|
|||||||
@@ -0,0 +1,134 @@
|
|||||||
|
-- The engine's own player-facing text, made overridable.
|
||||||
|
--
|
||||||
|
-- Extracted dialogue already had a home: `Data.text`, keyed by pokered's
|
||||||
|
-- labels, which a mod reaches through `mod.content.text`. The strings the
|
||||||
|
-- engine *authors* had none. Battle messages, item results, menu labels
|
||||||
|
-- and the link-play screens were literals in Lua, so a translator could
|
||||||
|
-- reach two thirds of the game and no more (#186, #245).
|
||||||
|
--
|
||||||
|
-- Those literals stay where they are, wrapped in `S(...)`, and the English
|
||||||
|
-- source doubles as the catalog key:
|
||||||
|
--
|
||||||
|
-- self:say(S("But, it failed!"))
|
||||||
|
-- self:say(S("Wild %s\nappeared!", self.enemy.name))
|
||||||
|
--
|
||||||
|
-- Keying on the source rather than on an invented id is deliberate. It
|
||||||
|
-- keeps the English readable at the point it is used (the alternative
|
||||||
|
-- scatters a thousand `battle.it_failed` ids that have to be looked up to
|
||||||
|
-- review a diff), it needs no id registry to stay in sync, and an entry a
|
||||||
|
-- translation has not covered yet falls through to English instead of
|
||||||
|
-- rendering a raw id at the player. The cost is that editing an English
|
||||||
|
-- string orphans its translations; `tools/modkit.py translation --refresh`
|
||||||
|
-- reports those as changed keys rather than silently dropping them.
|
||||||
|
--
|
||||||
|
-- Same-source-different-meaning is the one case source keys cannot hold on
|
||||||
|
-- their own ("OFF" as a filter setting vs "OFF" as a toggle, which some
|
||||||
|
-- languages render differently). Those sites pass a context:
|
||||||
|
--
|
||||||
|
-- S("OFF", "options.musicFilter") -- key: "options.musicFilter|OFF"
|
||||||
|
--
|
||||||
|
-- A mod supplies the catalog through the `strings` registry:
|
||||||
|
--
|
||||||
|
-- mod.content.strings:override("But, it failed!", "Echec !")
|
||||||
|
--
|
||||||
|
-- With no mod loaded the catalog is empty and `S` is an identity function
|
||||||
|
-- guarded by one boolean, so a vanilla boot draws byte-identical text.
|
||||||
|
|
||||||
|
local Strings = {}
|
||||||
|
|
||||||
|
local catalog = nil -- Data.strings once a mod has put something in it
|
||||||
|
local missing = {} -- format-arity complaints, reported once each
|
||||||
|
|
||||||
|
-- Called from Game after the mod merge, and again on dev-mode hot reload.
|
||||||
|
-- Holding the table (not a copy) means a mod that registers late still
|
||||||
|
-- takes effect without a second load.
|
||||||
|
function Strings.load(data)
|
||||||
|
local t = data and data.strings
|
||||||
|
catalog = nil
|
||||||
|
if type(t) ~= "table" then return end
|
||||||
|
for _ in pairs(t) do
|
||||||
|
catalog = t
|
||||||
|
return
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
function Strings.active()
|
||||||
|
return catalog ~= nil
|
||||||
|
end
|
||||||
|
|
||||||
|
-- The lookup itself. `context` is optional and only disambiguates sources
|
||||||
|
-- that collide; the plain key is tried after it, so a translation that does
|
||||||
|
-- not care about the distinction can supply one entry for both.
|
||||||
|
function Strings.lookup(source, context)
|
||||||
|
if not catalog then return source end
|
||||||
|
if context then
|
||||||
|
local hit = catalog[context .. "|" .. source]
|
||||||
|
if type(hit) == "string" then return hit end
|
||||||
|
end
|
||||||
|
local hit = catalog[source]
|
||||||
|
if type(hit) == "string" then return hit end
|
||||||
|
return source
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Count `%`-directives so a translation that drops or adds one is caught
|
||||||
|
-- here rather than as a mid-battle `string.format` error.
|
||||||
|
local function specifiers(s)
|
||||||
|
local n = 0
|
||||||
|
for spec in s:gmatch("%%(.)") do
|
||||||
|
if spec ~= "%" then n = n + 1 end
|
||||||
|
end
|
||||||
|
return n
|
||||||
|
end
|
||||||
|
|
||||||
|
-- S(source) -> translated source
|
||||||
|
-- S(source, ...) -> translated source, string.format'ed
|
||||||
|
-- S(source, context) -> context-disambiguated lookup, no formatting
|
||||||
|
--
|
||||||
|
-- The two-argument forms are told apart by whether the source carries any
|
||||||
|
-- format directives: a source with no `%s` cannot be formatting, so a lone
|
||||||
|
-- string second argument is a context.
|
||||||
|
function Strings.get(source, ...)
|
||||||
|
local argc = select("#", ...)
|
||||||
|
if argc == 0 then return Strings.lookup(source) end
|
||||||
|
|
||||||
|
local wants = specifiers(source)
|
||||||
|
if wants == 0 and argc == 1 and type((...)) == "string" then
|
||||||
|
return Strings.lookup(source, (...))
|
||||||
|
end
|
||||||
|
|
||||||
|
local text = Strings.lookup(source)
|
||||||
|
-- A translation with the wrong arity would raise inside string.format,
|
||||||
|
-- which in a battle means a crash the player cannot escape. Fall back to
|
||||||
|
-- the English source, which is known to match, and say so once.
|
||||||
|
if specifiers(text) ~= wants then
|
||||||
|
if not missing[source] then
|
||||||
|
missing[source] = true
|
||||||
|
require("src.core.Logger").warn(
|
||||||
|
"strings: translation of %q has %d format directives, source has %d"
|
||||||
|
.. " -- using the source", source, specifiers(text), wants)
|
||||||
|
end
|
||||||
|
text = source
|
||||||
|
end
|
||||||
|
local ok, out = pcall(string.format, text, ...)
|
||||||
|
if not ok then return source end
|
||||||
|
return out
|
||||||
|
end
|
||||||
|
|
||||||
|
-- A marker, not a lookup: returns its argument untouched.
|
||||||
|
--
|
||||||
|
-- Some templates are declared in a module-level table and formatted much
|
||||||
|
-- later (BattleState's charge-move lines, for one). Translating at the
|
||||||
|
-- declaration would freeze the English, because those tables are built at
|
||||||
|
-- require time and Strings.load has no catalog yet; the use site therefore
|
||||||
|
-- calls Strings(template, ...) and looks the source up then. That works at
|
||||||
|
-- runtime but leaves the literal invisible to the catalog generator, which
|
||||||
|
-- only sees what is spelled out at a call site. Wrapping the declaration in
|
||||||
|
-- Strings.source puts it back in the harvest while changing nothing at all
|
||||||
|
-- about when the lookup happens.
|
||||||
|
function Strings.source(text)
|
||||||
|
return text
|
||||||
|
end
|
||||||
|
|
||||||
|
setmetatable(Strings, { __call = function(_, ...) return Strings.get(...) end })
|
||||||
|
|
||||||
|
return Strings
|
||||||
@@ -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
|
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
local GameVersion = require("src.core.GameVersion")
|
local GameVersion = require("src.core.GameVersion")
|
||||||
|
local Strings = require("src.core.Strings")
|
||||||
|
|
||||||
local RomImporter = {}
|
local RomImporter = {}
|
||||||
RomImporter.__index = RomImporter
|
RomImporter.__index = RomImporter
|
||||||
@@ -290,6 +291,39 @@ local function findPendingRom(ready)
|
|||||||
return nil
|
return nil
|
||||||
end
|
end
|
||||||
|
|
||||||
|
-- Android SAF writes mod picks to picked_mod.zip; USB copies may use any
|
||||||
|
-- .zip basename at the save-dir root. preferAny=true also accepts those USB
|
||||||
|
-- copies (Choose / Import); focus only consumes the SAF basename so a random
|
||||||
|
-- leftover archive is never auto-installed on every refocus.
|
||||||
|
local function findPendingMod(preferAny)
|
||||||
|
local preferred = "picked_mod.zip"
|
||||||
|
if love.filesystem.getInfo(preferred, "file") then
|
||||||
|
return preferred
|
||||||
|
end
|
||||||
|
if not preferAny then return nil end
|
||||||
|
for _, name in ipairs(love.filesystem.getDirectoryItems("")) do
|
||||||
|
if name:lower():match("%.zip$") and love.filesystem.getInfo(name, "file") then
|
||||||
|
return name
|
||||||
|
end
|
||||||
|
end
|
||||||
|
return nil
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Same pattern as findPendingMod for battery saves (picked_save.sav / *.sav).
|
||||||
|
local function findPendingSav(preferAny)
|
||||||
|
local preferred = "picked_save.sav"
|
||||||
|
if love.filesystem.getInfo(preferred, "file") then
|
||||||
|
return preferred
|
||||||
|
end
|
||||||
|
if not preferAny then return nil end
|
||||||
|
for _, name in ipairs(love.filesystem.getDirectoryItems("")) do
|
||||||
|
if name:lower():match("%.sav$") and love.filesystem.getInfo(name, "file") then
|
||||||
|
return name
|
||||||
|
end
|
||||||
|
end
|
||||||
|
return nil
|
||||||
|
end
|
||||||
|
|
||||||
local function chooseRom(promptName)
|
local function chooseRom(promptName)
|
||||||
promptName = promptName or "Pokemon"
|
promptName = promptName or "Pokemon"
|
||||||
local prompt = "Choose your " .. promptName .. " ROM"
|
local prompt = "Choose your " .. promptName .. " ROM"
|
||||||
@@ -320,10 +354,10 @@ local function chooseRom(promptName)
|
|||||||
end
|
end
|
||||||
|
|
||||||
-- Open a native picker for a mod .zip (mirrors chooseRom's per-OS dialogs).
|
-- Open a native picker for a mod .zip (mirrors chooseRom's per-OS dialogs).
|
||||||
-- Returns the chosen absolute path or nil. Android has no picker; the mods
|
-- Returns the chosen absolute path or nil. Android uses love.system.pickFile
|
||||||
-- panel steers that case to the drag-drop hint instead.
|
-- ("mod") instead -- see RomImporter:chooseMod.
|
||||||
local function chooseZip()
|
local function chooseZip()
|
||||||
local prompt = "Choose a mod .zip"
|
local prompt = Strings("Choose a mod .zip")
|
||||||
local platform = love.system.getOS()
|
local platform = love.system.getOS()
|
||||||
if platform == "OS X" then
|
if platform == "OS X" then
|
||||||
return commandOutput(
|
return commandOutput(
|
||||||
@@ -351,10 +385,10 @@ local function chooseZip()
|
|||||||
end
|
end
|
||||||
|
|
||||||
-- Open a native picker for a raw .sav battery save (mirrors chooseZip's per-OS
|
-- Open a native picker for a raw .sav battery save (mirrors chooseZip's per-OS
|
||||||
-- dialogs). Returns the chosen absolute path or nil. Android has no picker;
|
-- dialogs). Returns the chosen absolute path or nil. Android uses
|
||||||
-- the SAVE FILES card steers that case to the drag-drop hint instead.
|
-- love.system.pickFile("sav") instead -- see RomImporter:chooseSaveImport.
|
||||||
local function chooseSav()
|
local function chooseSav()
|
||||||
local prompt = "Choose a .sav save file"
|
local prompt = Strings("Choose a .sav save file")
|
||||||
local platform = love.system.getOS()
|
local platform = love.system.getOS()
|
||||||
if platform == "OS X" then
|
if platform == "OS X" then
|
||||||
return commandOutput(
|
return commandOutput(
|
||||||
@@ -429,10 +463,16 @@ function RomImporter.new(onComplete, opts)
|
|||||||
-- affordance (desktop love.system.openURL).
|
-- affordance (desktop love.system.openURL).
|
||||||
saveNotice = {},
|
saveNotice = {},
|
||||||
-- MODS panel state (pass 3): mods is the cached LauncherMods.list() array
|
-- MODS panel state (pass 3): mods is the cached LauncherMods.list() array
|
||||||
-- (refreshed lazily on first draw and after any toggle/install); modScroll
|
-- (refreshed lazily on first draw and after any toggle/install/delete);
|
||||||
-- is the list scroll offset (px, clamped in draw); modNotice is the last
|
-- modScroll is the list scroll offset (px, clamped in draw); modNotice is
|
||||||
-- install result { ok, text } shown as a line above the list.
|
-- the last install/delete result { ok, text } shown as a line above the list.
|
||||||
mods = nil, modScroll = 0, modNotice = nil,
|
mods = nil, modScroll = 0, modNotice = nil,
|
||||||
|
-- Android SAF: which game tab should receive the next picked_save.sav when
|
||||||
|
-- focus consumes it (set by chooseSaveImport before opening the picker).
|
||||||
|
androidPendingVersion = nil,
|
||||||
|
-- Android SAF create-document: which game's SAVE FILES card should show
|
||||||
|
-- "Save exported." when export_done.flag appears on focus.
|
||||||
|
androidPendingExportVersion = nil,
|
||||||
}, RomImporter)
|
}, RomImporter)
|
||||||
|
|
||||||
for _, version in ipairs(GameVersion.ORDER) do
|
for _, version in ipairs(GameVersion.ORDER) do
|
||||||
@@ -491,10 +531,40 @@ end
|
|||||||
-- The system picker runs as a separate top activity, so LOVE's own
|
-- The system picker runs as a separate top activity, so LOVE's own
|
||||||
-- love.focus/love.visible pause while it's up (see main.lua) -- once the
|
-- love.focus/love.visible pause while it's up (see main.lua) -- once the
|
||||||
-- player returns here with a file picked, GameActivity has already copied
|
-- player returns here with a file picked, GameActivity has already copied
|
||||||
-- it into the save directory, so a pending-ROM rescan on refocus picks it
|
-- it into the save directory, so a pending-file rescan on refocus picks it
|
||||||
-- up without the player needing to tap the button again.
|
-- up without the player needing to tap the button again. Mod and save SAF
|
||||||
|
-- drops (picked_mod.zip / picked_save.sav) are consumed first so a leftover
|
||||||
|
-- ROM pick cannot steal the focus path when both games are already ready.
|
||||||
function RomImporter:focus(f)
|
function RomImporter:focus(f)
|
||||||
if not (f and self.android and self.workState ~= "working") then return end
|
if not (f and self.android and self.workState ~= "working") then return end
|
||||||
|
-- SAF create-document finished: GameActivity wrote export_done.flag.
|
||||||
|
if love.filesystem.getInfo("export_done.flag", "file") then
|
||||||
|
love.filesystem.remove("export_done.flag")
|
||||||
|
love.filesystem.remove("pending_export.sav")
|
||||||
|
local version = self.androidPendingExportVersion or self:_savedropTarget()
|
||||||
|
self.androidPendingExportVersion = nil
|
||||||
|
self.saveNotice[version] = { ok = true, text = "Save exported." }
|
||||||
|
if self.tab == "mods" or self.tab == "yellow" then self.tab = version end
|
||||||
|
return
|
||||||
|
end
|
||||||
|
local modName = findPendingMod(false)
|
||||||
|
if modName then
|
||||||
|
self:_installMod(modName)
|
||||||
|
if self.modNotice and self.modNotice.ok then
|
||||||
|
love.filesystem.remove(modName)
|
||||||
|
end
|
||||||
|
return
|
||||||
|
end
|
||||||
|
local savName = findPendingSav(false)
|
||||||
|
if savName then
|
||||||
|
local version = self.androidPendingVersion or self:_savedropTarget()
|
||||||
|
self.androidPendingVersion = nil
|
||||||
|
self:_importSave(version, savName)
|
||||||
|
if self.saveNotice[version] and self.saveNotice[version].ok then
|
||||||
|
love.filesystem.remove(savName)
|
||||||
|
end
|
||||||
|
return
|
||||||
|
end
|
||||||
if self.ready.red and self.ready.blue then return end
|
if self.ready.red and self.ready.blue then return end
|
||||||
local name, data = findPendingRom(self.ready)
|
local name, data = findPendingRom(self.ready)
|
||||||
if name then self:startData(data, name) end
|
if name then self:startData(data, name) end
|
||||||
@@ -672,13 +742,38 @@ function RomImporter:_installMod(source)
|
|||||||
self.tab = "mods"
|
self.tab = "mods"
|
||||||
end
|
end
|
||||||
|
|
||||||
-- "Import mod .zip" button: open a native picker (desktop) and install the
|
-- Remove an installed mod from the save-dir mods/ tree and refresh the panel.
|
||||||
-- pick. Android has no picker, so it points the player at the drop path.
|
function RomImporter:_deleteMod(id)
|
||||||
|
if self.workState == "working" then return end
|
||||||
|
local LauncherMods = require("src.mods.LauncherMods")
|
||||||
|
local ok, res = LauncherMods.uninstall(id)
|
||||||
|
if ok then
|
||||||
|
self:_refreshMods()
|
||||||
|
self.modNotice = { ok = true, text = "Deleted " .. tostring(id) }
|
||||||
|
else
|
||||||
|
self.modNotice = { ok = false, text = tostring(res) }
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
-- "Import mod .zip" button: open a native picker and install the pick.
|
||||||
|
-- Android mirrors ROM import: scan for a pending .zip in the save dir (USB
|
||||||
|
-- or a fresh SAF drop), else love.system.pickFile("mod") -> picked_mod.zip
|
||||||
|
-- which focus/Choose consumes on return.
|
||||||
function RomImporter:chooseMod()
|
function RomImporter:chooseMod()
|
||||||
if self.workState == "working" then return end
|
if self.workState == "working" then return end
|
||||||
if self.android then
|
if self.android then
|
||||||
self.modNotice =
|
local name = findPendingMod(true)
|
||||||
{ ok = false, text = "Drop a mod .zip onto the window to install it." }
|
if name then
|
||||||
|
self:_installMod(name)
|
||||||
|
if self.modNotice and self.modNotice.ok then
|
||||||
|
love.filesystem.remove(name)
|
||||||
|
end
|
||||||
|
return
|
||||||
|
end
|
||||||
|
if not love.system.pickFile("mod") then
|
||||||
|
self.modNotice = { ok = false,
|
||||||
|
text = "Could not open the file picker. Copy a mod .zip via USB." }
|
||||||
|
end
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
local path = chooseZip()
|
local path = chooseZip()
|
||||||
@@ -722,13 +817,26 @@ function RomImporter:_importSave(version, source)
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
-- "Import save" button: open a native .sav picker (desktop) and import the pick.
|
-- "Import save" button: open a native .sav picker and import the pick.
|
||||||
-- Android has no picker, so it points the player at the drop path.
|
-- Android mirrors ROM / mod import via love.system.pickFile("sav").
|
||||||
function RomImporter:chooseSaveImport(version)
|
function RomImporter:chooseSaveImport(version)
|
||||||
if self.workState == "working" then return end
|
if self.workState == "working" then return end
|
||||||
if self.android then
|
if self.android then
|
||||||
self.saveNotice[version] =
|
local name = findPendingSav(true)
|
||||||
{ ok = false, text = "Drop a .sav onto the window to import it." }
|
if name then
|
||||||
|
self.androidPendingVersion = version
|
||||||
|
self:_importSave(version, name)
|
||||||
|
if self.saveNotice[version] and self.saveNotice[version].ok then
|
||||||
|
love.filesystem.remove(name)
|
||||||
|
end
|
||||||
|
return
|
||||||
|
end
|
||||||
|
self.androidPendingVersion = version
|
||||||
|
if not love.system.pickFile("sav") then
|
||||||
|
self.androidPendingVersion = nil
|
||||||
|
self.saveNotice[version] = { ok = false,
|
||||||
|
text = "Could not open the file picker. Copy a .sav via USB." }
|
||||||
|
end
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
local path = chooseSav()
|
local path = chooseSav()
|
||||||
@@ -736,16 +844,58 @@ function RomImporter:chooseSaveImport(version)
|
|||||||
end
|
end
|
||||||
|
|
||||||
-- "Export save" button: write the active slot back out to a raw .sav in the save
|
-- "Export save" button: write the active slot back out to a raw .sav in the save
|
||||||
-- directory's exports/ folder and show the path (with an open-folder affordance)
|
-- directory's exports/ folder. On desktop, show the path with an open-folder
|
||||||
-- on the SAVE FILES card.
|
-- affordance. On Android, stage pending_export.sav and open the system
|
||||||
|
-- create-document picker (love.system.createFile) so the player can save to
|
||||||
|
-- Downloads / Drive / etc. -- the app-private exports/ path is not useful there.
|
||||||
function RomImporter:exportSave(version)
|
function RomImporter:exportSave(version)
|
||||||
if self.workState == "working" then return end
|
if self.workState == "working" then return end
|
||||||
local ok, res = require("src.import.SaveFileIO").exportActiveSlot(version)
|
local ok, res = require("src.import.SaveFileIO").exportActiveSlot(version)
|
||||||
if ok then
|
if not ok then
|
||||||
local dir = res:match("^(.*)[/\\][^/\\]+$")
|
|
||||||
self.saveNotice[version] = { ok = true, text = "Exported to " .. res, dir = dir }
|
|
||||||
else
|
|
||||||
self.saveNotice[version] = { ok = false, text = tostring(res) }
|
self.saveNotice[version] = { ok = false, text = tostring(res) }
|
||||||
|
return
|
||||||
|
end
|
||||||
|
if self.android then
|
||||||
|
local rel = res:match("exports[/\\][^/\\]+$")
|
||||||
|
local data = rel and love.filesystem.read(rel)
|
||||||
|
if not data then
|
||||||
|
self.saveNotice[version] = { ok = false,
|
||||||
|
text = "Exported, but could not stage the file for the picker." }
|
||||||
|
return
|
||||||
|
end
|
||||||
|
local suggested = rel:match("[^/\\]+$") or "export.sav"
|
||||||
|
local wrote, writeErr = love.filesystem.write("pending_export.sav", data)
|
||||||
|
if not wrote then
|
||||||
|
self.saveNotice[version] = { ok = false,
|
||||||
|
text = "Could not stage the export: " .. tostring(writeErr) }
|
||||||
|
return
|
||||||
|
end
|
||||||
|
self.androidPendingExportVersion = version
|
||||||
|
if love.system.createFile and love.system.createFile(suggested) then
|
||||||
|
self.saveNotice[version] = { ok = true,
|
||||||
|
text = "Pick where to save " .. suggested .. "..." }
|
||||||
|
else
|
||||||
|
self.androidPendingExportVersion = nil
|
||||||
|
self.saveNotice[version] = { ok = true,
|
||||||
|
text = "Exported inside the app folder (picker unavailable)." }
|
||||||
|
end
|
||||||
|
return
|
||||||
|
end
|
||||||
|
local dir = res:match("^(.*)[/\\][^/\\]+$")
|
||||||
|
self.saveNotice[version] = { ok = true, text = "Exported to " .. res, dir = dir }
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Delete a save slot from the registry and disk, then refresh the panel. If the
|
||||||
|
-- deleted slot was active, SaveData.deleteSlot points active at another slot.
|
||||||
|
function RomImporter:_deleteSlot(version, id)
|
||||||
|
if self.workState == "working" then return end
|
||||||
|
local SaveData = require("src.core.SaveData")
|
||||||
|
local ok, err = SaveData.deleteSlot(version, id)
|
||||||
|
if ok then
|
||||||
|
self:_refreshSlots(version)
|
||||||
|
self.saveNotice[version] = { ok = true, text = "Deleted " .. tostring(id) .. "." }
|
||||||
|
else
|
||||||
|
self.saveNotice[version] = { ok = false, text = tostring(err) }
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -1277,7 +1427,7 @@ function RomImporter:draw()
|
|||||||
self.updateButton = { x = rect.x, y = rect.y, width = rect.width,
|
self.updateButton = { x = rect.x, y = rect.y, width = rect.width,
|
||||||
height = rect.height, action = "download" }
|
height = rect.height, action = "download" }
|
||||||
message(upLatest and ("Update v" .. upLatest .. " available")
|
message(upLatest and ("Update v" .. upLatest .. " available")
|
||||||
or "An update is available", rect.width)
|
or Strings("An update is available"), rect.width)
|
||||||
elseif upStatus == "needs_full" then
|
elseif upStatus == "needs_full" then
|
||||||
local rect = actionButton("Open releases")
|
local rect = actionButton("Open releases")
|
||||||
self.updateButton = { x = rect.x, y = rect.y, width = rect.width,
|
self.updateButton = { x = rect.x, y = rect.y, width = rect.width,
|
||||||
@@ -1427,9 +1577,17 @@ function RomImporter:mousepressed(x, y, button)
|
|||||||
end
|
end
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
-- SAVE SLOT rows. On desktop a press only ARMS a click: _updateSlotDrag
|
-- SAVE SLOT rows / Delete. Delete is checked first so a tap on the Delete
|
||||||
-- commits it on release when the pointer did not move (a moved pointer scrolls
|
-- label never also selects the row. On desktop a press only ARMS a click:
|
||||||
-- instead). Android has no reliable pointer polling, so it selects on press.
|
-- _updateSlotDrag commits it on release when the pointer did not move (a
|
||||||
|
-- moved pointer scrolls instead). Android has no reliable pointer polling,
|
||||||
|
-- so it selects on press. Delete fires immediately (small fixed target).
|
||||||
|
for _, r in ipairs(self.slotDeleteRects or {}) do
|
||||||
|
if inside(r, x, y) then
|
||||||
|
self:_deleteSlot(self.panelVersion, r.id)
|
||||||
|
return
|
||||||
|
end
|
||||||
|
end
|
||||||
for _, r in ipairs(self.slotRects or {}) do
|
for _, r in ipairs(self.slotRects or {}) do
|
||||||
if inside(r, x, y) then
|
if inside(r, x, y) then
|
||||||
if self.android then
|
if self.android then
|
||||||
@@ -1445,12 +1603,18 @@ function RomImporter:mousepressed(x, y, button)
|
|||||||
self:_newSlot(self.panelVersion); return
|
self:_newSlot(self.panelVersion); return
|
||||||
end
|
end
|
||||||
-- Mods panel: the import button dispatches on press (fixed header, no scroll
|
-- Mods panel: the import button dispatches on press (fixed header, no scroll
|
||||||
-- conflict); a toggle switch, which lives in the scrollable list, only ARMS a
|
-- conflict); Delete fires immediately; a toggle switch, which lives in the
|
||||||
-- press so _updateSlotDrag can tell a click from a drag-scroll (Android, with
|
-- scrollable list, only ARMS a press so _updateSlotDrag can tell a click from
|
||||||
-- no pointer polling, toggles on press).
|
-- a drag-scroll (Android, with no pointer polling, toggles on press).
|
||||||
if inside(self.modImportRect, x, y) then
|
if inside(self.modImportRect, x, y) then
|
||||||
self:chooseMod(); return
|
self:chooseMod(); return
|
||||||
end
|
end
|
||||||
|
for _, r in ipairs(self.modDeleteRects or {}) do
|
||||||
|
if inside(r, x, y) then
|
||||||
|
self:_deleteMod(r.id)
|
||||||
|
return
|
||||||
|
end
|
||||||
|
end
|
||||||
for _, r in ipairs(self.modRects or {}) do
|
for _, r in ipairs(self.modRects or {}) do
|
||||||
if inside(r, x, y) then
|
if inside(r, x, y) then
|
||||||
if self.android then
|
if self.android then
|
||||||
@@ -1555,7 +1719,7 @@ function RomImporter:_playButton(x, y, w, h, gameName, ready, locked)
|
|||||||
col(PAL.disabledInk, 0.3)
|
col(PAL.disabledInk, 0.3)
|
||||||
love.graphics.rectangle("line", x, y, w, h, r, r)
|
love.graphics.rectangle("line", x, y, w, h, r, r)
|
||||||
col(PAL.disabledInk)
|
col(PAL.disabledInk)
|
||||||
local label = locked and "Coming soon" or "Import a ROM to play"
|
local label = locked and "Coming soon" or Strings("Import a ROM to play")
|
||||||
printfB(label, x, y + (h - self.playFont:getHeight()) / 2, w, "center")
|
printfB(label, x, y + (h - self.playFont:getHeight()) / 2, w, "center")
|
||||||
self.playButtonRect = nil
|
self.playButtonRect = nil
|
||||||
end
|
end
|
||||||
@@ -1567,13 +1731,13 @@ function RomImporter:_drawTabBar(x, y, w, h, chip)
|
|||||||
local s, pulse = self._s, self.pulse
|
local s, pulse = self._s, self.pulse
|
||||||
local tabs = {
|
local tabs = {
|
||||||
{ id = "red", letter = "R", top = PAL.chipRedTop, bot = PAL.chipRedBot,
|
{ id = "red", letter = "R", top = PAL.chipRedTop, bot = PAL.chipRedBot,
|
||||||
under = PAL.red, label = "RED", ink = PAL.white },
|
under = PAL.red, label = Strings("RED"), ink = PAL.white },
|
||||||
{ id = "blue", letter = "B", top = PAL.chipBlueTop, bot = PAL.chipBlueBot,
|
{ id = "blue", letter = "B", top = PAL.chipBlueTop, bot = PAL.chipBlueBot,
|
||||||
under = PAL.blue, label = "BLUE", ink = PAL.white },
|
under = PAL.blue, label = Strings("BLUE"), ink = PAL.white },
|
||||||
{ id = "yellow", letter = "Y", top = PAL.chipGoldTop, bot = PAL.chipGoldBot,
|
{ id = "yellow", letter = "Y", top = PAL.chipGoldTop, bot = PAL.chipGoldBot,
|
||||||
under = PAL.gold, label = "YELLOW", ink = PAL.chipInkGold },
|
under = PAL.gold, label = Strings("YELLOW"), ink = PAL.chipInkGold },
|
||||||
{ id = "mods", mods = true, top = PAL.chipModTop, bot = PAL.chipModBot,
|
{ id = "mods", mods = true, top = PAL.chipModTop, bot = PAL.chipModBot,
|
||||||
under = PAL.modDot, label = "MODS" },
|
under = PAL.modDot, label = Strings("MODS") },
|
||||||
}
|
}
|
||||||
local gap = 10 * s
|
local gap = 10 * s
|
||||||
local r = 12 * s
|
local r = 12 * s
|
||||||
@@ -1632,7 +1796,7 @@ function RomImporter:_drawTabBar(x, y, w, h, chip)
|
|||||||
local ready = 0
|
local ready = 0
|
||||||
for _, v in ipairs(GameVersion.ORDER) do if self.ready[v] then ready = ready + 1 end end
|
for _, v in ipairs(GameVersion.ORDER) do if self.ready[v] then ready = ready + 1 end end
|
||||||
love.graphics.setFont(self.readyFont)
|
love.graphics.setFont(self.readyFont)
|
||||||
local label = ("%d of 3 ready"):format(ready)
|
local label = Strings("%d of 3 ready", ready)
|
||||||
local lw = self.readyFont:getWidth(label)
|
local lw = self.readyFont:getWidth(label)
|
||||||
if x + w - lw > cursorX + 8 * s then
|
if x + w - lw > cursorX + 8 * s then
|
||||||
col(PAL.labelGray)
|
col(PAL.labelGray)
|
||||||
@@ -1688,7 +1852,7 @@ function RomImporter:_drawGamePanel(version, x, y, w, h)
|
|||||||
|
|
||||||
-- ROM card contents by state (rehomes the existing import flow)
|
-- ROM card contents by state (rehomes the existing import flow)
|
||||||
local dropHint = self.android and "Copy the .gb via USB."
|
local dropHint = self.android and "Copy the .gb via USB."
|
||||||
or "Or drop the .gb file here."
|
or Strings("Or drop the .gb file here.")
|
||||||
local accent = locked and PAL.gold or (version == "red" and PAL.red or PAL.blue)
|
local accent = locked and PAL.gold or (version == "red" and PAL.red or PAL.blue)
|
||||||
local romState, romDetail, romBtnLabel, romBtnEnabled, romProgress
|
local romState, romDetail, romBtnLabel, romBtnEnabled, romProgress
|
||||||
if locked then
|
if locked then
|
||||||
@@ -1703,12 +1867,12 @@ function RomImporter:_drawGamePanel(version, x, y, w, h)
|
|||||||
romDetail = self.detail or ""
|
romDetail = self.detail or ""
|
||||||
romProgress = self.progress or 0
|
romProgress = self.progress or 0
|
||||||
elseif ready then
|
elseif ready then
|
||||||
romState = self.romName[version] or "ROM imported"
|
romState = self.romName[version] or Strings("ROM imported")
|
||||||
romDetail = "Verified."
|
romDetail = "Verified."
|
||||||
romBtnLabel, romBtnEnabled = "Re-import ROM", true
|
romBtnLabel, romBtnEnabled = "Re-import ROM", true
|
||||||
elseif erroring then
|
elseif erroring then
|
||||||
romState = "Import failed"
|
romState = "Import failed"
|
||||||
romDetail = self.detail or "That ROM could not be imported."
|
romDetail = self.detail or Strings("That ROM could not be imported.")
|
||||||
romBtnLabel, romBtnEnabled = "Import ROM", true
|
romBtnLabel, romBtnEnabled = "Import ROM", true
|
||||||
elseif notice then
|
elseif notice then
|
||||||
romState = "No ROM imported"
|
romState = "No ROM imported"
|
||||||
@@ -1759,7 +1923,8 @@ function RomImporter:_drawGamePanel(version, x, y, w, h)
|
|||||||
elseif locked then
|
elseif locked then
|
||||||
sfHintText, sfHintCol = "Not available yet.", PAL.warning
|
sfHintText, sfHintCol = "Not available yet.", PAL.warning
|
||||||
elseif self.android then
|
elseif self.android then
|
||||||
sfHintText, sfHintCol = "Import a .sav, or drop one on the window.", PAL.warning
|
sfHintText, sfHintCol =
|
||||||
|
"Import or export a .sav with the system file picker.", PAL.warning
|
||||||
else
|
else
|
||||||
sfHintText, sfHintCol =
|
sfHintText, sfHintCol =
|
||||||
"Import a .sav to a new slot, or export the active slot.", PAL.warning
|
"Import a .sav to a new slot, or export the active slot.", PAL.warning
|
||||||
@@ -1836,7 +2001,7 @@ function RomImporter:_drawGamePanel(version, x, y, w, h)
|
|||||||
if sfNotice and sfNotice.dir then
|
if sfNotice and sfNotice.dir then
|
||||||
iy = iy + 4 * s
|
iy = iy + 4 * s
|
||||||
love.graphics.setFont(self.hintFont)
|
love.graphics.setFont(self.hintFont)
|
||||||
local label = "Open folder"
|
local label = Strings("Open folder")
|
||||||
local lw = self.hintFont:getWidth(label)
|
local lw = self.hintFont:getWidth(label)
|
||||||
local frect = { x = ix, y = iy, width = lw, height = self.hintFont:getHeight(),
|
local frect = { x = ix, y = iy, width = lw, height = self.hintFont:getHeight(),
|
||||||
dir = sfNotice.dir }
|
dir = sfNotice.dir }
|
||||||
@@ -1999,6 +2164,8 @@ function RomImporter:_drawSaveSlotPanel(version, x, y, w, h)
|
|||||||
love.graphics.printf("No saves yet - start a new game or import one.",
|
love.graphics.printf("No saves yet - start a new game or import one.",
|
||||||
rx + 12 * s, listTop + listH / 2 - self.hintFont:getHeight() / 2,
|
rx + 12 * s, listTop + listH / 2 - self.hintFont:getHeight() / 2,
|
||||||
rw - 24 * s, "center")
|
rw - 24 * s, "center")
|
||||||
|
self.slotRects = {}
|
||||||
|
self.slotDeleteRects = {}
|
||||||
elseif listH > 0 then
|
elseif listH > 0 then
|
||||||
local nameH = self.slotNameFont:getHeight()
|
local nameH = self.slotNameFont:getHeight()
|
||||||
local metaH = self.labelFont:getHeight()
|
local metaH = self.labelFont:getHeight()
|
||||||
@@ -2017,6 +2184,7 @@ function RomImporter:_drawSaveSlotPanel(version, x, y, w, h)
|
|||||||
self.slotScroll[version] = scroll
|
self.slotScroll[version] = scroll
|
||||||
|
|
||||||
self.slotRects = {}
|
self.slotRects = {}
|
||||||
|
self.slotDeleteRects = {}
|
||||||
love.graphics.setScissor(math.floor(rx), math.floor(listTop),
|
love.graphics.setScissor(math.floor(rx), math.floor(listTop),
|
||||||
math.ceil(rw), math.ceil(listH))
|
math.ceil(rw), math.ceil(listH))
|
||||||
for i, slot in ipairs(slots) do
|
for i, slot in ipairs(slots) do
|
||||||
@@ -2029,6 +2197,20 @@ function RomImporter:_drawSaveSlotPanel(version, x, y, w, h)
|
|||||||
col(selected and PAL.green or PAL.cardBorder, selected and 0.9 or 0.22)
|
col(selected and PAL.green or PAL.cardBorder, selected and 0.9 or 0.22)
|
||||||
love.graphics.rectangle("line", rx, ry, rw, rowH, rr, rr)
|
love.graphics.rectangle("line", rx, ry, rw, rowH, rr, rr)
|
||||||
|
|
||||||
|
-- Delete label (bottom-right); reserve its width so name/meta don't overlap
|
||||||
|
love.graphics.setFont(self.hintFont)
|
||||||
|
local delText = "Delete"
|
||||||
|
local delW = self.hintFont:getWidth(delText)
|
||||||
|
local delH = self.hintFont:getHeight()
|
||||||
|
local delX = rx + rw - 12 * s - delW
|
||||||
|
local delY = ry + rowH - rowPadV - delH
|
||||||
|
local drect = { x = delX - 6 * s, y = delY - 4 * s,
|
||||||
|
width = delW + 12 * s, height = delH + 8 * s, id = slot.id }
|
||||||
|
local dhot = self:_hover(drect)
|
||||||
|
col(dhot and PAL.red or PAL.warning)
|
||||||
|
love.graphics.print(delText, delX, delY)
|
||||||
|
local rightReserve = delW + 18 * s
|
||||||
|
|
||||||
-- LOADED pill (top-right of the active row), then reserve its width
|
-- LOADED pill (top-right of the active row), then reserve its width
|
||||||
local pillW = 0
|
local pillW = 0
|
||||||
if selected then
|
if selected then
|
||||||
@@ -2047,21 +2229,20 @@ function RomImporter:_drawSaveSlotPanel(version, x, y, w, h)
|
|||||||
|
|
||||||
love.graphics.setFont(self.slotNameFont)
|
love.graphics.setFont(self.slotNameFont)
|
||||||
col(PAL.white)
|
col(PAL.white)
|
||||||
local name = slot.name or "NEW GAME"
|
local name = slot.name or Strings("NEW GAME")
|
||||||
printB(ellipsize(self.slotNameFont, name, rw - 24 * s - pillW),
|
printB(ellipsize(self.slotNameFont, name, rw - 24 * s - math.max(pillW, rightReserve)),
|
||||||
rx + 12 * s, ry + rowPadV)
|
rx + 12 * s, ry + rowPadV)
|
||||||
|
|
||||||
local metaTxt
|
local metaTxt
|
||||||
if slot.exists and slot.meta then
|
if slot.exists and slot.meta then
|
||||||
metaTxt = ("%d badges - %s - %d caught"):format(
|
metaTxt = Strings("%d badges - %s - %d caught", slot.meta.badges or 0, slot.meta.timeText or "0:00",
|
||||||
slot.meta.badges or 0, slot.meta.timeText or "0:00",
|
|
||||||
slot.meta.dexCount or 0)
|
slot.meta.dexCount or 0)
|
||||||
else
|
else
|
||||||
metaTxt = "empty slot"
|
metaTxt = "empty slot"
|
||||||
end
|
end
|
||||||
love.graphics.setFont(self.labelFont)
|
love.graphics.setFont(self.labelFont)
|
||||||
col(PAL.warning)
|
col(PAL.warning)
|
||||||
love.graphics.print(ellipsize(self.labelFont, metaTxt, rw - 24 * s),
|
love.graphics.print(ellipsize(self.labelFont, metaTxt, rw - 24 * s - rightReserve),
|
||||||
rx + 12 * s, ry + rowPadV + nameH + 4 * s)
|
rx + 12 * s, ry + rowPadV + nameH + 4 * s)
|
||||||
|
|
||||||
-- clip the hit rect to the visible list band so a partly-scrolled row
|
-- clip the hit rect to the visible list band so a partly-scrolled row
|
||||||
@@ -2072,6 +2253,12 @@ function RomImporter:_drawSaveSlotPanel(version, x, y, w, h)
|
|||||||
self.slotRects[#self.slotRects + 1] =
|
self.slotRects[#self.slotRects + 1] =
|
||||||
{ x = rx, y = vy, width = rw, height = vy2 - vy, id = slot.id }
|
{ x = rx, y = vy, width = rw, height = vy2 - vy, id = slot.id }
|
||||||
end
|
end
|
||||||
|
local dvy = math.max(drect.y, listTop)
|
||||||
|
local dvy2 = math.min(drect.y + drect.height, listBottom)
|
||||||
|
if dvy2 > dvy then
|
||||||
|
self.slotDeleteRects[#self.slotDeleteRects + 1] =
|
||||||
|
{ x = drect.x, y = dvy, width = drect.width, height = dvy2 - dvy, id = slot.id }
|
||||||
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
love.graphics.setScissor()
|
love.graphics.setScissor()
|
||||||
@@ -2152,7 +2339,7 @@ function RomImporter:_drawModsPanel(x, y, w, h)
|
|||||||
for _, m in ipairs(mods) do if m.enabled then enabledCount = enabledCount + 1 end end
|
for _, m in ipairs(mods) do if m.enabled then enabledCount = enabledCount + 1 end end
|
||||||
love.graphics.setFont(self.hintFont)
|
love.graphics.setFont(self.hintFont)
|
||||||
col(PAL.warning)
|
col(PAL.warning)
|
||||||
love.graphics.print(("%d of %d enabled"):format(enabledCount, #mods),
|
love.graphics.print(Strings("%d of %d enabled", enabledCount, #mods),
|
||||||
x + nameW + 14 * s, y + (headerH - self.hintFont:getHeight()) / 2)
|
x + nameW + 14 * s, y + (headerH - self.hintFont:getHeight()) / 2)
|
||||||
|
|
||||||
local btnLabel = "Import mod .zip"
|
local btnLabel = "Import mod .zip"
|
||||||
@@ -2165,15 +2352,15 @@ function RomImporter:_drawModsPanel(x, y, w, h)
|
|||||||
|
|
||||||
local top = y + headerH + 14 * s
|
local top = y + headerH + 14 * s
|
||||||
|
|
||||||
-- notice line: the last install result, else the drag-drop hint
|
-- notice line: the last install/delete result, else the platform hint
|
||||||
love.graphics.setFont(self.hintFont)
|
love.graphics.setFont(self.hintFont)
|
||||||
if self.modNotice then
|
if self.modNotice then
|
||||||
col(self.modNotice.ok and PAL.green or PAL.red)
|
col(self.modNotice.ok and PAL.green or PAL.red)
|
||||||
love.graphics.printf(self.modNotice.text, x, top, w, "left")
|
love.graphics.printf(self.modNotice.text, x, top, w, "left")
|
||||||
else
|
else
|
||||||
col(PAL.warning)
|
col(PAL.warning)
|
||||||
love.graphics.printf(self.android and "Copy a mod .zip via USB."
|
love.graphics.printf(self.android and "Or copy a mod .zip via USB."
|
||||||
or "Or drop a mod .zip onto the window.", x, top, w, "left")
|
or Strings("Or drop a mod .zip onto the window."), x, top, w, "left")
|
||||||
end
|
end
|
||||||
top = top + self.hintFont:getHeight() + 12 * s
|
top = top + self.hintFont:getHeight() + 12 * s
|
||||||
|
|
||||||
@@ -2187,20 +2374,25 @@ function RomImporter:_drawModsPanel(x, y, w, h)
|
|||||||
dashedRoundRect(x, top, w, boxH, 14 * s, 7 * s, 5 * s)
|
dashedRoundRect(x, top, w, boxH, 14 * s, 7 * s, 5 * s)
|
||||||
love.graphics.setFont(self.hintFont)
|
love.graphics.setFont(self.hintFont)
|
||||||
col(PAL.warning)
|
col(PAL.warning)
|
||||||
love.graphics.printf("No mods installed - drop a mod .zip here to add one.",
|
local emptyHint = self.android
|
||||||
|
and "No mods installed - tap Import mod .zip to add one."
|
||||||
|
or Strings("No mods installed - drop a mod .zip here to add one.")
|
||||||
|
love.graphics.printf(emptyHint,
|
||||||
x + 16 * s, top + boxH / 2 - self.hintFont:getHeight() / 2, w - 32 * s, "center")
|
x + 16 * s, top + boxH / 2 - self.hintFont:getHeight() / 2, w - 32 * s, "center")
|
||||||
self.modRects = {}
|
self.modRects = {}
|
||||||
|
self.modDeleteRects = {}
|
||||||
self._modMax = 0
|
self._modMax = 0
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
|
|
||||||
-- card metrics (design: rounded 14, padding 14x16; toggle 56x28)
|
-- card metrics (design: rounded 14, padding 14x16; toggle 56x28; Delete under)
|
||||||
local padH, padV = 16 * s, 14 * s
|
local padH, padV = 16 * s, 14 * s
|
||||||
local cardGap, cardR = 10 * s, 14 * s
|
local cardGap, cardR = 10 * s, 14 * s
|
||||||
local tw, th = 52 * s, 28 * s
|
local tw, th = 52 * s, 28 * s
|
||||||
local innerW = w - 2 * padH
|
local innerW = w - 2 * padH
|
||||||
local chipH = self.hintFont:getHeight() + 8 * s
|
local chipH = self.hintFont:getHeight() + 8 * s
|
||||||
local clusterH = chipH + 6 * s + th -- status chip stacked over the toggle
|
local delH = self.hintFont:getHeight()
|
||||||
|
local clusterH = chipH + 6 * s + th + 6 * s + delH
|
||||||
|
|
||||||
love.graphics.setFont(self.stateFont)
|
love.graphics.setFont(self.stateFont)
|
||||||
local nameH = self.stateFont:getHeight()
|
local nameH = self.stateFont:getHeight()
|
||||||
@@ -2210,7 +2402,8 @@ function RomImporter:_drawModsPanel(x, y, w, h)
|
|||||||
for i, m in ipairs(mods) do
|
for i, m in ipairs(mods) do
|
||||||
local chipText = modStatusChip(m.status)
|
local chipText = modStatusChip(m.status)
|
||||||
local chipW = self.hintFont:getWidth(chipText) + 20 * s
|
local chipW = self.hintFont:getWidth(chipText) + 20 * s
|
||||||
local clusterW = math.max(chipW, tw)
|
local delW = self.hintFont:getWidth("Delete")
|
||||||
|
local clusterW = math.max(chipW, tw, delW)
|
||||||
local leftW = math.max(40 * s, innerW - clusterW - 14 * s)
|
local leftW = math.max(40 * s, innerW - clusterW - 14 * s)
|
||||||
local descH = 0
|
local descH = 0
|
||||||
if m.description ~= "" then
|
if m.description ~= "" then
|
||||||
@@ -2221,7 +2414,7 @@ function RomImporter:_drawModsPanel(x, y, w, h)
|
|||||||
local contentH = padV * 2 + nameH + (descH > 0 and (6 * s + descH) or 0)
|
local contentH = padV * 2 + nameH + (descH > 0 and (6 * s + descH) or 0)
|
||||||
local cardH = math.max(contentH, padV * 2 + clusterH)
|
local cardH = math.max(contentH, padV * 2 + clusterH)
|
||||||
layout[i] = { h = cardH, leftW = leftW, clusterW = clusterW,
|
layout[i] = { h = cardH, leftW = leftW, clusterW = clusterW,
|
||||||
chipText = chipText, chipW = chipW }
|
chipText = chipText, chipW = chipW, delW = delW }
|
||||||
total = total + cardH
|
total = total + cardH
|
||||||
end
|
end
|
||||||
total = total + (#mods - 1) * cardGap
|
total = total + (#mods - 1) * cardGap
|
||||||
@@ -2231,6 +2424,7 @@ function RomImporter:_drawModsPanel(x, y, w, h)
|
|||||||
local scroll = clamp(self.modScroll or 0, 0, maxScroll)
|
local scroll = clamp(self.modScroll or 0, 0, maxScroll)
|
||||||
self.modScroll = scroll
|
self.modScroll = scroll
|
||||||
self.modRects = {}
|
self.modRects = {}
|
||||||
|
self.modDeleteRects = {}
|
||||||
|
|
||||||
love.graphics.setScissor(math.floor(x), math.floor(top),
|
love.graphics.setScissor(math.floor(x), math.floor(top),
|
||||||
math.ceil(w), math.ceil(listH))
|
math.ceil(w), math.ceil(listH))
|
||||||
@@ -2269,7 +2463,7 @@ function RomImporter:_drawModsPanel(x, y, w, h)
|
|||||||
love.graphics.printf(m.description, nx, ny + nameH + 6 * s, L.leftW, "left")
|
love.graphics.printf(m.description, nx, ny + nameH + 6 * s, L.leftW, "left")
|
||||||
end
|
end
|
||||||
|
|
||||||
-- right cluster: status chip stacked over the toggle, vertically centred
|
-- right cluster: status chip, toggle, Delete — vertically centred
|
||||||
local clusterX = x + w - padH - L.clusterW
|
local clusterX = x + w - padH - L.clusterW
|
||||||
local clusterY = cy + (cardH - clusterH) / 2
|
local clusterY = cy + (cardH - clusterH) / 2
|
||||||
local _, chipColor = modStatusChip(m.status)
|
local _, chipColor = modStatusChip(m.status)
|
||||||
@@ -2303,14 +2497,29 @@ function RomImporter:_drawModsPanel(x, y, w, h)
|
|||||||
col(PAL.white)
|
col(PAL.white)
|
||||||
love.graphics.circle("fill", kcx, ty + th / 2, kd / 2)
|
love.graphics.circle("fill", kcx, ty + th / 2, kd / 2)
|
||||||
|
|
||||||
-- hit rect clipped to the visible list band (a partly-scrolled toggle is
|
-- Delete under the toggle
|
||||||
-- only clickable where it actually shows)
|
local delX = clusterX + (L.clusterW - L.delW) / 2
|
||||||
|
local delY = ty + th + 6 * s
|
||||||
|
local drect = { x = delX - 6 * s, y = delY - 2 * s,
|
||||||
|
width = L.delW + 12 * s, height = delH + 4 * s, id = m.id }
|
||||||
|
local dhot = self:_hover(drect)
|
||||||
|
love.graphics.setFont(self.hintFont)
|
||||||
|
col(dhot and PAL.red or PAL.warning)
|
||||||
|
love.graphics.print("Delete", delX, delY)
|
||||||
|
|
||||||
|
-- hit rects clipped to the visible list band
|
||||||
local vy = math.max(trect.y, top)
|
local vy = math.max(trect.y, top)
|
||||||
local vy2 = math.min(trect.y + trect.height, top + listH)
|
local vy2 = math.min(trect.y + trect.height, top + listH)
|
||||||
if vy2 > vy then
|
if vy2 > vy then
|
||||||
self.modRects[#self.modRects + 1] =
|
self.modRects[#self.modRects + 1] =
|
||||||
{ x = trect.x, y = vy, width = trect.width, height = vy2 - vy, id = m.id }
|
{ x = trect.x, y = vy, width = trect.width, height = vy2 - vy, id = m.id }
|
||||||
end
|
end
|
||||||
|
local dvy = math.max(drect.y, top)
|
||||||
|
local dvy2 = math.min(drect.y + drect.height, top + listH)
|
||||||
|
if dvy2 > dvy then
|
||||||
|
self.modDeleteRects[#self.modDeleteRects + 1] =
|
||||||
|
{ x = drect.x, y = dvy, width = drect.width, height = dvy2 - dvy, id = m.id }
|
||||||
|
end
|
||||||
end
|
end
|
||||||
cy = cy + cardH + cardGap
|
cy = cy + cardH + cardGap
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -47,11 +47,19 @@ local function readSource(source)
|
|||||||
return source
|
return source
|
||||||
end
|
end
|
||||||
local f, openErr = io.open(source, "rb")
|
local f, openErr = io.open(source, "rb")
|
||||||
if not f then return nil, "could not read the save file: " .. tostring(openErr) end
|
if f then
|
||||||
local data = f:read("*a")
|
local data = f:read("*a")
|
||||||
f:close()
|
f:close()
|
||||||
if type(data) ~= "string" then return nil, "the save file was empty" end
|
if type(data) ~= "string" then return nil, "the save file was empty" end
|
||||||
return data
|
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
|
end
|
||||||
|
|
||||||
-- importToSlot(source, version) -> ok, slotIdOrErr
|
-- importToSlot(source, version) -> ok, slotIdOrErr
|
||||||
|
|||||||
@@ -12,6 +12,7 @@
|
|||||||
|
|
||||||
local Pokemon = require("src.pokemon.Pokemon")
|
local Pokemon = require("src.pokemon.Pokemon")
|
||||||
local Flags = require("src.script.Flags")
|
local Flags = require("src.script.Flags")
|
||||||
|
local Strings = require("src.core.Strings")
|
||||||
|
|
||||||
local ItemEffects = {}
|
local ItemEffects = {}
|
||||||
|
|
||||||
@@ -119,8 +120,8 @@ function ItemEffects.use(data, save, itemId, target, battle, moveIndex, ow)
|
|||||||
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"
|
or itemId == "RARE_CANDY" or itemId == "COIN_CASE"
|
||||||
or (itemDef and itemDef.machine)) then
|
or (itemDef and itemDef.machine)) then
|
||||||
return "failed", { "OAK: " .. save.player.name
|
return "failed", { Strings("OAK: %s!\nThis isn't the\ntime to use that!",
|
||||||
.. "!\nThis isn't the\ntime to use that!" }
|
save.player.name) }
|
||||||
end
|
end
|
||||||
|
|
||||||
if BALLS[itemId] then
|
if BALLS[itemId] then
|
||||||
@@ -138,11 +139,12 @@ function ItemEffects.use(data, save, itemId, target, battle, moveIndex, ow)
|
|||||||
local mapId, npc = adjacentSleepingSnorlax(save, ow)
|
local mapId, npc = adjacentSleepingSnorlax(save, ow)
|
||||||
if npc then
|
if npc then
|
||||||
return "flute_wake", { data.text._PlayedFluteHadEffectText
|
return "flute_wake", { data.text._PlayedFluteHadEffectText
|
||||||
or "{PLAYER} played the\nPOKé FLUTE." }, { mapId = mapId, npc = npc }
|
or Strings("{PLAYER} played the\nPOKé FLUTE.") },
|
||||||
|
{ mapId = mapId, npc = npc }
|
||||||
end
|
end
|
||||||
-- otherwise: play the tune, nothing happens (ItemUsePokeFlute's
|
-- otherwise: play the tune, nothing happens (ItemUsePokeFlute's
|
||||||
-- PlayedFluteNoEffectText branch)
|
-- PlayedFluteNoEffectText branch)
|
||||||
return "flute_field", { "Played the POKé\nFLUTE.\fNow, that's a\ncatchy tune!" }
|
return "flute_field", { Strings("Played the POKé\nFLUTE.\fNow, that's a\ncatchy tune!") }
|
||||||
end
|
end
|
||||||
local woke = false
|
local woke = false
|
||||||
local function wake(mon)
|
local function wake(mon)
|
||||||
@@ -157,24 +159,24 @@ function ItemEffects.use(data, save, itemId, target, battle, moveIndex, ow)
|
|||||||
-- WakeUpEntireParty runs on the enemy's bench too
|
-- WakeUpEntireParty runs on the enemy's bench too
|
||||||
for _, mon in ipairs(battle.enemyParty or {}) do wake(mon) end
|
for _, mon in ipairs(battle.enemyParty or {}) do wake(mon) end
|
||||||
if not woke then
|
if not woke then
|
||||||
return "failed", { "Played the POKé\nFLUTE.\fNow, that's a\ncatchy tune!" }
|
return "failed", { Strings("Played the POKé\nFLUTE.\fNow, that's a\ncatchy tune!") }
|
||||||
end
|
end
|
||||||
return "flute", { ("%s played the\nPOKé FLUTE."):format(save.player.name),
|
return "flute", { Strings("%s played the\nPOKé FLUTE.", save.player.name),
|
||||||
"All sleeping\nPOKéMON woke up!" }
|
Strings("All sleeping\nPOKéMON woke up!") }
|
||||||
end
|
end
|
||||||
|
|
||||||
-- battle-only items
|
-- battle-only items
|
||||||
if X_ITEMS[itemId] or itemId == "DIRE_HIT" or itemId == "GUARD_SPEC"
|
if X_ITEMS[itemId] or itemId == "DIRE_HIT" or itemId == "GUARD_SPEC"
|
||||||
or itemId == "POKE_DOLL" then
|
or itemId == "POKE_DOLL" then
|
||||||
if not battle then
|
if not battle then
|
||||||
return "failed", { "OAK: " .. save.player.name .. "!\nThis isn't the\ntime to use that!" }
|
return "failed", { Strings("OAK: %s!\nThis isn't the\ntime to use that!", save.player.name) }
|
||||||
end
|
end
|
||||||
local b = battle.player
|
local b = battle.player
|
||||||
if itemId == "X_ACCURACY" then
|
if itemId == "X_ACCURACY" then
|
||||||
-- ItemUseXAccuracy sets USING_X_ACCURACY: moves never miss
|
-- ItemUseXAccuracy sets USING_X_ACCURACY: moves never miss
|
||||||
-- (not an accuracy stage)
|
-- (not an accuracy stage)
|
||||||
b.xAccuracy = true
|
b.xAccuracy = true
|
||||||
return "consumed", { ("%s's\nhits will never\nmiss!"):format(b.name) }
|
return "consumed", { Strings("%s's\nhits will never\nmiss!", b.name) }
|
||||||
end
|
end
|
||||||
if X_ITEMS[itemId] then
|
if X_ITEMS[itemId] then
|
||||||
local stat = X_ITEMS[itemId]
|
local stat = X_ITEMS[itemId]
|
||||||
@@ -183,28 +185,28 @@ function ItemEffects.use(data, save, itemId, target, battle, moveIndex, ow)
|
|||||||
-- effect, so at +6 it is still consumed and StatModifierUpEffect
|
-- effect, so at +6 it is still consumed and StatModifierUpEffect
|
||||||
-- just prints "Nothing happened!"
|
-- just prints "Nothing happened!"
|
||||||
if cur >= 6 then
|
if cur >= 6 then
|
||||||
return "consumed", { "Nothing happened!" }
|
return "consumed", { Strings("Nothing happened!") }
|
||||||
end
|
end
|
||||||
b.stages[stat] = cur + 1
|
b.stages[stat] = cur + 1
|
||||||
return "consumed", { ("%s's\n%s rose!"):format(b.name, stat:upper()) }
|
return "consumed", { Strings("%s's\n%s rose!", b.name, stat:upper()) }
|
||||||
end
|
end
|
||||||
-- ItemUseDireHit/ItemUseGuardSpec always set the bit and consume
|
-- ItemUseDireHit/ItemUseGuardSpec always set the bit and consume
|
||||||
-- the item, even when it is already active
|
-- the item, even when it is already active
|
||||||
if itemId == "DIRE_HIT" then
|
if itemId == "DIRE_HIT" then
|
||||||
b.focusEnergy = true
|
b.focusEnergy = true
|
||||||
return "consumed", { ("%s's\ngetting pumped!"):format(b.name) }
|
return "consumed", { Strings("%s's\ngetting pumped!", b.name) }
|
||||||
end
|
end
|
||||||
if itemId == "GUARD_SPEC" then
|
if itemId == "GUARD_SPEC" then
|
||||||
b.mist = true
|
b.mist = true
|
||||||
return "consumed", { ("%s's\nprotected against\nstat changes!"):format(b.name) }
|
return "consumed", { Strings("%s's\nprotected against\nstat changes!", b.name) }
|
||||||
end
|
end
|
||||||
if itemId == "POKE_DOLL" then
|
if itemId == "POKE_DOLL" then
|
||||||
if battle.kind ~= "wild" then
|
if battle.kind ~= "wild" then
|
||||||
-- ItemUsePokeDoll jumps to ItemUseNotTime in trainer battles
|
-- ItemUsePokeDoll jumps to ItemUseNotTime in trainer battles
|
||||||
return "failed", { "OAK: " .. save.player.name
|
return "failed", { Strings(
|
||||||
.. "!\nThis isn't the\ntime to use that!" }
|
"OAK: %s!\nThis isn't the\ntime to use that!", save.player.name) }
|
||||||
end
|
end
|
||||||
return "consumed_escape", { "The wild POKéMON\nran away!" }
|
return "consumed_escape", { Strings("The wild POKéMON\nran away!") }
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -213,7 +215,7 @@ function ItemEffects.use(data, save, itemId, target, battle, moveIndex, ow)
|
|||||||
-- restore every move with no menu.
|
-- restore every move with no menu.
|
||||||
if itemId == "ETHER" or itemId == "MAX_ETHER"
|
if itemId == "ETHER" or itemId == "MAX_ETHER"
|
||||||
or itemId == "ELIXER" or itemId == "MAX_ELIXER" then
|
or itemId == "ELIXER" or itemId == "MAX_ELIXER" then
|
||||||
if not target then return "failed", { "It won't have\nany effect." } end
|
if not target then return "failed", { Strings("It won't have\nany effect.") } end
|
||||||
local restored = false
|
local restored = false
|
||||||
local full = itemId == "MAX_ETHER" or itemId == "MAX_ELIXER"
|
local full = itemId == "MAX_ETHER" or itemId == "MAX_ELIXER"
|
||||||
local allMoves = itemId == "ELIXER" or itemId == "MAX_ELIXER"
|
local allMoves = itemId == "ELIXER" or itemId == "MAX_ELIXER"
|
||||||
@@ -235,9 +237,9 @@ function ItemEffects.use(data, save, itemId, target, battle, moveIndex, ow)
|
|||||||
restored = mv and restore(mv) or false
|
restored = mv and restore(mv) or false
|
||||||
end
|
end
|
||||||
if not restored then
|
if not restored then
|
||||||
return "failed", { "It won't have\nany effect." }
|
return "failed", { Strings("It won't have\nany effect.") }
|
||||||
end
|
end
|
||||||
return "consumed", { ("%s's PP\nwas restored!"):format(monName(data, target)) }
|
return "consumed", { Strings("%s's PP\nwas restored!", monName(data, target)) }
|
||||||
end
|
end
|
||||||
|
|
||||||
local heal = HEAL_AMOUNT[itemId]
|
local heal = HEAL_AMOUNT[itemId]
|
||||||
@@ -250,17 +252,17 @@ function ItemEffects.use(data, save, itemId, target, battle, moveIndex, ow)
|
|||||||
target.status = nil
|
target.status = nil
|
||||||
cureActiveToxic(battle, target)
|
cureActiveToxic(battle, target)
|
||||||
require("src.core.Sound").play(data, "Heal_Ailment")
|
require("src.core.Sound").play(data, "Heal_Ailment")
|
||||||
return "consumed", { ("%s's\nstatus returned\nto normal!"):format(monName(data, target)) }
|
return "consumed", { Strings("%s's\nstatus returned\nto normal!", monName(data, target)) }
|
||||||
end
|
end
|
||||||
if not target or target.hp <= 0 or target.hp >= target.stats.hp then
|
if not target or target.hp <= 0 or target.hp >= target.stats.hp then
|
||||||
return "failed", { "It won't have\nany effect." }
|
return "failed", { Strings("It won't have\nany effect.") }
|
||||||
end
|
end
|
||||||
if itemId == "MAX_POTION" or itemId == "FULL_RESTORE" then
|
if itemId == "MAX_POTION" or itemId == "FULL_RESTORE" then
|
||||||
target.hp = target.stats.hp
|
target.hp = target.stats.hp
|
||||||
else
|
else
|
||||||
target.hp = math.min(target.stats.hp, target.hp + heal)
|
target.hp = math.min(target.stats.hp, target.hp + heal)
|
||||||
end
|
end
|
||||||
local msgs = { ("%s's HP\nwas restored!"):format(monName(data, target)) }
|
local msgs = { Strings("%s's HP\nwas restored!", monName(data, target)) }
|
||||||
if itemId == "FULL_RESTORE" then
|
if itemId == "FULL_RESTORE" then
|
||||||
target.status = nil
|
target.status = nil
|
||||||
cureActiveToxic(battle, target)
|
cureActiveToxic(battle, target)
|
||||||
@@ -272,27 +274,27 @@ function ItemEffects.use(data, save, itemId, target, battle, moveIndex, ow)
|
|||||||
local cures = STATUS_HEAL[itemId]
|
local cures = STATUS_HEAL[itemId]
|
||||||
if cures then
|
if cures then
|
||||||
if not target or not target.status or not cures[target.status] then
|
if not target or not target.status or not cures[target.status] then
|
||||||
return "failed", { "It won't have\nany effect." }
|
return "failed", { Strings("It won't have\nany effect.") }
|
||||||
end
|
end
|
||||||
target.status = nil
|
target.status = nil
|
||||||
cureActiveToxic(battle, target)
|
cureActiveToxic(battle, target)
|
||||||
require("src.core.Sound").play(data, "Heal_Ailment")
|
require("src.core.Sound").play(data, "Heal_Ailment")
|
||||||
return "consumed", { ("%s's\nstatus returned\nto normal!"):format(monName(data, target)) }
|
return "consumed", { Strings("%s's\nstatus returned\nto normal!", monName(data, target)) }
|
||||||
end
|
end
|
||||||
|
|
||||||
if itemId == "REVIVE" or itemId == "MAX_REVIVE" then
|
if itemId == "REVIVE" or itemId == "MAX_REVIVE" then
|
||||||
if not target or target.hp > 0 then
|
if not target or target.hp > 0 then
|
||||||
return "failed", { "It won't have\nany effect." }
|
return "failed", { Strings("It won't have\nany effect.") }
|
||||||
end
|
end
|
||||||
target.status = nil
|
target.status = nil
|
||||||
target.hp = itemId == "REVIVE" and math.floor(target.stats.hp / 2) or target.stats.hp
|
target.hp = itemId == "REVIVE" and math.floor(target.stats.hp / 2) or target.stats.hp
|
||||||
require("src.core.Sound").play(data, "Heal_HP")
|
require("src.core.Sound").play(data, "Heal_HP")
|
||||||
return "consumed", { ("%s\nis revitalized!"):format(monName(data, target)) }
|
return "consumed", { Strings("%s\nis revitalized!", monName(data, target)) }
|
||||||
end
|
end
|
||||||
|
|
||||||
if itemId == "RARE_CANDY" then
|
if itemId == "RARE_CANDY" then
|
||||||
if not target or target.level >= 100 then
|
if not target or target.level >= 100 then
|
||||||
return "failed", { "It won't have\nany effect." }
|
return "failed", { Strings("It won't have\nany effect.") }
|
||||||
end
|
end
|
||||||
local Growth = require("src.pokemon.Growth")
|
local Growth = require("src.pokemon.Growth")
|
||||||
local Stats = require("src.pokemon.Stats")
|
local Stats = require("src.pokemon.Stats")
|
||||||
@@ -302,56 +304,56 @@ function ItemEffects.use(data, save, itemId, target, battle, moveIndex, ow)
|
|||||||
local old = target.stats
|
local old = target.stats
|
||||||
target.stats = Stats.calc(speciesDef, target.level, target.dvs, target.statExp)
|
target.stats = Stats.calc(speciesDef, target.level, target.dvs, target.statExp)
|
||||||
target.hp = math.min(target.stats.hp, target.hp + (target.stats.hp - old.hp))
|
target.hp = math.min(target.stats.hp, target.hp + (target.stats.hp - old.hp))
|
||||||
return "consumed", { ("%s grew\nto level %d!"):format(monName(data, target), target.level) },
|
return "consumed", { Strings("%s grew\nto level %d!", monName(data, target), target.level) },
|
||||||
{ leveledTo = target.level }
|
{ leveledTo = target.level }
|
||||||
end
|
end
|
||||||
|
|
||||||
if STONES[itemId] then
|
if STONES[itemId] then
|
||||||
if not target then return "failed", { "It won't have\nany effect." } end
|
if not target then return "failed", { Strings("It won't have\nany effect.") } end
|
||||||
local speciesDef = data.pokemon[target.species]
|
local speciesDef = data.pokemon[target.species]
|
||||||
for _, evo in ipairs(speciesDef.evolutions) do
|
for _, evo in ipairs(speciesDef.evolutions) do
|
||||||
if evo.method == "ITEM" and evo.item == itemId then
|
if evo.method == "ITEM" and evo.item == itemId then
|
||||||
return "consumed", nil, { evolveTo = evo.species }
|
return "consumed", nil, { evolveTo = evo.species }
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
return "failed", { "It won't have\nany effect." }
|
return "failed", { Strings("It won't have\nany effect.") }
|
||||||
end
|
end
|
||||||
|
|
||||||
-- vitamins: +2560 stat exp, refused at 25600+ (ItemUseVitamin,
|
-- vitamins: +2560 stat exp, refused at 25600+ (ItemUseVitamin,
|
||||||
-- engine/items/item_effects.asm)
|
-- engine/items/item_effects.asm)
|
||||||
local vitaminStat = VITAMINS[itemId]
|
local vitaminStat = VITAMINS[itemId]
|
||||||
if vitaminStat then
|
if vitaminStat then
|
||||||
if not target then return "failed", { "It won't have\nany effect." } end
|
if not target then return "failed", { Strings("It won't have\nany effect.") } end
|
||||||
target.statExp = target.statExp or {}
|
target.statExp = target.statExp or {}
|
||||||
local cur = target.statExp[vitaminStat] or 0
|
local cur = target.statExp[vitaminStat] or 0
|
||||||
if cur >= 25600 then
|
if cur >= 25600 then
|
||||||
return "failed", { "It won't have\nany effect." }
|
return "failed", { Strings("It won't have\nany effect.") }
|
||||||
end
|
end
|
||||||
target.statExp[vitaminStat] = math.min(65535, cur + 2560)
|
target.statExp[vitaminStat] = math.min(65535, cur + 2560)
|
||||||
local Stats = require("src.pokemon.Stats")
|
local Stats = require("src.pokemon.Stats")
|
||||||
target.stats = Stats.calc(data.pokemon[target.species], target.level,
|
target.stats = Stats.calc(data.pokemon[target.species], target.level,
|
||||||
target.dvs, target.statExp)
|
target.dvs, target.statExp)
|
||||||
target.hp = math.min(target.hp, target.stats.hp)
|
target.hp = math.min(target.hp, target.stats.hp)
|
||||||
return "consumed", { ("%s's %s\nrose!"):format(monName(data, target),
|
return "consumed", { Strings("%s's %s\nrose!", monName(data, target),
|
||||||
vitaminStat == "hp" and "HP" or vitaminStat:upper()) }
|
vitaminStat == "hp" and "HP" or vitaminStat:upper()) }
|
||||||
end
|
end
|
||||||
|
|
||||||
-- PP UP boosts the move the player picked (ItemUsePPUp's move menu)
|
-- PP UP boosts the move the player picked (ItemUsePPUp's move menu)
|
||||||
if itemId == "PP_UP" then
|
if itemId == "PP_UP" then
|
||||||
if not target then return "failed", { "It won't have\nany effect." } end
|
if not target then return "failed", { Strings("It won't have\nany effect.") } end
|
||||||
local mv = target.moves[moveIndex or 1]
|
local mv = target.moves[moveIndex or 1]
|
||||||
local mdef = mv and data.moves[mv.id]
|
local mdef = mv and data.moves[mv.id]
|
||||||
if mdef and (mv.ppUps or 0) < 3 then
|
if mdef and (mv.ppUps or 0) < 3 then
|
||||||
mv.ppUps = (mv.ppUps or 0) + 1
|
mv.ppUps = (mv.ppUps or 0) + 1
|
||||||
-- each PP UP adds maxPP/5 uses on top of the base maximum
|
-- each PP UP adds maxPP/5 uses on top of the base maximum
|
||||||
mv.pp = mv.pp + math.floor(mdef.pp / 5)
|
mv.pp = mv.pp + math.floor(mdef.pp / 5)
|
||||||
return "consumed", { ("%s's PP\nincreased!"):format(mdef.name) }
|
return "consumed", { Strings("%s's PP\nincreased!", mdef.name) }
|
||||||
end
|
end
|
||||||
return "failed", { "It won't have\nany effect." }
|
return "failed", { Strings("It won't have\nany effect.") }
|
||||||
end
|
end
|
||||||
|
|
||||||
if itemDef and itemDef.machine then
|
if itemDef and itemDef.machine then
|
||||||
if not target then return "failed", { "It won't have\nany effect." } end
|
if not target then return "failed", { Strings("It won't have\nany effect.") } end
|
||||||
local speciesDef = data.pokemon[target.species]
|
local speciesDef = data.pokemon[target.species]
|
||||||
local ok = false
|
local ok = false
|
||||||
for _, m in ipairs(speciesDef.tmhm) do
|
for _, m in ipairs(speciesDef.tmhm) do
|
||||||
@@ -362,11 +364,11 @@ function ItemEffects.use(data, save, itemId, target, battle, moveIndex, ow)
|
|||||||
-- plays SFX_DENIED before MonCannotLearnMachineMoveText (the generic
|
-- plays SFX_DENIED before MonCannotLearnMachineMoveText (the generic
|
||||||
-- ItemUseNotTime/NoCyclingAllowedHere paths are silent)
|
-- ItemUseNotTime/NoCyclingAllowedHere paths are silent)
|
||||||
require("src.core.Sound").play(data, "Denied")
|
require("src.core.Sound").play(data, "Denied")
|
||||||
return "failed", { ("%s can't\nlearn that move!"):format(monName(data, target)) }
|
return "failed", { Strings("%s can't\nlearn that move!", monName(data, target)) }
|
||||||
end
|
end
|
||||||
for _, mv in ipairs(target.moves) do
|
for _, mv in ipairs(target.moves) do
|
||||||
if mv.id == itemDef.machine.move then
|
if mv.id == itemDef.machine.move then
|
||||||
return "failed", { "It knows that\nmove already!" }
|
return "failed", { Strings("It knows that\nmove already!") }
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
-- HMs are never consumed; TMs are single-use
|
-- HMs are never consumed; TMs are single-use
|
||||||
@@ -375,14 +377,14 @@ function ItemEffects.use(data, save, itemId, target, battle, moveIndex, ow)
|
|||||||
|
|
||||||
if itemId == "OLD_ROD" or itemId == "GOOD_ROD" or itemId == "SUPER_ROD" then
|
if itemId == "OLD_ROD" or itemId == "GOOD_ROD" or itemId == "SUPER_ROD" then
|
||||||
if battle then
|
if battle then
|
||||||
return "failed", { "OAK: " .. save.player.name .. "!\nThis isn't the\ntime to use that!" }
|
return "failed", { Strings("OAK: %s!\nThis isn't the\ntime to use that!", save.player.name) }
|
||||||
end
|
end
|
||||||
return "fish", itemId
|
return "fish", itemId
|
||||||
end
|
end
|
||||||
|
|
||||||
if itemId == "BICYCLE" then
|
if itemId == "BICYCLE" then
|
||||||
if battle then
|
if battle then
|
||||||
return "failed", { "OAK: " .. save.player.name .. "!\nThis isn't the\ntime to use that!" }
|
return "failed", { Strings("OAK: %s!\nThis isn't the\ntime to use that!", save.player.name) }
|
||||||
end
|
end
|
||||||
return "bicycle"
|
return "bicycle"
|
||||||
end
|
end
|
||||||
@@ -392,26 +394,26 @@ function ItemEffects.use(data, save, itemId, target, battle, moveIndex, ow)
|
|||||||
end
|
end
|
||||||
if itemId == "TOWN_MAP" then
|
if itemId == "TOWN_MAP" then
|
||||||
if battle then
|
if battle then
|
||||||
return "failed", { "OAK: " .. save.player.name .. "!\nThis isn't the\ntime to use that!" }
|
return "failed", { Strings("OAK: %s!\nThis isn't the\ntime to use that!", save.player.name) }
|
||||||
end
|
end
|
||||||
return "townmap"
|
return "townmap"
|
||||||
end
|
end
|
||||||
if itemId == "ITEMFINDER" then
|
if itemId == "ITEMFINDER" then
|
||||||
if battle then
|
if battle then
|
||||||
return "failed", { "OAK: " .. save.player.name .. "!\nThis isn't the\ntime to use that!" }
|
return "failed", { Strings("OAK: %s!\nThis isn't the\ntime to use that!", save.player.name) }
|
||||||
end
|
end
|
||||||
return "itemfinder"
|
return "itemfinder"
|
||||||
end
|
end
|
||||||
if itemId == "COIN_CASE" then
|
if itemId == "COIN_CASE" then
|
||||||
return "failed", { ("Coin count:\n%d"):format(save.coins or 0) }
|
return "failed", { Strings("Coin count:\n%d", save.coins or 0) }
|
||||||
end
|
end
|
||||||
if itemId == "REPEL" or itemId == "SUPER_REPEL" or itemId == "MAX_REPEL" then
|
if itemId == "REPEL" or itemId == "SUPER_REPEL" or itemId == "MAX_REPEL" then
|
||||||
local steps = itemId == "REPEL" and 100 or itemId == "SUPER_REPEL" and 200 or 250
|
local steps = itemId == "REPEL" and 100 or itemId == "SUPER_REPEL" and 200 or 250
|
||||||
save.repelSteps = steps
|
save.repelSteps = steps
|
||||||
return "consumed", { ("%s used\n%s!"):format(save.player.name, name) }
|
return "consumed", { Strings("%s used\n%s!", save.player.name, name) }
|
||||||
end
|
end
|
||||||
|
|
||||||
return "failed", { "OAK: " .. save.player.name .. "!\nThis isn't the\ntime to use that!" }
|
return "failed", { Strings("OAK: %s!\nThis isn't the\ntime to use that!", save.player.name) }
|
||||||
end
|
end
|
||||||
|
|
||||||
return ItemEffects
|
return ItemEffects
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ local Logger = require("src.core.Logger")
|
|||||||
local Protocol = require("src.link.Protocol")
|
local Protocol = require("src.link.Protocol")
|
||||||
local Runtime = require("src.mods.Runtime")
|
local Runtime = require("src.mods.Runtime")
|
||||||
local TurnOrder = require("src.battle.TurnOrder")
|
local TurnOrder = require("src.battle.TurnOrder")
|
||||||
|
local Strings = require("src.core.Strings")
|
||||||
|
|
||||||
local LinkBattle = {}
|
local LinkBattle = {}
|
||||||
|
|
||||||
@@ -177,18 +178,18 @@ function LinkBattle.new(game, net, opts)
|
|||||||
local theirName = opts.theirName or "FOE"
|
local theirName = opts.theirName or "FOE"
|
||||||
|
|
||||||
if not Handshake.battleAllowed(opts.verdict) then
|
if not Handshake.battleAllowed(opts.verdict) then
|
||||||
return nil, "Link battle needs\nthe same mods on\nboth games."
|
return nil, Strings("Link battle needs\nthe same mods on\nboth games.")
|
||||||
end
|
end
|
||||||
|
|
||||||
-- 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, forceLevel = opts.forceLevel }
|
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 Strings("Your %s can't\nbattle on the\nother game.", tostring(p.species))
|
||||||
end)
|
end)
|
||||||
if not myParty then return nil, myErr end
|
if not myParty then return nil, myErr end
|
||||||
local theirParty, theirErr = unpackParty(game, opts.theirParty, unpackOpts, function(p, why)
|
local theirParty, theirErr = unpackParty(game, opts.theirParty, unpackOpts, function(p, why)
|
||||||
return ("Their %s isn't\nin this game.\n(%s)"):format(tostring(p.species), tostring(why))
|
return Strings("Their %s isn't\nin this game.\n(%s)", tostring(p.species), tostring(why))
|
||||||
end)
|
end)
|
||||||
if not theirParty then return nil, theirErr end
|
if not theirParty then return nil, theirErr end
|
||||||
if #myParty == 0 or #theirParty == 0 then
|
if #myParty == 0 or #theirParty == 0 then
|
||||||
@@ -232,7 +233,7 @@ function LinkBattle.new(game, net, opts)
|
|||||||
self.enemyParty = theirParty
|
self.enemyParty = theirParty
|
||||||
self.playerParty = myParty -- intro ball row uses the clamped copies
|
self.playerParty = myParty -- intro ball row uses the clamped copies
|
||||||
self.opponentName = theirName
|
self.opponentName = theirName
|
||||||
self.introText = ("%s wants\nto battle!"):format(theirName)
|
self.introText = Strings("%s wants\nto battle!", theirName)
|
||||||
self.remoteHashes = {}
|
self.remoteHashes = {}
|
||||||
self.localHashes = {}
|
self.localHashes = {}
|
||||||
self.remoteParts = {}
|
self.remoteParts = {}
|
||||||
@@ -292,7 +293,7 @@ function LinkBattle.new(game, net, opts)
|
|||||||
battle = s, side = s.sides[2], battler = s.enemy, previous = previous,
|
battle = s, side = s.sides[2], battler = s.enemy, previous = previous,
|
||||||
})
|
})
|
||||||
s.enemySendingOut = true
|
s.enemySendingOut = true
|
||||||
s:sayNext(("%s sent\nout %s!"):format(theirName, s.enemy.name))
|
s:sayNext(Strings("%s sent\nout %s!", theirName, s.enemy.name))
|
||||||
s:actNext(function()
|
s:actNext(function()
|
||||||
s.enemySendingOut = false
|
s.enemySendingOut = false
|
||||||
s:startGrowIn(s.enemy)
|
s:startGrowIn(s.enemy)
|
||||||
@@ -315,8 +316,9 @@ function LinkBattle.new(game, net, opts)
|
|||||||
tostring(turn), component, tostring(localH), tostring(remoteH))
|
tostring(turn), component, tostring(localH), tostring(remoteH))
|
||||||
Runtime.emit("link.desync", { turn = turn, component = component,
|
Runtime.emit("link.desync", { turn = turn, component = component,
|
||||||
localHash = localH, remoteHash = remoteH })
|
localHash = localH, remoteHash = remoteH })
|
||||||
endAsDraw(s, ("Link desync!\n%s differs.\fAre both games\nrunning the same\nmods?")
|
endAsDraw(s, Strings(
|
||||||
:format(component))
|
"Link desync!\n%s differs.\fAre both games\nrunning the same\nmods?",
|
||||||
|
component))
|
||||||
end
|
end
|
||||||
|
|
||||||
-- a verified turn stays recorded: consuming it here left a finished
|
-- a verified turn stays recorded: consuming it here left a finished
|
||||||
@@ -349,7 +351,7 @@ function LinkBattle.new(game, net, opts)
|
|||||||
local function resolveLockstep(s, myMsg, theirMsg)
|
local function resolveLockstep(s, myMsg, theirMsg)
|
||||||
if myMsg.kind == "run" or theirMsg.kind == "run" then
|
if myMsg.kind == "run" or theirMsg.kind == "run" then
|
||||||
local who = myMsg.kind == "run" and game.save.player.name or theirName
|
local who = myMsg.kind == "run" and game.save.player.name or theirName
|
||||||
endAsDraw(s, ("%s ran from\nthe battle!"):format(who))
|
endAsDraw(s, Strings("%s ran from\nthe battle!", who))
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
s.phase = "messages"
|
s.phase = "messages"
|
||||||
@@ -479,9 +481,9 @@ function LinkBattle.new(game, net, opts)
|
|||||||
party = myParty,
|
party = myParty,
|
||||||
onSwitch = function(mon)
|
onSwitch = function(mon)
|
||||||
if mon == s.player.mon then
|
if mon == s.player.mon then
|
||||||
s:say(("%s is\nalready out!"):format(s.player.name))
|
s:say(Strings("%s is\nalready out!", s.player.name))
|
||||||
elseif mon.hp <= 0 then
|
elseif mon.hp <= 0 then
|
||||||
s:say("There's no will\nto fight!")
|
s:say(Strings("There's no will\nto fight!"))
|
||||||
else
|
else
|
||||||
s:resolveSwitch(mon)
|
s:resolveSwitch(mon)
|
||||||
end
|
end
|
||||||
@@ -491,7 +493,7 @@ function LinkBattle.new(game, net, opts)
|
|||||||
end
|
end
|
||||||
|
|
||||||
self.openItems = function(s)
|
self.openItems = function(s)
|
||||||
s:say("Items can't be\nused in a link\nbattle!")
|
s:say(Strings("Items can't be\nused in a link\nbattle!"))
|
||||||
s.phase = "messages"
|
s.phase = "messages"
|
||||||
s.afterQueue = "menu"
|
s.afterQueue = "menu"
|
||||||
end
|
end
|
||||||
@@ -509,7 +511,7 @@ function LinkBattle.new(game, net, opts)
|
|||||||
return
|
return
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
s:sayNext(("%s is out of\nPOKéMON!\f%s wins!"):format(game.save.player.name,
|
s:sayNext(Strings("%s is out of\nPOKéMON!\f%s wins!", game.save.player.name,
|
||||||
theirName))
|
theirName))
|
||||||
s.result = "lose"
|
s.result = "lose"
|
||||||
s.afterQueue = "finish"
|
s.afterQueue = "finish"
|
||||||
@@ -522,7 +524,7 @@ function LinkBattle.new(game, net, opts)
|
|||||||
return
|
return
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
s:sayNext(("%s is out of\nPOKéMON!\f%s wins!"):format(theirName,
|
s:sayNext(Strings("%s is out of\nPOKéMON!\f%s wins!", theirName,
|
||||||
game.save.player.name))
|
game.save.player.name))
|
||||||
s.result = "win"
|
s.result = "win"
|
||||||
s.afterQueue = "finish"
|
s.afterQueue = "finish"
|
||||||
@@ -543,13 +545,13 @@ function LinkBattle.new(game, net, opts)
|
|||||||
-- only a draw if our own simulation hasn't already decided
|
-- only a draw if our own simulation hasn't already decided
|
||||||
-- (the winner's bye can arrive while we're still animating)
|
-- (the winner's bye can arrive while we're still animating)
|
||||||
if not s.result then
|
if not s.result then
|
||||||
endAsDraw(s, ("%s left the\nbattle."):format(theirName))
|
endAsDraw(s, Strings("%s left the\nbattle.", theirName))
|
||||||
end
|
end
|
||||||
elseif msg.type == "forfeit" then
|
elseif msg.type == "forfeit" then
|
||||||
-- the peer's own shot clock ran out; unlike a mutual RUN/desync
|
-- the peer's own shot clock ran out; unlike a mutual RUN/desync
|
||||||
-- draw, this has a definite winner (us)
|
-- draw, this has a definite winner (us)
|
||||||
if not s.result then
|
if not s.result then
|
||||||
endWithResult(s, "win", ("%s ran out of\ntime!"):format(theirName))
|
endWithResult(s, "win", Strings("%s ran out of\ntime!", theirName))
|
||||||
end
|
end
|
||||||
else
|
else
|
||||||
-- a tournament control message (bracket_update, the next
|
-- a tournament control message (bracket_update, the next
|
||||||
@@ -582,7 +584,7 @@ function LinkBattle.new(game, net, opts)
|
|||||||
if s.turnClock <= 0 then
|
if s.turnClock <= 0 then
|
||||||
s.turnClockActive = false
|
s.turnClockActive = false
|
||||||
send({ type = "forfeit" })
|
send({ type = "forfeit" })
|
||||||
endWithResult(s, "lose", "Time's up! You\nforfeit the match.")
|
endWithResult(s, "lose", Strings("Time's up! You\nforfeit the match."))
|
||||||
end
|
end
|
||||||
elseif opts.turnLimit then
|
elseif opts.turnLimit then
|
||||||
s.turnClockActive = false
|
s.turnClockActive = false
|
||||||
@@ -643,17 +645,16 @@ function LinkBattle.newSpectator(game, net, opts)
|
|||||||
local guestName = opts.guestName or "GUEST"
|
local guestName = opts.guestName or "GUEST"
|
||||||
|
|
||||||
if not Handshake.battleAllowed(opts.verdict) then
|
if not Handshake.battleAllowed(opts.verdict) then
|
||||||
return nil, "Link battle needs\nthe same mods on\nboth games."
|
return nil, Strings("Link battle needs\nthe same mods on\nboth games.")
|
||||||
end
|
end
|
||||||
|
|
||||||
local unpackOpts = { strict = opts.strict or false, forceLevel = opts.forceLevel }
|
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 Strings("%s's %s can't\nbattle on this\ngame.", hostName, tostring(p.species))
|
||||||
end)
|
end)
|
||||||
if not hostParty then return nil, hostErr end
|
if not hostParty then return nil, hostErr end
|
||||||
local guestParty, guestErr = unpackParty(game, opts.guestParty, unpackOpts, function(p, why)
|
local guestParty, guestErr = unpackParty(game, opts.guestParty, unpackOpts, function(p, why)
|
||||||
return ("%s's %s can't\nbattle on this\ngame.\n(%s)"):format(
|
return Strings("%s's %s can't\nbattle on this\ngame.\n(%s)", guestName, tostring(p.species), tostring(why))
|
||||||
guestName, tostring(p.species), tostring(why))
|
|
||||||
end)
|
end)
|
||||||
if not guestParty then return nil, guestErr end
|
if not guestParty then return nil, guestErr end
|
||||||
if #hostParty == 0 or #guestParty == 0 then
|
if #hostParty == 0 or #guestParty == 0 then
|
||||||
@@ -672,7 +673,7 @@ function LinkBattle.newSpectator(game, net, opts)
|
|||||||
self.enemyParty = guestParty
|
self.enemyParty = guestParty
|
||||||
self.playerParty = hostParty
|
self.playerParty = hostParty
|
||||||
self.opponentName = guestName
|
self.opponentName = guestName
|
||||||
self.introText = ("%s vs %s!"):format(hostName, guestName)
|
self.introText = Strings("%s vs %s!", hostName, guestName)
|
||||||
|
|
||||||
local function orderMove(action)
|
local function orderMove(action)
|
||||||
if action and action.id then return game.data.moves[action.id] end
|
if action and action.id then return game.data.moves[action.id] end
|
||||||
@@ -712,7 +713,7 @@ function LinkBattle.newSpectator(game, net, opts)
|
|||||||
battle = s, side = s.sides[2], battler = s.enemy, previous = previous,
|
battle = s, side = s.sides[2], battler = s.enemy, previous = previous,
|
||||||
})
|
})
|
||||||
s.enemySendingOut = true
|
s.enemySendingOut = true
|
||||||
s:sayNext(("%s sent\nout %s!"):format(guestName, s.enemy.name))
|
s:sayNext(Strings("%s sent\nout %s!", guestName, s.enemy.name))
|
||||||
s:actNext(function()
|
s:actNext(function()
|
||||||
s.enemySendingOut = false
|
s.enemySendingOut = false
|
||||||
s:startGrowIn(s.enemy)
|
s:startGrowIn(s.enemy)
|
||||||
@@ -789,7 +790,7 @@ function LinkBattle.newSpectator(game, net, opts)
|
|||||||
return
|
return
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
s:sayNext(("%s is out of\nPOKéMON!\f%s wins!"):format(hostName, guestName))
|
s:sayNext(Strings("%s is out of\nPOKéMON!\f%s wins!", hostName, guestName))
|
||||||
s.result = "guestWin"
|
s.result = "guestWin"
|
||||||
s.afterQueue = "finish"
|
s.afterQueue = "finish"
|
||||||
end
|
end
|
||||||
@@ -801,7 +802,7 @@ function LinkBattle.newSpectator(game, net, opts)
|
|||||||
return
|
return
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
s:sayNext(("%s is out of\nPOKéMON!\f%s wins!"):format(guestName, hostName))
|
s:sayNext(Strings("%s is out of\nPOKéMON!\f%s wins!", guestName, hostName))
|
||||||
s.result = "hostWin"
|
s.result = "hostWin"
|
||||||
s.afterQueue = "finish"
|
s.afterQueue = "finish"
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ local Protocol = require("src.link.Protocol")
|
|||||||
local Runtime = require("src.mods.Runtime")
|
local Runtime = require("src.mods.Runtime")
|
||||||
local Screens = require("src.ui.Screens")
|
local Screens = require("src.ui.Screens")
|
||||||
local TextBox = require("src.render.TextBox")
|
local TextBox = require("src.render.TextBox")
|
||||||
|
local Strings = require("src.core.Strings")
|
||||||
|
|
||||||
local LinkState = {}
|
local LinkState = {}
|
||||||
LinkState.__index = LinkState
|
LinkState.__index = LinkState
|
||||||
@@ -31,7 +32,12 @@ local function indexOf(list, value)
|
|||||||
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
|
end
|
||||||
|
|
||||||
local function forceLevelLabel(v)
|
local function forceLevelLabel(v)
|
||||||
@@ -85,7 +91,7 @@ function LinkState.newJoinOnline(game, code)
|
|||||||
self.stage = "onlineJoining"
|
self.stage = "onlineJoining"
|
||||||
else
|
else
|
||||||
self.stage = "menu" -- exitWith below needs a real stage to unwind from
|
self.stage = "menu" -- exitWith below needs a real stage to unwind from
|
||||||
self:exitWith("Link error:\n" .. (self.net.error or "?"))
|
self:exitWith(Strings("Link error:\n%s", self.net.error or "?"))
|
||||||
end
|
end
|
||||||
return self
|
return self
|
||||||
end
|
end
|
||||||
@@ -169,7 +175,7 @@ function LinkState:update(dt)
|
|||||||
if self.net then
|
if self.net then
|
||||||
self.net:update()
|
self.net:update()
|
||||||
if self.net.error and not PRE_CONNECT_STAGES[self.stage] then
|
if self.net.error and not PRE_CONNECT_STAGES[self.stage] then
|
||||||
self:exitWith("Link error:\n" .. self.net.error:sub(1, 60))
|
self:exitWith(Strings("Link error:\n%s", self.net.error:sub(1, 60)))
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
-- the peer vanished without a bye (only once the inbox is drained,
|
-- the peer vanished without a bye (only once the inbox is drained,
|
||||||
@@ -178,7 +184,7 @@ function LinkState:update(dt)
|
|||||||
and not PRE_CONNECT_STAGES[self.stage] and self.stage ~= "addrEntry"
|
and not PRE_CONNECT_STAGES[self.stage] and self.stage ~= "addrEntry"
|
||||||
and self.stage ~= "codeEntry" and self.stage ~= "notice"
|
and self.stage ~= "codeEntry" and self.stage ~= "notice"
|
||||||
and self.stage ~= "battleRunning" then
|
and self.stage ~= "battleRunning" then
|
||||||
self:exitWith("The link was\nbroken.")
|
self:exitWith(Strings("The link was\nbroken."))
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
@@ -196,7 +202,7 @@ function LinkState:update(dt)
|
|||||||
self.index = 1
|
self.index = 1
|
||||||
elseif self.index == 2 or self.index == 3 then
|
elseif self.index == 2 or self.index == 3 then
|
||||||
if not Handshake.onlineAllowed(self.game) then
|
if not Handshake.onlineAllowed(self.game) then
|
||||||
self:exitWith("Online play needs\nno mods enabled.\fDisable them in\nSTART > MODS.")
|
self:exitWith(Strings("Online play needs\nno mods enabled.\fDisable them in\nSTART > MODS."))
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
if self.index == 2 then
|
if self.index == 2 then
|
||||||
@@ -222,7 +228,7 @@ function LinkState:update(dt)
|
|||||||
if self.net:host() then
|
if self.net:host() then
|
||||||
self.stage = "hosting"
|
self.stage = "hosting"
|
||||||
else
|
else
|
||||||
self:exitWith("Link error:\n" .. (self.net.error or "?"))
|
self:exitWith(Strings("Link error:\n%s", self.net.error or "?"))
|
||||||
end
|
end
|
||||||
else
|
else
|
||||||
self.stage = "addrEntry"
|
self.stage = "addrEntry"
|
||||||
@@ -241,7 +247,7 @@ function LinkState:update(dt)
|
|||||||
if self.net:hostOnline() then
|
if self.net:hostOnline() then
|
||||||
self.stage = "onlineHosting"
|
self.stage = "onlineHosting"
|
||||||
else
|
else
|
||||||
self:exitWith("Link error:\n" .. (self.net.error or "?"))
|
self:exitWith(Strings("Link error:\n%s", self.net.error or "?"))
|
||||||
end
|
end
|
||||||
else
|
else
|
||||||
self.stage = "codeEntry"
|
self.stage = "codeEntry"
|
||||||
@@ -279,7 +285,7 @@ function LinkState:update(dt)
|
|||||||
if self.net:joinOnline(nil, code) then
|
if self.net:joinOnline(nil, code) then
|
||||||
self.stage = "onlineJoining"
|
self.stage = "onlineJoining"
|
||||||
else
|
else
|
||||||
self:exitWith("Link error:\n" .. (self.net.error or "?"))
|
self:exitWith(Strings("Link error:\n%s", self.net.error or "?"))
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -318,7 +324,7 @@ function LinkState:update(dt)
|
|||||||
if self.net:join(table.concat(octets, ".")) then
|
if self.net:join(table.concat(octets, ".")) then
|
||||||
self.stage = "joining"
|
self.stage = "joining"
|
||||||
else
|
else
|
||||||
self:exitWith("Link error:\n" .. (self.net.error or "?"))
|
self:exitWith(Strings("Link error:\n%s", self.net.error or "?"))
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -428,7 +434,7 @@ function LinkState:update(dt)
|
|||||||
end
|
end
|
||||||
if not battle then
|
if not battle then
|
||||||
self.net:send({ type = "bye" })
|
self.net:send({ type = "bye" })
|
||||||
self:exitWith(why or "Link battle\ncan't start.", "error")
|
self:exitWith(why or Strings("Link battle\ncan't start."), "error")
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
self.game.stack:push(battle)
|
self.game.stack:push(battle)
|
||||||
@@ -486,13 +492,21 @@ function LinkState:updateTrade(input)
|
|||||||
local t = self.trade
|
local t = self.trade
|
||||||
|
|
||||||
if t.stage == "cancelled" then
|
if t.stage == "cancelled" then
|
||||||
self:exitWith(t.error and ("The trade stopped:\n%s."):format(t.error)
|
self:exitWith(t.error and Strings("The trade stopped:\n%s.", t.error)
|
||||||
or "The trade was\ncancelled.")
|
or Strings("The trade was\ncancelled."))
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
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()
|
||||||
@@ -507,10 +521,16 @@ function LinkState:updateTrade(input)
|
|||||||
enemyOtId = received.otId,
|
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),
|
Strings("Trade completed!\f%s received\n%s!", 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,
|
||||||
@@ -528,7 +548,7 @@ function LinkState:updateTrade(input)
|
|||||||
-- the trade) -- B is dead after that, matching the A branch's own
|
-- the trade) -- B is dead after that, matching the A branch's own
|
||||||
-- self.confirmed == nil guard
|
-- self.confirmed == nil guard
|
||||||
self.net:send({ type = "bye" })
|
self.net:send({ type = "bye" })
|
||||||
self:exitWith("The trade was\ncancelled.")
|
self:exitWith(Strings("The trade was\ncancelled."))
|
||||||
elseif t.stage == "picking" and input:wasPressed("a") then
|
elseif t.stage == "picking" and input:wasPressed("a") then
|
||||||
if t:canPick(self.index) then
|
if t:canPick(self.index) then
|
||||||
self.net:send(t:pick(self.index))
|
self.net:send(t:pick(self.index))
|
||||||
@@ -555,30 +575,30 @@ end
|
|||||||
function LinkState:draw()
|
function LinkState:draw()
|
||||||
if self.stage == "menu" then
|
if self.stage == "menu" then
|
||||||
drawTitle("BOIS CLUB LIVE")
|
drawTitle("BOIS CLUB LIVE")
|
||||||
Font.draw("LINK CABLE (LAN)", 32, 44)
|
Font.draw(Strings("LINK CABLE (LAN)"), 32, 44)
|
||||||
Font.draw("ONLINE MATCH", 32, 60)
|
Font.draw(Strings("ONLINE MATCH"), 32, 60)
|
||||||
Font.draw("TOURNAMENT", 32, 76)
|
Font.draw(Strings("TOURNAMENT"), 32, 76)
|
||||||
Font.drawCode(CURSOR, 24, 44 + (self.index - 1) * 16)
|
Font.drawCode(CURSOR, 24, 44 + (self.index - 1) * 16)
|
||||||
|
|
||||||
elseif self.stage == "lanMenu" then
|
elseif self.stage == "lanMenu" then
|
||||||
drawTitle("LINK CABLE (LAN)")
|
drawTitle("LINK CABLE (LAN)")
|
||||||
Font.draw("HOST A GAME", 32, 48)
|
Font.draw(Strings("HOST A GAME"), 32, 48)
|
||||||
Font.draw("JOIN A GAME", 32, 68)
|
Font.draw(Strings("JOIN A GAME"), 32, 68)
|
||||||
Font.drawCode(CURSOR, 24, self.index == 1 and 48 or 68)
|
Font.drawCode(CURSOR, 24, self.index == 1 and 48 or 68)
|
||||||
Font.draw("UDP port " .. Net.defaultPort(), 8, 128)
|
Font.draw(Strings("UDP port %s", Net.defaultPort()), 8, 128)
|
||||||
|
|
||||||
elseif self.stage == "onlineMenu" then
|
elseif self.stage == "onlineMenu" then
|
||||||
drawTitle("ONLINE MATCH")
|
drawTitle("ONLINE MATCH")
|
||||||
Font.draw("HOST ONLINE", 32, 48)
|
Font.draw(Strings("HOST ONLINE"), 32, 48)
|
||||||
Font.draw("JOIN ONLINE", 32, 68)
|
Font.draw(Strings("JOIN ONLINE"), 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 == "onlineHosting" then
|
elseif self.stage == "onlineHosting" then
|
||||||
drawTitle("HOSTING ONLINE")
|
drawTitle("HOSTING ONLINE")
|
||||||
Font.draw("Tell your friend", 16, 40)
|
Font.draw(Strings("Tell your friend"), 16, 40)
|
||||||
Font.draw("the code:", 16, 52)
|
Font.draw(Strings("the code:"), 16, 52)
|
||||||
Font.draw(self.net.code or "??????", 32, 68)
|
Font.draw(self.net.code or "??????", 32, 68)
|
||||||
Font.draw("Waiting for join...", 8, 96)
|
Font.draw(Strings("Waiting for join..."), 8, 96)
|
||||||
|
|
||||||
elseif self.stage == "codeEntry" then
|
elseif self.stage == "codeEntry" then
|
||||||
drawTitle("ENTER CODE")
|
drawTitle("ENTER CODE")
|
||||||
@@ -590,18 +610,18 @@ function LinkState:draw()
|
|||||||
Font.drawCode(0xEE, x, 76) -- ▼ under the active slot
|
Font.drawCode(0xEE, x, 76) -- ▼ under the active slot
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
Font.draw("A: connect B: back", 8, 128)
|
Font.draw(Strings("A: connect B: back"), 8, 128)
|
||||||
|
|
||||||
elseif self.stage == "onlineJoining" then
|
elseif self.stage == "onlineJoining" then
|
||||||
drawTitle("CONNECTING...")
|
drawTitle("CONNECTING...")
|
||||||
Font.draw("Calling...", 8, 56)
|
Font.draw(Strings("Calling..."), 8, 56)
|
||||||
Font.draw(self.net.target or "", 8, 72)
|
Font.draw(self.net.target or "", 8, 72)
|
||||||
|
|
||||||
elseif self.stage == "hosting" then
|
elseif self.stage == "hosting" then
|
||||||
drawTitle("HOSTING")
|
drawTitle("HOSTING")
|
||||||
Font.draw("Friend joins at:", 16, 48)
|
Font.draw(Strings("Friend joins at:"), 16, 48)
|
||||||
Font.draw(self.net.address or "?", 16, 64)
|
Font.draw(self.net.address or "?", 16, 64)
|
||||||
Font.draw("Waiting for join...", 8, 96)
|
Font.draw(Strings("Waiting for join..."), 8, 96)
|
||||||
|
|
||||||
elseif self.stage == "addrEntry" then
|
elseif self.stage == "addrEntry" then
|
||||||
drawTitle("ENTER HOST ADDRESS")
|
drawTitle("ENTER HOST ADDRESS")
|
||||||
@@ -616,34 +636,34 @@ function LinkState:draw()
|
|||||||
for octet = 1, 3 do
|
for octet = 1, 3 do
|
||||||
Font.draw(".", 16 + octet * 32 - 8, 64)
|
Font.draw(".", 16 + octet * 32 - 8, 64)
|
||||||
end
|
end
|
||||||
Font.draw("Port: " .. Net.defaultPort(), 16, 96)
|
Font.draw(Strings("Port: %s", Net.defaultPort()), 16, 96)
|
||||||
Font.draw("A: connect B: back", 8, 128)
|
Font.draw(Strings("A: connect B: back"), 8, 128)
|
||||||
|
|
||||||
elseif self.stage == "joining" then
|
elseif self.stage == "joining" then
|
||||||
drawTitle("JOINING...")
|
drawTitle("JOINING...")
|
||||||
Font.draw("Calling...", 8, 56)
|
Font.draw(Strings("Calling..."), 8, 56)
|
||||||
Font.draw(self.net.target or "", 8, 72)
|
Font.draw(self.net.target or "", 8, 72)
|
||||||
|
|
||||||
elseif self.stage == "modeSelect" then
|
elseif self.stage == "modeSelect" then
|
||||||
drawTitle("CONNECTED!")
|
drawTitle("CONNECTED!")
|
||||||
Font.draw("TRADE", 32, 48)
|
Font.draw(Strings("TRADE"), 32, 48)
|
||||||
Font.draw("BATTLE", 32, 68)
|
Font.draw(Strings("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
|
elseif self.stage == "battleOptions" then
|
||||||
drawTitle("BATTLE OPTIONS")
|
drawTitle("BATTLE OPTIONS")
|
||||||
Font.draw("LEVELS:", 16, 56)
|
Font.draw(Strings("LEVELS:"), 16, 56)
|
||||||
Font.draw(forceLevelLabel(self.levelChoice or ANY), 88, 56)
|
Font.draw(forceLevelLabel(self.levelChoice or ANY), 88, 56)
|
||||||
Font.draw("A: continue B: back", 8, 128)
|
Font.draw(Strings("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
|
||||||
Font.draw("Checking the", 16, 56)
|
Font.draw(Strings("Checking the"), 16, 56)
|
||||||
Font.draw("other game...", 16, 72)
|
Font.draw(Strings("other game..."), 16, 72)
|
||||||
else
|
else
|
||||||
Font.draw("Waiting for the", 16, 56)
|
Font.draw(Strings("Waiting for the"), 16, 56)
|
||||||
Font.draw("host to choose...", 16, 72)
|
Font.draw(Strings("host to choose..."), 16, 72)
|
||||||
end
|
end
|
||||||
|
|
||||||
elseif self.stage == "notice" then
|
elseif self.stage == "notice" then
|
||||||
@@ -652,12 +672,12 @@ function LinkState:draw()
|
|||||||
if i > 8 then break end -- what fits above the prompt row
|
if i > 8 then break end -- what fits above the prompt row
|
||||||
Font.draw(line, 8, 24 + (i - 1) * 12)
|
Font.draw(line, 8, 24 + (i - 1) * 12)
|
||||||
end
|
end
|
||||||
Font.draw(self.noticeExits and "A: back" or "A: trade anyway", 8, 128)
|
Font.draw(self.noticeExits and "A: back" or Strings("A: trade anyway"), 8, 128)
|
||||||
|
|
||||||
elseif self.stage == "trade" then
|
elseif self.stage == "trade" then
|
||||||
drawTitle("TRADE")
|
drawTitle("TRADE")
|
||||||
local t = self.trade
|
local t = self.trade
|
||||||
Font.draw("YOURS", 8, 20)
|
Font.draw(Strings("YOURS"), 8, 20)
|
||||||
for i, mon in ipairs(self.game.save.party) do
|
for i, mon in ipairs(self.game.save.party) do
|
||||||
local def = self.game.data.pokemon[mon.species]
|
local def = self.game.data.pokemon[mon.species]
|
||||||
local label = (mon.nickname or def.name):sub(1, 8)
|
local label = (mon.nickname or def.name):sub(1, 8)
|
||||||
@@ -665,7 +685,7 @@ function LinkState:draw()
|
|||||||
Font.draw(label, 16, 20 + i * 12)
|
Font.draw(label, 16, 20 + i * 12)
|
||||||
if i == self.index then Font.drawCode(CURSOR, 8, 20 + i * 12) end
|
if i == self.index then Font.drawCode(CURSOR, 8, 20 + i * 12) end
|
||||||
end
|
end
|
||||||
Font.draw("THEIRS", 84, 20)
|
Font.draw(Strings("THEIRS"), 84, 20)
|
||||||
for i, mon in ipairs(t.theirParty or {}) do
|
for i, mon in ipairs(t.theirParty or {}) do
|
||||||
local def = self.game.data.pokemon[mon.species]
|
local def = self.game.data.pokemon[mon.species]
|
||||||
Font.draw((mon.nickname or def.name):sub(1, 8), 92, 20 + i * 12)
|
Font.draw((mon.nickname or def.name):sub(1, 8), 92, 20 + i * 12)
|
||||||
@@ -676,16 +696,16 @@ function LinkState:draw()
|
|||||||
elseif t.stage == "waitParty" then hint = "Exchanging data..."
|
elseif t.stage == "waitParty" then hint = "Exchanging data..."
|
||||||
elseif t.stage == "picking" then
|
elseif t.stage == "picking" then
|
||||||
hint = t:canPick(self.index) and "Pick one to trade"
|
hint = t:canPick(self.index) and "Pick one to trade"
|
||||||
or "X: not on theirs"
|
or Strings("X: not on theirs")
|
||||||
elseif t.stage == "waitPick" then hint = "Waiting for them..."
|
elseif t.stage == "waitPick" then hint = "Waiting for them..."
|
||||||
elseif t.stage == "confirming" then
|
elseif t.stage == "confirming" then
|
||||||
hint = self.confirmed and "Waiting..." or "A: trade B: cancel"
|
hint = self.confirmed and "Waiting..." or Strings("A: trade B: cancel")
|
||||||
end
|
end
|
||||||
Font.draw(hint or "", 8, 132)
|
Font.draw(hint or "", 8, 132)
|
||||||
|
|
||||||
elseif self.stage == "battleWait" or self.stage == "battleRunning" then
|
elseif self.stage == "battleWait" or self.stage == "battleRunning" then
|
||||||
drawTitle("LINK BATTLE")
|
drawTitle("LINK BATTLE")
|
||||||
Font.draw("Exchanging data...", 16, 64)
|
Font.draw(Strings("Exchanging data..."), 16, 64)
|
||||||
end
|
end
|
||||||
love.graphics.setColor(1, 1, 1, 1)
|
love.graphics.setColor(1, 1, 1, 1)
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -26,6 +26,7 @@
|
|||||||
|
|
||||||
local Json = require("src.link.Json")
|
local Json = require("src.link.Json")
|
||||||
local Logger = require("src.core.Logger")
|
local Logger = require("src.core.Logger")
|
||||||
|
local Strings = require("src.core.Strings")
|
||||||
|
|
||||||
local hasEnet, enet = pcall(require, "enet")
|
local hasEnet, enet = pcall(require, "enet")
|
||||||
if not hasEnet then enet = nil end
|
if not hasEnet then enet = nil end
|
||||||
@@ -173,7 +174,7 @@ function Net:connectTCP(addr)
|
|||||||
tcp:settimeout(5)
|
tcp:settimeout(5)
|
||||||
local ok, err = tcp:connect(host, port)
|
local ok, err = tcp:connect(host, port)
|
||||||
if not ok then
|
if not ok then
|
||||||
self.error = ("can't reach relay %s:%d\n(%s)"):format(host, port, tostring(err))
|
self.error = Strings("can't reach relay %s:%d\n(%s)", host, port, tostring(err))
|
||||||
return false
|
return false
|
||||||
end
|
end
|
||||||
tcp:settimeout(0)
|
tcp:settimeout(0)
|
||||||
@@ -236,11 +237,14 @@ local function handleGenericRelayControl(self, msg)
|
|||||||
self.paired = true
|
self.paired = true
|
||||||
return true
|
return true
|
||||||
elseif msg.type == "join_error" then
|
elseif msg.type == "join_error" then
|
||||||
self.error = ({
|
self.error = Strings(({
|
||||||
not_found = "That code wasn't\nfound.",
|
not_found = Strings.source("That code wasn't\nfound."),
|
||||||
full = "That game already\nhas two players.",
|
full = Strings.source("That game already\nhas two players."),
|
||||||
expired = "That code has\nexpired.",
|
expired = Strings.source("That code has\nexpired."),
|
||||||
})[msg.reason] or ("Couldn't join:\n%s"):format(tostring(msg.reason))
|
})[msg.reason] or "")
|
||||||
|
if self.error == "" then
|
||||||
|
self.error = Strings("Couldn't join:\n%s", tostring(msg.reason))
|
||||||
|
end
|
||||||
self.closed = true
|
self.closed = true
|
||||||
return true
|
return true
|
||||||
elseif msg.type == "peer_gone" then
|
elseif msg.type == "peer_gone" then
|
||||||
@@ -328,7 +332,7 @@ function Net:update()
|
|||||||
-- an unreachable join target surfaces as a service error
|
-- an unreachable join target surfaces as a service error
|
||||||
-- (ICMP unreachable on the connected UDP socket)
|
-- (ICMP unreachable on the connected UDP socket)
|
||||||
if self.mode == "joining" and not self.paired then
|
if self.mode == "joining" and not self.paired then
|
||||||
self.error = ("no answer from\n%s"):format(self.target or "the host")
|
self.error = Strings("no answer from\n%s", self.target or "the host")
|
||||||
else
|
else
|
||||||
self.error = tostring(event)
|
self.error = tostring(event)
|
||||||
end
|
end
|
||||||
@@ -358,14 +362,14 @@ function Net:update()
|
|||||||
self.closed = true
|
self.closed = true
|
||||||
if not self.paired then
|
if not self.paired then
|
||||||
self.error = self.error or
|
self.error = self.error or
|
||||||
("no answer from\n%s"):format(self.target or "the host")
|
Strings("no answer from\n%s", self.target or "the host")
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
if self.mode == "joining" and not self.paired
|
if self.mode == "joining" and not self.paired
|
||||||
and self.joinDeadline and now() > self.joinDeadline then
|
and self.joinDeadline and now() > self.joinDeadline then
|
||||||
self.error = ("no answer from\n%s"):format(self.target or "the host")
|
self.error = Strings("no answer from\n%s", self.target or "the host")
|
||||||
self.closed = true
|
self.closed = true
|
||||||
pcall(function() self.peer:disconnect_now() end)
|
pcall(function() self.peer:disconnect_now() end)
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -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,6 +78,14 @@ 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
|
||||||
@@ -81,8 +96,8 @@ function Protocol.unpackMon(data, packed, opts)
|
|||||||
-- ignored and everyone rebuilds at the same fixed level instead, so a
|
-- 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
|
-- Lv12 and a Lv100 party can battle on equal footing. Both sides pass
|
||||||
-- the identical forceLevel for a given match, so this stays symmetric.
|
-- the identical forceLevel for a given match, so this stays symmetric.
|
||||||
if opts and opts.forceLevel then
|
if forceLevel then
|
||||||
level = math.max(2, math.min(100, math.floor(opts.forceLevel)))
|
level = math.max(2, math.min(100, math.floor(forceLevel)))
|
||||||
end
|
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
|
||||||
@@ -115,10 +130,19 @@ function Protocol.unpackMon(data, packed, opts)
|
|||||||
-- current HP/status (a different level's numbers, possibly mid-fight)
|
-- current HP/status (a different level's numbers, possibly mid-fight)
|
||||||
-- isn't meaningful anymore -- auto-level starts everyone full and fresh,
|
-- isn't meaningful anymore -- auto-level starts everyone full and fresh,
|
||||||
-- same as a standardized tournament format would
|
-- same as a standardized tournament format would
|
||||||
local forced = opts and opts.forceLevel
|
local forced = forceLevel
|
||||||
local hp = forced and stats.hp
|
local hp = forced and stats.hp
|
||||||
or math.max(0, math.min(stats.hp, math.floor(packed.hp or 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
|
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,
|
||||||
@@ -129,6 +153,8 @@ function Protocol.unpackMon(data, packed, opts)
|
|||||||
hp = hp,
|
hp = hp,
|
||||||
status = 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
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ local Protocol = require("src.link.Protocol")
|
|||||||
local Runtime = require("src.mods.Runtime")
|
local Runtime = require("src.mods.Runtime")
|
||||||
local Sound = require("src.core.Sound")
|
local Sound = require("src.core.Sound")
|
||||||
local TextBox = require("src.render.TextBox")
|
local TextBox = require("src.render.TextBox")
|
||||||
|
local Strings = require("src.core.Strings")
|
||||||
|
|
||||||
local Tournament = {}
|
local Tournament = {}
|
||||||
Tournament.__index = Tournament
|
Tournament.__index = Tournament
|
||||||
@@ -57,7 +58,12 @@ 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
|
end
|
||||||
|
|
||||||
local function forceLevelLabel(v)
|
local function forceLevelLabel(v)
|
||||||
@@ -123,7 +129,7 @@ end
|
|||||||
function Tournament:startHosting()
|
function Tournament:startHosting()
|
||||||
self.net = Net.new()
|
self.net = Net.new()
|
||||||
if not self.net:connectTCP(Net.defaultRelayAddress()) then
|
if not self.net:connectTCP(Net.defaultRelayAddress()) then
|
||||||
self:exitWith("Link error:\n" .. (self.net.error or "?"))
|
self:exitWith(Strings("Link error:\n%s", self.net.error or "?"))
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
local size, minL, maxL = partyStats(self.game.save.party)
|
local size, minL, maxL = partyStats(self.game.save.party)
|
||||||
@@ -146,7 +152,7 @@ end
|
|||||||
function Tournament:startJoining(code)
|
function Tournament:startJoining(code)
|
||||||
self.net = Net.new()
|
self.net = Net.new()
|
||||||
if not self.net:connectTCP(Net.defaultRelayAddress()) then
|
if not self.net:connectTCP(Net.defaultRelayAddress()) then
|
||||||
self:exitWith("Link error:\n" .. (self.net.error or "?"))
|
self:exitWith(Strings("Link error:\n%s", self.net.error or "?"))
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
local size, minL, maxL = partyStats(self.game.save.party)
|
local size, minL, maxL = partyStats(self.game.save.party)
|
||||||
@@ -166,9 +172,9 @@ end
|
|||||||
-- -------------------------------------------------------------------
|
-- -------------------------------------------------------------------
|
||||||
|
|
||||||
local JOIN_ERROR_TEXT = {
|
local JOIN_ERROR_TEXT = {
|
||||||
not_found = "That code wasn't\nfound.",
|
not_found = Strings.source("That code wasn't\nfound."),
|
||||||
already_started = "That tournament\nhas already begun.",
|
already_started = Strings.source("That tournament\nhas already begun."),
|
||||||
expired = "That code has\nexpired.",
|
expired = Strings.source("That code has\nexpired."),
|
||||||
}
|
}
|
||||||
|
|
||||||
function Tournament:handleMessage(msg)
|
function Tournament:handleMessage(msg)
|
||||||
@@ -193,17 +199,17 @@ function Tournament:handleMessage(msg)
|
|||||||
end
|
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(Strings("Can't host:\nneed %d Pokemon\nLv %s-%s.", msg.requiredPartySize, levelLabel(msg.minLevel), levelLabel(msg.maxLevel)))
|
||||||
msg.requiredPartySize, levelLabel(msg.minLevel), levelLabel(msg.maxLevel)))
|
|
||||||
else
|
else
|
||||||
self:exitWith("Couldn't host\nthat tournament.")
|
self:exitWith(Strings("Couldn't host\nthat tournament."))
|
||||||
end
|
end
|
||||||
elseif msg.type == "tournament_join_error" then
|
elseif msg.type == "tournament_join_error" then
|
||||||
if msg.reason == "party_ineligible" then
|
if msg.reason == "party_ineligible" then
|
||||||
self:exitWith(("Your party needs\n%d Pokemon, Lv\n%s-%s."):format(
|
self:exitWith(Strings("Your party needs\n%d Pokemon, Lv\n%s-%s.", msg.requiredPartySize, levelLabel(msg.minLevel), levelLabel(msg.maxLevel)))
|
||||||
msg.requiredPartySize, levelLabel(msg.minLevel), levelLabel(msg.maxLevel)))
|
|
||||||
else
|
else
|
||||||
self:exitWith(JOIN_ERROR_TEXT[msg.reason] or "Couldn't join\nthat tournament.")
|
self:exitWith(JOIN_ERROR_TEXT[msg.reason]
|
||||||
|
and Strings(JOIN_ERROR_TEXT[msg.reason])
|
||||||
|
or Strings("Couldn't join\nthat tournament."))
|
||||||
end
|
end
|
||||||
elseif msg.type == "tournament_roster" then
|
elseif msg.type == "tournament_roster" then
|
||||||
self.roster = msg.players
|
self.roster = msg.players
|
||||||
@@ -276,7 +282,7 @@ function Tournament:beginMatchBattle()
|
|||||||
-- shouldn't happen (both sides already passed the online-play mods
|
-- shouldn't happen (both sides already passed the online-play mods
|
||||||
-- gate), but a mismatched engine/build is still possible -- bail out
|
-- gate), but a mismatched engine/build is still possible -- bail out
|
||||||
-- of just this match rather than crash the tournament
|
-- of just this match rather than crash the tournament
|
||||||
self:exitWith("Link error:\nversion mismatch\nwith opponent.")
|
self:exitWith(Strings("Link error:\nversion mismatch\nwith opponent."))
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
self.linkSeed = self.isHost and love.math.random(1, 2 ^ 30) or nil
|
self.linkSeed = self.isHost and love.math.random(1, 2 ^ 30) or nil
|
||||||
@@ -346,11 +352,11 @@ function Tournament:update(dt)
|
|||||||
self.net:update()
|
self.net:update()
|
||||||
if self.net.error and self.stage ~= "menu" and self.stage ~= "hostSettings"
|
if self.net.error and self.stage ~= "menu" and self.stage ~= "hostSettings"
|
||||||
and self.stage ~= "codeEntry" then
|
and self.stage ~= "codeEntry" then
|
||||||
self:exitWith("Link error:\n" .. self.net.error:sub(1, 60))
|
self:exitWith(Strings("Link error:\n%s", self.net.error:sub(1, 60)))
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
if self.net.closed and self.stage ~= "done" then
|
if self.net.closed and self.stage ~= "done" then
|
||||||
self:exitWith("The tournament\nconnection was\nlost.")
|
self:exitWith(Strings("The tournament\nconnection was\nlost."))
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
@@ -376,7 +382,7 @@ function Tournament:update(dt)
|
|||||||
and LinkBattle.newHost(self.game, self.net, self.pendingBattleOpts)
|
and LinkBattle.newHost(self.game, self.net, self.pendingBattleOpts)
|
||||||
or LinkBattle.newGuest(self.game, self.net, self.pendingBattleOpts)
|
or LinkBattle.newGuest(self.game, self.net, self.pendingBattleOpts)
|
||||||
if not battle then
|
if not battle then
|
||||||
self:exitWith(why or "Link battle\ncan't start.")
|
self:exitWith(why or Strings("Link battle\ncan't start."))
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
-- anything after `party` in this same batch belongs to the
|
-- anything after `party` in this same batch belongs to the
|
||||||
@@ -413,7 +419,7 @@ function Tournament:update(dt)
|
|||||||
forceLevel = levelForWire(self.settings.forceLevel),
|
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 Strings("Can't watch this\nmatch."))
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
for j = #msgs, i + 1, -1 do
|
for j = #msgs, i + 1, -1 do
|
||||||
@@ -536,8 +542,8 @@ local SETTINGS_LABELS = { "POKEMON", "MIN LV", "MAX LV", "TIMER", "LEVELS", "PLA
|
|||||||
function Tournament:draw()
|
function Tournament:draw()
|
||||||
if self.stage == "menu" then
|
if self.stage == "menu" then
|
||||||
drawTitle("TOURNAMENT")
|
drawTitle("TOURNAMENT")
|
||||||
Font.draw("HOST", 32, 48)
|
Font.draw(Strings("HOST"), 32, 48)
|
||||||
Font.draw("JOIN", 32, 68)
|
Font.draw(Strings("JOIN"), 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 == "hostSettings" then
|
elseif self.stage == "hostSettings" then
|
||||||
@@ -556,7 +562,7 @@ function Tournament:draw()
|
|||||||
Font.draw(values[i], 96, y)
|
Font.draw(values[i], 96, y)
|
||||||
if i == self.settingsIndex then Font.drawCode(CURSOR, 8, y) end
|
if i == self.settingsIndex then Font.drawCode(CURSOR, 8, y) end
|
||||||
end
|
end
|
||||||
Font.draw("START: create", 8, 128)
|
Font.draw(Strings("START: create"), 8, 128)
|
||||||
|
|
||||||
elseif self.stage == "codeEntry" then
|
elseif self.stage == "codeEntry" then
|
||||||
drawTitle("ENTER CODE")
|
drawTitle("ENTER CODE")
|
||||||
@@ -568,28 +574,27 @@ function Tournament:draw()
|
|||||||
Font.drawCode(0xEE, x, 76)
|
Font.drawCode(0xEE, x, 76)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
Font.draw("A: join B: back", 8, 128)
|
Font.draw(Strings("A: join B: back"), 8, 128)
|
||||||
|
|
||||||
elseif self.stage == "registering" then
|
elseif self.stage == "registering" then
|
||||||
drawTitle("CONNECTING...")
|
drawTitle("CONNECTING...")
|
||||||
Font.draw("B: cancel", 8, 128)
|
Font.draw(Strings("B: cancel"), 8, 128)
|
||||||
|
|
||||||
elseif self.stage == "bracket" or self.stage == "matchHello"
|
elseif self.stage == "bracket" or self.stage == "matchHello"
|
||||||
or self.stage == "matchWaitParty" or self.stage == "spectateWait" then
|
or self.stage == "matchWaitParty" or self.stage == "spectateWait" then
|
||||||
drawTitle(("TOURNAMENT %s"):format(self.code or "??????"))
|
drawTitle(Strings("TOURNAMENT %s", self.code or "??????"))
|
||||||
if self.bracket then
|
if self.bracket then
|
||||||
local y = 20
|
local y = 20
|
||||||
for _, round in ipairs(self.bracket.rounds) do
|
for _, round in ipairs(self.bracket.rounds) do
|
||||||
Font.draw(("ROUND %d"):format(round.round), 8, y)
|
Font.draw(Strings("ROUND %d", round.round), 8, y)
|
||||||
y = y + 10
|
y = y + 10
|
||||||
for _, m in ipairs(round.matches) do
|
for _, m in ipairs(round.matches) do
|
||||||
local line
|
local line
|
||||||
if m.bye then
|
if m.bye then
|
||||||
line = ("%s (bye)"):format(m.a or m.b or "?")
|
line = Strings("%s (bye)", m.a or m.b or "?")
|
||||||
else
|
else
|
||||||
local mark = m.state == "live" and "*" or (m.winner and "" or "")
|
local mark = m.state == "live" and "*" or (m.winner and "" or "")
|
||||||
line = ("%s%s vs %s%s"):format(
|
line = Strings("%s%s vs %s%s", m.winner == m.a and ">" or " ", m.a or "?",
|
||||||
m.winner == m.a and ">" or " ", m.a or "?",
|
|
||||||
m.b or "?", m.winner == m.b and "<" or (mark == "*" and " *" or ""))
|
m.b or "?", m.winner == m.b and "<" or (mark == "*" and " *" or ""))
|
||||||
end
|
end
|
||||||
Font.draw(line, 12, y)
|
Font.draw(line, 12, y)
|
||||||
@@ -599,11 +604,11 @@ function Tournament:draw()
|
|||||||
end
|
end
|
||||||
else
|
else
|
||||||
if self.participating == false then
|
if self.participating == false then
|
||||||
Font.draw("(organizing --", 16, 32)
|
Font.draw(Strings("(organizing --"), 16, 32)
|
||||||
Font.draw("not playing)", 16, 42)
|
Font.draw(Strings("not playing)"), 16, 42)
|
||||||
end
|
end
|
||||||
Font.draw("Waiting for", 16, 48)
|
Font.draw(Strings("Waiting for"), 16, 48)
|
||||||
Font.draw("players to join:", 16, 60)
|
Font.draw(Strings("players to join:"), 16, 60)
|
||||||
local y = 60
|
local y = 60
|
||||||
for i, name in ipairs(self.roster) do
|
for i, name in ipairs(self.roster) do
|
||||||
y = 60 + i * 10
|
y = 60 + i * 10
|
||||||
@@ -615,18 +620,18 @@ function Tournament:draw()
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
if self.isCreator and #self.roster >= 2 and not self.bracket then
|
if self.isCreator and #self.roster >= 2 and not self.bracket then
|
||||||
Font.draw("A: START B: cancel", 8, 132)
|
Font.draw(Strings("A: START B: cancel"), 8, 132)
|
||||||
else
|
else
|
||||||
Font.draw("B: cancel", 8, 132)
|
Font.draw(Strings("B: cancel"), 8, 132)
|
||||||
end
|
end
|
||||||
|
|
||||||
elseif self.stage == "done" then
|
elseif self.stage == "done" then
|
||||||
drawTitle("TOURNAMENT OVER")
|
drawTitle("TOURNAMENT OVER")
|
||||||
if self.champion then
|
if self.champion then
|
||||||
Font.draw(("%s is the"):format(self.champion), 16, 56)
|
Font.draw(Strings("%s is the", self.champion), 16, 56)
|
||||||
Font.draw("champion!", 16, 68)
|
Font.draw(Strings("champion!"), 16, 68)
|
||||||
end
|
end
|
||||||
Font.draw("A: continue", 8, 128)
|
Font.draw(Strings("A: continue"), 8, 128)
|
||||||
end
|
end
|
||||||
love.graphics.setColor(1, 1, 1, 1)
|
love.graphics.setColor(1, 1, 1, 1)
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -3,11 +3,12 @@
|
|||||||
-- manifests only. The full loader (src/mods/Loader.lua) still owns the real
|
-- 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
|
-- load at boot; this reads the same options.mods enable-state the loader
|
||||||
-- writes, derives per-mod status with the pure ManagerState.resolveToggle,
|
-- writes, derives per-mod status with the pure ManagerState.resolveToggle,
|
||||||
-- and installs a dropped/chosen .zip into the save-dir "mods/<id>/" tree.
|
-- 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
|
-- Split in two: the pure derivation (deriveList, locateRoot) has no love and
|
||||||
-- no filesystem, so the engine tier can table-drive it; the discovery and
|
-- no filesystem, so the engine tier can table-drive it; the discovery,
|
||||||
-- install paths reach for love.filesystem and SaveData.
|
-- install, and uninstall paths reach for love.filesystem and SaveData.
|
||||||
|
|
||||||
local Manifest = require("src.mods.Manifest")
|
local Manifest = require("src.mods.Manifest")
|
||||||
local ManagerState = require("src.mods.ManagerState")
|
local ManagerState = require("src.mods.ManagerState")
|
||||||
@@ -332,4 +333,34 @@ function LauncherMods.installZip(source)
|
|||||||
return true, manifest.id
|
return true, manifest.id
|
||||||
end
|
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
|
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)
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ local Semver = require("src.mods.Semver")
|
|||||||
local Version = require("src.core.Version")
|
local Version = require("src.core.Version")
|
||||||
local Theme = require("src.ui.Theme")
|
local Theme = require("src.ui.Theme")
|
||||||
local OptionRows = require("src.ui.OptionRows")
|
local OptionRows = require("src.ui.OptionRows")
|
||||||
|
local Strings = require("src.core.Strings")
|
||||||
|
|
||||||
local ManagerState = {}
|
local ManagerState = {}
|
||||||
ManagerState.__index = ManagerState
|
ManagerState.__index = ManagerState
|
||||||
@@ -269,7 +270,7 @@ function ManagerState:modRows()
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
if #rows == 0 then
|
if #rows == 0 then
|
||||||
rows[1] = { header = true, label = "NO MODS INSTALLED" }
|
rows[1] = { header = true, label = Strings("NO MODS INSTALLED") }
|
||||||
end
|
end
|
||||||
return rows
|
return rows
|
||||||
end
|
end
|
||||||
@@ -281,7 +282,7 @@ function ManagerState:profileRows()
|
|||||||
rows[#rows + 1] = { profile = p, label = p.name,
|
rows[#rows + 1] = { profile = p, label = p.name,
|
||||||
glyph = opts.activeProfile == p.name and GLYPH.errored or " " }
|
glyph = opts.activeProfile == p.name and GLYPH.errored or " " }
|
||||||
end
|
end
|
||||||
rows[#rows + 1] = { saveAs = true, label = "SAVE CURRENT AS.." }
|
rows[#rows + 1] = { saveAs = true, label = Strings("SAVE CURRENT AS..") }
|
||||||
rows[#rows + 1] = { adhoc = true,
|
rows[#rows + 1] = { adhoc = true,
|
||||||
label = opts.activeProfile and "[AD-HOC]" or "[AD-HOC] (LIVE)" }
|
label = opts.activeProfile and "[AD-HOC]" or "[AD-HOC] (LIVE)" }
|
||||||
return rows
|
return rows
|
||||||
@@ -316,30 +317,30 @@ function ManagerState:detailRows(m)
|
|||||||
rows[#rows + 1] = { label = m.enabled and "DISABLE" or "ENABLE",
|
rows[#rows + 1] = { label = m.enabled and "DISABLE" or "ENABLE",
|
||||||
action = function() self:beginToggle(m) end }
|
action = function() self:beginToggle(m) end }
|
||||||
if self:schemaFor(m) then
|
if self:schemaFor(m) then
|
||||||
rows[#rows + 1] = { label = "OPTIONS..",
|
rows[#rows + 1] = { label = Strings("OPTIONS.."),
|
||||||
action = function() self:openOptions(m) end }
|
action = function() self:openOptions(m) end }
|
||||||
end
|
end
|
||||||
if m.permissions and #m.permissions > 0 then
|
if m.permissions and #m.permissions > 0 then
|
||||||
rows[#rows + 1] = { label = "PERMISSIONS..",
|
rows[#rows + 1] = { label = Strings("PERMISSIONS.."),
|
||||||
action = function() self:goTo("permissions") end }
|
action = function() self:goTo("permissions") end }
|
||||||
end
|
end
|
||||||
if m.error then
|
if m.error then
|
||||||
rows[#rows + 1] = { label = "VIEW ERROR..",
|
rows[#rows + 1] = { label = Strings("VIEW ERROR.."),
|
||||||
action = function() self:goTo("errors") end }
|
action = function() self:goTo("errors") end }
|
||||||
end
|
end
|
||||||
rows[#rows + 1] = { label = "BACK", action = function() self:goBack() end }
|
rows[#rows + 1] = { label = Strings("BACK"), action = function() self:goBack() end }
|
||||||
return rows
|
return rows
|
||||||
end
|
end
|
||||||
|
|
||||||
function ManagerState:applyRows()
|
function ManagerState:applyRows()
|
||||||
local rows = {}
|
local rows = {}
|
||||||
rows[#rows + 1] = { label = "APPLY & RESTART", action = function()
|
rows[#rows + 1] = { label = Strings("APPLY & RESTART"), action = function()
|
||||||
self:openConfirm({ "RESTART NOW?" }, function() self:restartGame() end)
|
self:openConfirm({ "RESTART NOW?" }, function() self:restartGame() end)
|
||||||
end }
|
end }
|
||||||
rows[#rows + 1] = { label = "DISCARD CHANGES", action = function()
|
rows[#rows + 1] = { label = Strings("DISCARD CHANGES"), action = function()
|
||||||
self:discardChanges()
|
self:discardChanges()
|
||||||
end }
|
end }
|
||||||
rows[#rows + 1] = { label = "BACK", action = function() self:goBack() end }
|
rows[#rows + 1] = { label = Strings("BACK"), action = function() self:goBack() end }
|
||||||
return rows
|
return rows
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -352,7 +353,7 @@ function ManagerState:permissionRows(m)
|
|||||||
label = info and info.text or name }
|
label = info and info.text or name }
|
||||||
end
|
end
|
||||||
if #rows == 0 then
|
if #rows == 0 then
|
||||||
rows[1] = { inert = true, label = "DATA & API ONLY" }
|
rows[1] = { inert = true, label = Strings("DATA & API ONLY") }
|
||||||
end
|
end
|
||||||
return rows
|
return rows
|
||||||
end
|
end
|
||||||
@@ -564,7 +565,7 @@ function ManagerState:openCascade(r, m, want)
|
|||||||
else
|
else
|
||||||
local dep = self.byId[r.alsoDisable[1]]
|
local dep = self.byId[r.alsoDisable[1]]
|
||||||
lines[#lines + 1] = (dep and dep.name or r.alsoDisable[1]) .. " NEEDS THIS."
|
lines[#lines + 1] = (dep and dep.name or r.alsoDisable[1]) .. " NEEDS THIS."
|
||||||
lines[#lines + 1] = #r.alsoDisable > 1 and "DISABLE ALL?" or "DISABLE BOTH?"
|
lines[#lines + 1] = #r.alsoDisable > 1 and "DISABLE ALL?" or Strings("DISABLE BOTH?")
|
||||||
end
|
end
|
||||||
self.overlay = { kind = "confirm", lines = lines, index = 1,
|
self.overlay = { kind = "confirm", lines = lines, index = 1,
|
||||||
onYes = function() self:commitToggle(r.apply) end }
|
onYes = function() self:commitToggle(r.apply) end }
|
||||||
@@ -700,7 +701,7 @@ end
|
|||||||
function ManagerState:saveCurrentAs()
|
function ManagerState:saveCurrentAs()
|
||||||
local NamingScreen = require("src.ui.NamingScreen")
|
local NamingScreen = require("src.ui.NamingScreen")
|
||||||
self.game.stack:push(NamingScreen.new(self.game, {
|
self.game.stack:push(NamingScreen.new(self.game, {
|
||||||
title = "PROFILE NAME?",
|
title = Strings("PROFILE NAME?"),
|
||||||
maxLen = 10,
|
maxLen = 10,
|
||||||
onDone = function(name)
|
onDone = function(name)
|
||||||
local opts = self:optionsTable()
|
local opts = self:optionsTable()
|
||||||
@@ -726,7 +727,7 @@ end
|
|||||||
function ManagerState:renameProfile(p)
|
function ManagerState:renameProfile(p)
|
||||||
local NamingScreen = require("src.ui.NamingScreen")
|
local NamingScreen = require("src.ui.NamingScreen")
|
||||||
self.game.stack:push(NamingScreen.new(self.game, {
|
self.game.stack:push(NamingScreen.new(self.game, {
|
||||||
title = "RENAME?",
|
title = Strings("RENAME?"),
|
||||||
maxLen = 10,
|
maxLen = 10,
|
||||||
default = p.name,
|
default = p.name,
|
||||||
onDone = function(name)
|
onDone = function(name)
|
||||||
@@ -886,7 +887,7 @@ function ManagerState:buildOptionRows(m, schema)
|
|||||||
end }
|
end }
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
rows[#rows + 1] = { id = "__reset", label = "RESET DEFAULTS",
|
rows[#rows + 1] = { id = "__reset", label = Strings("RESET DEFAULTS"),
|
||||||
value = function() return "" end,
|
value = function() return "" end,
|
||||||
activate = function()
|
activate = function()
|
||||||
for _, row in ipairs(schema) do
|
for _, row in ipairs(schema) do
|
||||||
@@ -1047,7 +1048,7 @@ function ManagerState:drawApply()
|
|||||||
y = y + 1
|
y = y + 1
|
||||||
end
|
end
|
||||||
if #staged == 0 then
|
if #staged == 0 then
|
||||||
Font.draw(Runtime.safeMode and "SAFE MODE" or "NO CHANGES", 16, y * 8)
|
Font.draw(Runtime.safeMode and "SAFE MODE" or Strings("NO CHANGES"), 16, y * 8)
|
||||||
y = y + 1
|
y = y + 1
|
||||||
end
|
end
|
||||||
local rows = self:rowsForScreen()
|
local rows = self:rowsForScreen()
|
||||||
@@ -1076,12 +1077,12 @@ function ManagerState:drawOverlay()
|
|||||||
end
|
end
|
||||||
if overlay.kind == "confirm" then
|
if overlay.kind == "confirm" then
|
||||||
local yesY = ty + #lines + 1
|
local yesY = ty + #lines + 1
|
||||||
Font.draw("YES", 5 * 8, yesY * 8)
|
Font.draw(Strings("YES"), 5 * 8, yesY * 8)
|
||||||
Font.draw("NO", 5 * 8, (yesY + 1) * 8)
|
Font.draw(Strings("NO"), 5 * 8, (yesY + 1) * 8)
|
||||||
Font.drawCode(Theme.cursor, 4 * 8,
|
Font.drawCode(Theme.cursor, 4 * 8,
|
||||||
(overlay.index == 1 and yesY or yesY + 1) * 8)
|
(overlay.index == 1 and yesY or yesY + 1) * 8)
|
||||||
else
|
else
|
||||||
Font.draw("A:OK", 5 * 8, (ty + #lines + 1) * 8)
|
Font.draw(Strings("A:OK"), 5 * 8, (ty + #lines + 1) * 8)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -1090,7 +1091,7 @@ function ManagerState:draw()
|
|||||||
OptionRows.draw(self.game, self.optionRows or {}, self.cursor,
|
OptionRows.draw(self.game, self.optionRows or {}, self.cursor,
|
||||||
self.scroll or 0)
|
self.scroll or 0)
|
||||||
love.graphics.setColor(0, 0, 0, 1)
|
love.graphics.setColor(0, 0, 0, 1)
|
||||||
Font.draw(self.notice or "B:DONE (NO RESTART)", 8, 136)
|
Font.draw(self.notice or Strings("B:DONE (NO RESTART)"), 8, 136)
|
||||||
love.graphics.setColor(1, 1, 1, 1)
|
love.graphics.setColor(1, 1, 1, 1)
|
||||||
if self.overlay then self:drawOverlay() end
|
if self.overlay then self:drawOverlay() end
|
||||||
return
|
return
|
||||||
@@ -1099,7 +1100,7 @@ function ManagerState:draw()
|
|||||||
love.graphics.rectangle("fill", 0, 0, 160, 144)
|
love.graphics.rectangle("fill", 0, 0, 160, 144)
|
||||||
love.graphics.setColor(1, 1, 1, 1)
|
love.graphics.setColor(1, 1, 1, 1)
|
||||||
Font.drawBox(0, 0, 20, 18)
|
Font.drawBox(0, 0, 20, 18)
|
||||||
Font.draw(self.banner or "MOD MANAGER", 16, 8)
|
Font.draw(self.banner or Strings("MOD MANAGER"), 16, 8)
|
||||||
if self.screen == "list" then
|
if self.screen == "list" then
|
||||||
self:drawList()
|
self:drawList()
|
||||||
elseif self.screen == "detail" then
|
elseif self.screen == "detail" then
|
||||||
|
|||||||
@@ -584,6 +584,17 @@ R.text = {
|
|||||||
example = 'mod.content.text:override("_PalletTownText1", "HELLO!")',
|
example = 'mod.content.text:override("_PalletTownText1", "HELLO!")',
|
||||||
}
|
}
|
||||||
|
|
||||||
|
-- The engine's own authored text, the half of the game `text` does not
|
||||||
|
-- cover (src/core/Strings.lua). The id is the English source string, so a
|
||||||
|
-- translation supplies one entry per literal the engine draws and anything
|
||||||
|
-- it has not reached yet keeps rendering in English. Ids that collide in
|
||||||
|
-- English but not elsewhere carry a "context|source" prefix.
|
||||||
|
R.strings = {
|
||||||
|
semantics = "record", target = "strings",
|
||||||
|
value = f.str,
|
||||||
|
example = 'mod.content.strings:override("But, it failed!", "Echec !")',
|
||||||
|
}
|
||||||
|
|
||||||
-- the self-contained bytecode blob ChipAsm.song/sfx emit (13.1 shape 2);
|
-- the self-contained bytecode blob ChipAsm.song/sfx emit (13.1 shape 2);
|
||||||
-- shared by every namespace that plays a chip program
|
-- shared by every namespace that plays a chip program
|
||||||
local chipProgram = f.rec{
|
local chipProgram = f.rec{
|
||||||
@@ -918,8 +929,20 @@ R.battle_sprite_scales = {
|
|||||||
-- 1 = native pixels; the drawn size relative to the pic's own pixels
|
-- 1 = native pixels; the drawn size relative to the pic's own pixels
|
||||||
scale = f.numRange(0.25, 4.0),
|
scale = f.numRange(0.25, 4.0),
|
||||||
},
|
},
|
||||||
example = 'mod.content.battle_sprite_scales:register("abra_back", ' ..
|
example = [[
|
||||||
'{ path = "assets/generated/battle/back/abrab.png", scale = 1.5 })',
|
mod.content.battle_sprite_scales:register("abra_back", {
|
||||||
|
path = "assets/generated/battle/back/abrab.png",
|
||||||
|
scale = 1.5,
|
||||||
|
})]],
|
||||||
|
notes = [[
|
||||||
|
Scales one battle pic by its asset path, overriding the species-level
|
||||||
|
`battleScaleFront`/`battleScaleBack` (see [pokemon](#pokemon)) for that
|
||||||
|
image. The only way to scale a pic that is not species-keyed, like the
|
||||||
|
player's trainer back sprite. Resolution order at draw time:
|
||||||
|
image-level, then species-level, then the defaults (1x front, 2x back).
|
||||||
|
The pic stays grounded at any scale: player feet stay flush on the
|
||||||
|
text-box top, the enemy pic stays bottom-pinned in its slot, and the
|
||||||
|
scale composes with the send-out grow animation.]],
|
||||||
}
|
}
|
||||||
|
|
||||||
-- ------- progression
|
-- ------- progression
|
||||||
@@ -1146,6 +1169,12 @@ R.field = {
|
|||||||
name = f.opt(f.str) })),
|
name = f.opt(f.str) })),
|
||||||
nest = f.opt(f.any) },
|
nest = f.opt(f.any) },
|
||||||
flyOrder = f.list(f.str),
|
flyOrder = f.list(f.str),
|
||||||
|
-- the player's own trainer art (FieldDefaults.PLAYER_PICS): the battle
|
||||||
|
-- back pic, the catch tutorial's old man, and the front pic the intro,
|
||||||
|
-- trainer card and Hall of Fame share. Every key is optional so a
|
||||||
|
-- conversion can replace one pic and inherit the rest.
|
||||||
|
playerPics = f.rec{
|
||||||
|
back = f.opt(f.str), demoBack = f.opt(f.str), front = f.opt(f.str) },
|
||||||
-- the new-game and boot config a total conversion replaces
|
-- the new-game and boot config a total conversion replaces
|
||||||
boot = f.rec{
|
boot = f.rec{
|
||||||
startMap = f.opt(f.str), startX = f.opt(f.int(0)), startY = f.opt(f.int(0)),
|
startMap = f.opt(f.str), startX = f.opt(f.int(0)), startY = f.opt(f.int(0)),
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ local Runtime = require("src.mods.Runtime")
|
|||||||
local Screens = require("src.ui.Screens")
|
local Screens = require("src.ui.Screens")
|
||||||
local Stats = require("src.pokemon.Stats")
|
local Stats = require("src.pokemon.Stats")
|
||||||
local TextBox = require("src.render.TextBox")
|
local TextBox = require("src.render.TextBox")
|
||||||
|
local Strings = require("src.core.Strings")
|
||||||
|
|
||||||
local Evolution = {}
|
local Evolution = {}
|
||||||
|
|
||||||
@@ -23,7 +24,7 @@ Evolution.METHODS = {
|
|||||||
return trigger.kind == "levelup" and mon.level >= (evo.level or 0)
|
return trigger.kind == "levelup" and mon.level >= (evo.level or 0)
|
||||||
end,
|
end,
|
||||||
describe = function(evo)
|
describe = function(evo)
|
||||||
return ("Level %d"):format(evo.level or 0)
|
return Strings("Level %d", evo.level or 0)
|
||||||
end,
|
end,
|
||||||
},
|
},
|
||||||
ITEM = {
|
ITEM = {
|
||||||
@@ -106,21 +107,67 @@ 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,
|
||||||
|
Strings("%s learned\n%s!", 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"))
|
||||||
local oldName = mon.nickname or game.data.pokemon[mon.species].name
|
local oldName = mon.nickname or game.data.pokemon[mon.species].name
|
||||||
Evolution.apply(game, mon, newSpecies, via)
|
Evolution.apply(game, mon, newSpecies, via)
|
||||||
local msg = ("What?\n%s is\nevolving!\fCongratulations!\nYour %s\nevolved into\n%s!")
|
local msg = Strings("What?\n%s is\nevolving!\fCongratulations!\nYour %s\nevolved into\n%s!",
|
||||||
:format(oldName, oldName, game.data.pokemon[newSpecies].name)
|
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 +185,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
|
||||||
|
|||||||
@@ -0,0 +1,97 @@
|
|||||||
|
-- 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.
|
||||||
|
--
|
||||||
|
-- playerPath is the same seam for the player's own trainer art, whose
|
||||||
|
-- vanilla paths are data (field.playerPics) rather than a species record.
|
||||||
|
|
||||||
|
local Runtime = require("src.mods.Runtime")
|
||||||
|
local FieldDefaults = require("src.world.FieldDefaults")
|
||||||
|
|
||||||
|
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 the player's own trainer pic path.
|
||||||
|
-- side: "back" (the battle intro pic) | "front" (intro / card / Hall of Fame)
|
||||||
|
-- opts.kind: "battle" | "intro" | "trainer_card" | "hof"
|
||||||
|
-- opts.demo: the catch tutorial, where the old man fights in the player's
|
||||||
|
-- place and stands in for the back pic
|
||||||
|
-- opts.battle: the live battle, for kind == "battle"
|
||||||
|
-- Returns path, trueColor.
|
||||||
|
function Sprites.playerPath(data, side, opts)
|
||||||
|
opts = opts or {}
|
||||||
|
side = side == "back" and "back" or "front"
|
||||||
|
-- one key per pic, so a conversion can replace the back and inherit the
|
||||||
|
-- rest; fieldValue folds data.field over FieldDefaults per key
|
||||||
|
local key = side == "front" and "front"
|
||||||
|
or (opts.demo and "demoBack" or "back")
|
||||||
|
local path = FieldDefaults.fieldValue(data, "playerPics", key)
|
||||||
|
local ctx = {
|
||||||
|
side = side,
|
||||||
|
kind = opts.kind or "battle",
|
||||||
|
demo = opts.demo and true or false,
|
||||||
|
battle = opts.battle,
|
||||||
|
trueColor = false,
|
||||||
|
data = data,
|
||||||
|
}
|
||||||
|
if path and Runtime.wantsHook("player.sprite") then
|
||||||
|
local hooked = Runtime.call("player.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
|
||||||
|
|||||||
@@ -102,35 +102,84 @@ local function pageFor(code)
|
|||||||
return nil
|
return nil
|
||||||
end
|
end
|
||||||
|
|
||||||
|
local SPACE = 0x7F
|
||||||
|
|
||||||
|
-- Segment text into glyph spans: `{ from, to, code }` byte ranges, one per
|
||||||
|
-- drawn glyph, code nil when the charmap has nothing. A span is a whole
|
||||||
|
-- charmap sequence, so a multi-byte char ("é", "♂") and an ASCII ligature
|
||||||
|
-- ("<PK>", "'d") are each one glyph.
|
||||||
|
--
|
||||||
|
-- Every caller that *measures* or *cuts* text walks these instead of bytes.
|
||||||
|
-- "POKéMON" is 8 bytes and 7 glyphs: measuring it as 8 wraps lines that fit
|
||||||
|
-- (25 vanilla lines did), and cutting at byte 8 splits the é into two
|
||||||
|
-- invalid bytes that both draw as spaces. That distinction is the whole
|
||||||
|
-- reason a non-English font can be shipped as a mod (#186, #245).
|
||||||
|
--
|
||||||
|
-- Safe before Font.load: with no charmap it falls back to UTF-8 lead-byte
|
||||||
|
-- boundaries, which is all a headless paginate needs.
|
||||||
|
function Font.split(text)
|
||||||
|
local spans = {}
|
||||||
|
local i, n = 1, #text
|
||||||
|
while i <= n do
|
||||||
|
local span
|
||||||
|
local candidates = state and state.byFirstByte[text:byte(i)]
|
||||||
|
if candidates then
|
||||||
|
for _, entry in ipairs(candidates) do
|
||||||
|
local len = #entry.seq
|
||||||
|
if text:sub(i, i + len - 1) == entry.seq then
|
||||||
|
span = { from = i, to = i + len - 1, code = entry.code }
|
||||||
|
break
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
if not span then
|
||||||
|
-- Nothing matched. Still keep a UTF-8 sequence whole, so a cut never
|
||||||
|
-- lands mid-character even for a glyph we cannot draw.
|
||||||
|
local last = i
|
||||||
|
if text:byte(i) >= 0xC0 then
|
||||||
|
local k = i + 1
|
||||||
|
while k <= n do
|
||||||
|
local b = text:byte(k)
|
||||||
|
if b < 0x80 or b > 0xBF then break end
|
||||||
|
last, k = k, k + 1
|
||||||
|
end
|
||||||
|
end
|
||||||
|
span = { from = i, to = last }
|
||||||
|
end
|
||||||
|
spans[#spans + 1] = span
|
||||||
|
i = span.to + 1
|
||||||
|
end
|
||||||
|
return spans
|
||||||
|
end
|
||||||
|
|
||||||
|
-- How many leading spans fit in `budget` pixels. Advances come from each
|
||||||
|
-- glyph's own page, so a variable-width page measures correctly.
|
||||||
|
function Font.spansFitting(spans, budget)
|
||||||
|
local used, fit = 0, 0
|
||||||
|
for _, span in ipairs(spans) do
|
||||||
|
used = used + Font.advanceOf(span.code or SPACE)
|
||||||
|
if used > budget then break end
|
||||||
|
fit = fit + 1
|
||||||
|
end
|
||||||
|
return fit
|
||||||
|
end
|
||||||
|
|
||||||
-- Convert a text string into a list of glyph codes. Unknown characters
|
-- Convert a text string into a list of glyph codes. Unknown characters
|
||||||
-- render as space (and are reported once).
|
-- render as space (and are reported once).
|
||||||
local reported = {}
|
local reported = {}
|
||||||
function Font.encode(text)
|
function Font.encode(text)
|
||||||
local codes = {}
|
local codes = {}
|
||||||
local i = 1
|
for _, span in ipairs(Font.split(text)) do
|
||||||
while i <= #text do
|
local code = span.code
|
||||||
local candidates = state.byFirstByte[text:byte(i)]
|
if not code then
|
||||||
local matched = false
|
local ch = text:sub(span.from, span.to)
|
||||||
if candidates then
|
if not reported[ch] and text:byte(span.from) >= 32 then
|
||||||
for _, entry in ipairs(candidates) do
|
|
||||||
local n = #entry.seq
|
|
||||||
if text:sub(i, i + n - 1) == entry.seq then
|
|
||||||
codes[#codes + 1] = entry.code
|
|
||||||
i = i + n
|
|
||||||
matched = true
|
|
||||||
break
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
if not matched then
|
|
||||||
local ch = text:sub(i, i)
|
|
||||||
if not reported[ch] and ch:byte() >= 32 then
|
|
||||||
reported[ch] = true
|
reported[ch] = true
|
||||||
require("src.core.Logger").warn("font: no glyph for %q", ch)
|
require("src.core.Logger").warn("font: no glyph for %q", ch)
|
||||||
end
|
end
|
||||||
codes[#codes + 1] = 0x7F -- space
|
code = SPACE
|
||||||
i = i + 1
|
|
||||||
end
|
end
|
||||||
|
codes[#codes + 1] = code
|
||||||
end
|
end
|
||||||
return codes
|
return codes
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -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,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
|
||||||
@@ -10,6 +10,8 @@ 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 Pipelines = require("src.render.Pipelines")
|
||||||
|
local PixelCanvas = require("src.render.PixelCanvas")
|
||||||
|
local Runtime = require("src.mods.Runtime")
|
||||||
|
|
||||||
local Renderer = {}
|
local Renderer = {}
|
||||||
|
|
||||||
@@ -39,32 +41,49 @@ end
|
|||||||
-- 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
|
||||||
@@ -93,21 +112,43 @@ 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
|
||||||
@@ -145,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
|
||||||
@@ -176,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)
|
||||||
@@ -190,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")
|
||||||
@@ -219,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)
|
||||||
@@ -299,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()
|
||||||
|
|
||||||
@@ -313,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)
|
||||||
@@ -351,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)
|
||||||
@@ -393,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.
|
||||||
@@ -422,6 +465,14 @@ function Renderer:endFrame(zones, worldZones)
|
|||||||
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
|
||||||
@@ -444,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
|
||||||
@@ -468,10 +531,10 @@ 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()
|
||||||
@@ -486,7 +549,7 @@ function Renderer:endFrame(zones, worldZones)
|
|||||||
-- runs, so dialogs, menus and the HUD sit on top as usual.
|
-- runs, so dialogs, menus and the HUD sit on top as usual.
|
||||||
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.worldOverride, 0, 0, 0, 1 / dpi, 1 / dpi)
|
love.graphics.draw(self.worldOverride, 0, 0, 0, 1 / dpiX, 1 / dpiY)
|
||||||
love.graphics.setScissor()
|
love.graphics.setScissor()
|
||||||
-- the screen-space overlays the flat path draws over its composite
|
-- the screen-space overlays the flat path draws over its composite
|
||||||
local fade = self.worldFadeAlpha
|
local fade = self.worldFadeAlpha
|
||||||
@@ -496,15 +559,15 @@ function Renderer:endFrame(zones, worldZones)
|
|||||||
love.graphics.setColor(1, 1, 1, 1)
|
love.graphics.setColor(1, 1, 1, 1)
|
||||||
end
|
end
|
||||||
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
|
||||||
elseif self.worldActive then
|
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
|
||||||
@@ -512,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
|
||||||
@@ -538,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
|
||||||
@@ -559,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
|
||||||
@@ -575,11 +638,11 @@ 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()
|
||||||
@@ -589,7 +652,7 @@ function Renderer:endFrame(zones, worldZones)
|
|||||||
-- grid itself. Each pass hands back a canvas; with none registered
|
-- grid itself. Each pass hands back a canvas; with none registered
|
||||||
-- this returns `present` unchanged and the frame is byte-identical.
|
-- this returns `present` unchanged and the frame is byte-identical.
|
||||||
local composed = Pipelines.present(present,
|
local composed = Pipelines.present(present,
|
||||||
{ width = ww, height = wh, scale = Sp, dpi = dpi }) or present
|
{ width = ww, height = wh, scale = Sp, dpi = dpiY, dpiX = dpiX, dpiY = dpiY }) or present
|
||||||
if GBCFX.active() then
|
if GBCFX.active() then
|
||||||
-- shader grid/shadow math is in framebuffer pixels
|
-- shader grid/shadow math is in framebuffer pixels
|
||||||
GBCFX.present(composed, Sp)
|
GBCFX.present(composed, Sp)
|
||||||
|
|||||||
@@ -105,7 +105,10 @@ function SpriteRenderer:resolveImage()
|
|||||||
local colors, group = PaletteFX.spriteObp(self.def, self.seed)
|
local colors, group = PaletteFX.spriteObp(self.def, self.seed)
|
||||||
if colors then return getObpImage(self.def.image, colors, group) end
|
if colors then return getObpImage(self.def.image, colors, group) end
|
||||||
elseif PaletteFX.usesSpriteObp() then
|
elseif PaletteFX.usesSpriteObp() then
|
||||||
return getObpImage(self.def.image, PaletteFX.GBC_OBJ, "gbcobj")
|
-- 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
|
end
|
||||||
return self.image
|
return self.image
|
||||||
end
|
end
|
||||||
@@ -141,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;
|
||||||
|
|||||||
@@ -92,13 +92,28 @@ end
|
|||||||
-- pokered ContText waits for A/B + ▼ before scrolling that line in.
|
-- 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
|
||||||
|
-- maxCols is a column count, so the budget is that many vanilla 8px
|
||||||
|
-- cells. Measuring in pixels rather than columns is what lets a mod's
|
||||||
|
-- variable-advance page wrap correctly (#186).
|
||||||
|
local budget = maxCols * 8
|
||||||
local pages = {}
|
local pages = {}
|
||||||
local contBefore = {}
|
local contBefore = {}
|
||||||
|
-- Soft-wrap on glyph boundaries, never byte boundaries: a line is over
|
||||||
|
-- budget by what it *draws*, and the cut falls between glyphs so a
|
||||||
|
-- multi-byte char is never torn in half.
|
||||||
local function pushLine(lines, conts, line, wait)
|
local function pushLine(lines, conts, line, wait)
|
||||||
while #line > maxCols do
|
while true do
|
||||||
local cut = maxCols
|
local spans = Font.split(line)
|
||||||
for i = maxCols, 1, -1 do
|
local fit = Font.spansFitting(spans, budget)
|
||||||
if line:sub(i, i) == " " then cut = i break end
|
if fit >= #spans then break end
|
||||||
|
-- a glyph wider than the whole box still has to advance by one
|
||||||
|
fit = math.max(fit, 1)
|
||||||
|
local cut = spans[fit].to
|
||||||
|
for i = fit, 1, -1 do
|
||||||
|
if line:sub(spans[i].from, spans[i].to) == " " then
|
||||||
|
cut = spans[i].to
|
||||||
|
break
|
||||||
|
end
|
||||||
end
|
end
|
||||||
table.insert(lines, line:sub(1, cut))
|
table.insert(lines, line:sub(1, cut))
|
||||||
table.insert(conts, wait)
|
table.insert(conts, wait)
|
||||||
@@ -167,6 +182,18 @@ function TextBox:update(dt)
|
|||||||
if self.autoSrc and self.autoSrc.isPlaying and self.autoSrc:isPlaying() then
|
if self.autoSrc and self.autoSrc.isPlaying and self.autoSrc:isPlaying() then
|
||||||
return -- the cry is still sounding (WaitForSoundToFinish)
|
return -- the cry is still sounding (WaitForSoundToFinish)
|
||||||
end
|
end
|
||||||
|
-- auto.wait: the pet-NPC cries (PewterNidoranHouseNidoranText,
|
||||||
|
-- ViridianNicknameHouseSpearowText) have nothing queued behind the
|
||||||
|
-- cry, so DisplayTextID's trailing WaitForTextScrollButtonPress still
|
||||||
|
-- runs once it is over -- their maps enable auto text box drawing,
|
||||||
|
-- which zeroes wDoNotWaitForButtonPressAfterDisplayingText
|
||||||
|
-- (home/window.asm AutoTextBoxDrawingCommon). Drop the auto gate and
|
||||||
|
-- hand the box to the plain A/B path, which also starts the blinking
|
||||||
|
-- arrow, instead of popping it (#247, #251).
|
||||||
|
if self.auto.wait then
|
||||||
|
self.auto = nil
|
||||||
|
return
|
||||||
|
end
|
||||||
self.autoTimer = self.autoTimer + 1
|
self.autoTimer = self.autoTimer + 1
|
||||||
local delay = self.auto.delay or 3
|
local delay = self.auto.delay or 3
|
||||||
-- auto.onOverlap: fired once when the delay elapses but before the
|
-- auto.onOverlap: fired once when the delay elapses but before the
|
||||||
|
|||||||
@@ -542,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)
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ local Flags = require("src.script.Flags")
|
|||||||
local Logger = require("src.core.Logger")
|
local Logger = require("src.core.Logger")
|
||||||
local Screens = require("src.ui.Screens")
|
local Screens = require("src.ui.Screens")
|
||||||
local TextBox = require("src.render.TextBox")
|
local TextBox = require("src.render.TextBox")
|
||||||
|
local Strings = require("src.core.Strings")
|
||||||
|
|
||||||
local Commands = {}
|
local Commands = {}
|
||||||
|
|
||||||
@@ -74,6 +75,9 @@ end
|
|||||||
-- <PROMPT> of its own; the box only closes once WaitForSoundToFinish's poll
|
-- <PROMPT> of its own; the box only closes once WaitForSoundToFinish's poll
|
||||||
-- loop sees the cry channel go quiet, so it's a no-button-wait auto text
|
-- loop sees the cry channel go quiet, so it's a no-button-wait auto text
|
||||||
-- gated on the cry, not a button-wait one -- see TextBox's opts.auto.
|
-- gated on the cry, not a button-wait one -- see TextBox's opts.auto.
|
||||||
|
-- play_cry's waitForButton form keeps that cry gate but hands the box back
|
||||||
|
-- to the A/B path once the cry is over -- see TextBox's opts.auto.wait
|
||||||
|
-- (#247, #251).
|
||||||
function Commands.show_text(ctx, textId, subs)
|
function Commands.show_text(ctx, textId, subs)
|
||||||
local text = ctx.game.data.text[textId]
|
local text = ctx.game.data.text[textId]
|
||||||
if not text and ctx.overworld then
|
if not text and ctx.overworld then
|
||||||
@@ -100,10 +104,12 @@ function Commands.show_text(ctx, textId, subs)
|
|||||||
local opts
|
local opts
|
||||||
if ctx.pendingCry then
|
if ctx.pendingCry then
|
||||||
local species = ctx.pendingCry
|
local species = ctx.pendingCry
|
||||||
ctx.pendingCry = nil
|
local waitForButton = ctx.pendingCryWait
|
||||||
|
ctx.pendingCry, ctx.pendingCryWait = nil, nil
|
||||||
|
-- delay 0: WaitForSoundToFinish has no trailing Delay3 of its own
|
||||||
opts = { auto = { sound = function()
|
opts = { auto = { sound = function()
|
||||||
return require("src.core.Sound").playCry(ctx.game.data, species)
|
return require("src.core.Sound").playCry(ctx.game.data, species)
|
||||||
end, delay = 0 } } -- WaitForSoundToFinish has no trailing Delay3 of its own
|
end, delay = 0, wait = waitForButton } }
|
||||||
end
|
end
|
||||||
-- text_opts armed the next box: auto = true is the plain no-button-wait
|
-- text_opts armed the next box: auto = true is the plain no-button-wait
|
||||||
-- form, overlap folds under auto, everything else passes through
|
-- form, overlap folds under auto, everything else passes through
|
||||||
@@ -189,7 +195,7 @@ function Commands.give_item(ctx, itemId, count, gotText)
|
|||||||
-- skips the received text entirely when AddItemToInventory refuses)
|
-- skips the received text entirely when AddItemToInventory refuses)
|
||||||
if not require("src.inventory.Bag").add(ctx.save, itemId, count or 1) then
|
if not require("src.inventory.Bag").add(ctx.save, itemId, count or 1) then
|
||||||
Commands.show_text(ctx, ctx.game.data.text
|
Commands.show_text(ctx, ctx.game.data.text
|
||||||
and ctx.game.data.text._BagFullText or "You can't carry\nany more items!")
|
and ctx.game.data.text._BagFullText or Strings("You can't carry\nany more items!"))
|
||||||
return math.huge
|
return math.huge
|
||||||
end
|
end
|
||||||
local def = ctx.game.data.items[itemId]
|
local def = ctx.game.data.items[itemId]
|
||||||
@@ -202,7 +208,7 @@ function Commands.give_item(ctx, itemId, count, gotText)
|
|||||||
(def and def.keyItem) and "Get_Key_Item" or "Get_Item1")
|
(def and def.keyItem) and "Get_Key_Item" or "Get_Item1")
|
||||||
if gotText ~= false then
|
if gotText ~= false then
|
||||||
Commands.show_text(ctx, gotText
|
Commands.show_text(ctx, gotText
|
||||||
or "{PLAYER} got\n" .. ctx.game.stringBuffer .. "!")
|
or Strings("{PLAYER} got\n%s!", ctx.game.stringBuffer))
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -469,18 +475,26 @@ function Commands.play_once(ctx, songId)
|
|||||||
runner:yield()
|
runner:yield()
|
||||||
end
|
end
|
||||||
|
|
||||||
-- play_cry <species>: PlayCry (home/audio.asm). The text_asm bodies that
|
-- play_cry <species> [waitForButton]: PlayCry (home/audio.asm). The
|
||||||
-- use it run text_far (a no-button-wait "...@" string) -> PlayCry ->
|
-- text_asm bodies that use it run text_far (a no-button-wait "...@" string)
|
||||||
-- WaitForSoundToFinish, all within the same text ID -- the cry only starts
|
-- -> PlayCry -> WaitForSoundToFinish, all within the same text ID -- the cry
|
||||||
-- once the box has finished typing, and the box then auto-closes (no A
|
-- only starts once the box has finished typing, and the box then auto-closes
|
||||||
-- press) the instant the cry finishes, rather than firing immediately
|
-- (no A press) the instant the cry finishes, rather than firing immediately
|
||||||
-- alongside the typewriter effect. Script rows run strictly in order, so
|
-- alongside the typewriter effect. Script rows run strictly in order, so
|
||||||
-- this stashes the species on ctx for the show_text row that always
|
-- this stashes the species on ctx for the show_text row that always
|
||||||
-- immediately follows it (Power Plant Zapdos, Seafoam Articuno, Victory
|
-- immediately follows it (Power Plant Zapdos, Seafoam Articuno, Victory
|
||||||
-- Road Moltres, Cerulean Cave Mewtwo battle text) to play once its box is
|
-- Road Moltres, Cerulean Cave Mewtwo battle text) to play once its box is
|
||||||
-- done typing (see show_text's opts.auto). Headless-safe no-op there.
|
-- done typing (see show_text's opts.auto). Headless-safe no-op there.
|
||||||
function Commands.play_cry(ctx, species)
|
--
|
||||||
|
-- waitForButton is the pet-NPC form (scripts/PewterNidoranHouse.asm and
|
||||||
|
-- scripts/ViridianNicknameHouse.asm: the text prints, then PlayCry,
|
||||||
|
-- WaitForSoundToFinish, TextScriptEnd). Nothing is queued behind the cry
|
||||||
|
-- there, so DisplayTextID's trailing WaitForTextScrollButtonPress still
|
||||||
|
-- runs and the box holds until A/B like any other NPC line instead of
|
||||||
|
-- popping itself into a static battle (#247, #251).
|
||||||
|
function Commands.play_cry(ctx, species, waitForButton)
|
||||||
ctx.pendingCry = species
|
ctx.pendingCry = species
|
||||||
|
ctx.pendingCryWait = waitForButton or nil
|
||||||
end
|
end
|
||||||
|
|
||||||
-- check_battle_result <r1> [r2 ...]: lastCheck = the last scripted
|
-- check_battle_result <r1> [r2 ...]: lastCheck = the last scripted
|
||||||
@@ -517,7 +531,7 @@ local function askNickname(ctx, mon)
|
|||||||
if ctx.game.data.text and ctx.game.data.text._DoYouWantToNicknameText then
|
if ctx.game.data.text and ctx.game.data.text._DoYouWantToNicknameText then
|
||||||
Commands.show_text(ctx, "_DoYouWantToNicknameText", { RAM = name })
|
Commands.show_text(ctx, "_DoYouWantToNicknameText", { RAM = name })
|
||||||
else
|
else
|
||||||
Commands.show_text(ctx, ("Do you want to\ngive a nickname\nto %s?"):format(name))
|
Commands.show_text(ctx, Strings("Do you want to\ngive a nickname\nto %s?", name))
|
||||||
end
|
end
|
||||||
local ChoiceBox = require("src.ui.ChoiceBox")
|
local ChoiceBox = require("src.ui.ChoiceBox")
|
||||||
ctx.game.stack:push(ChoiceBox.new(ctx.game, function(yes)
|
ctx.game.stack:push(ChoiceBox.new(ctx.game, function(yes)
|
||||||
@@ -527,7 +541,7 @@ local function askNickname(ctx, mon)
|
|||||||
return
|
return
|
||||||
end
|
end
|
||||||
Screens.push(ctx.game, "NamingScreen", {
|
Screens.push(ctx.game, "NamingScreen", {
|
||||||
title = "NICKNAME?", maxLen = 10,
|
title = Strings("NICKNAME?"), maxLen = 10,
|
||||||
onDone = function(nick)
|
onDone = function(nick)
|
||||||
if nick and #nick > 0 then mon.nickname = nick end
|
if nick and #nick > 0 then mon.nickname = nick end
|
||||||
ctx.lastCheck = success
|
ctx.lastCheck = success
|
||||||
@@ -598,8 +612,9 @@ function Commands.give_pokemon(ctx, species, level)
|
|||||||
local TextBox = require("src.render.TextBox")
|
local TextBox = require("src.render.TextBox")
|
||||||
local raw = ctx.game.data.text and ctx.game.data.text._SentToBoxText
|
local raw = ctx.game.data.text and ctx.game.data.text._SentToBoxText
|
||||||
ctx.game.stack:push(TextBox.new(ctx.game, raw or (
|
ctx.game.stack:push(TextBox.new(ctx.game, raw or (
|
||||||
"There's no more\nroom for POKéMON!\v" .. name
|
Strings(
|
||||||
.. " was\vsent to POKéMON\vBOX " .. boxNum .. " on PC!"),
|
"There's no more\nroom for POKéMON!\v%s was\vsent to POKéMON\vBOX %s on PC!",
|
||||||
|
name, boxNum)),
|
||||||
function() end))
|
function() end))
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -15,6 +15,7 @@
|
|||||||
local Data = require("src.core.Data")
|
local Data = require("src.core.Data")
|
||||||
local Logger = require("src.core.Logger")
|
local Logger = require("src.core.Logger")
|
||||||
local Runtime = require("src.mods.Runtime")
|
local Runtime = require("src.mods.Runtime")
|
||||||
|
local Strings = require("src.core.Strings")
|
||||||
|
|
||||||
local MapScripts = {}
|
local MapScripts = {}
|
||||||
|
|
||||||
@@ -268,7 +269,7 @@ function MapScripts.validateContribution(contribution, lookup)
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
if type(contribution) ~= "table" then
|
if type(contribution) ~= "table" then
|
||||||
return { "contribution is not a table" }
|
return { Strings("contribution is not a table") }
|
||||||
end
|
end
|
||||||
for textConst, script in pairs(contribution.talk or {}) do
|
for textConst, script in pairs(contribution.talk or {}) do
|
||||||
if type(script) == "table" then collect("talk." .. textConst, script) end
|
if type(script) == "table" then collect("talk." .. textConst, script) end
|
||||||
|
|||||||
@@ -13,6 +13,7 @@
|
|||||||
local Commands = require("src.script.Commands")
|
local Commands = require("src.script.Commands")
|
||||||
local Logger = require("src.core.Logger")
|
local Logger = require("src.core.Logger")
|
||||||
local Runtime = require("src.mods.Runtime")
|
local Runtime = require("src.mods.Runtime")
|
||||||
|
local Strings = require("src.core.Strings")
|
||||||
|
|
||||||
local unpack = table.unpack or unpack -- LuaJIT (LÖVE) compatibility
|
local unpack = table.unpack or unpack -- LuaJIT (LÖVE) compatibility
|
||||||
|
|
||||||
@@ -195,7 +196,7 @@ function ScriptRunner:resume(...)
|
|||||||
if not ok then
|
if not ok then
|
||||||
local source = self.ctx and self.ctx.source
|
local source = self.ctx and self.ctx.source
|
||||||
local where = source
|
local where = source
|
||||||
and (" [%s %s.%s]"):format(tostring(source.modId or "engine"),
|
and Strings(" [%s %s.%s]", tostring(source.modId or "engine"),
|
||||||
tostring(source.mapId or "?"), tostring(source.hook or "?"))
|
tostring(source.mapId or "?"), tostring(source.hook or "?"))
|
||||||
or ""
|
or ""
|
||||||
Logger.error("script error%s: %s", where, tostring(err))
|
Logger.error("script error%s: %s", where, tostring(err))
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ local TextBox = require("src.render.TextBox")
|
|||||||
local BagMenu = {}
|
local BagMenu = {}
|
||||||
|
|
||||||
local Bag = require("src.inventory.Bag")
|
local Bag = require("src.inventory.Bag")
|
||||||
|
local Strings = require("src.core.Strings")
|
||||||
|
|
||||||
-- acquisition order like wBagItems (Bag.order), not alphabetical
|
-- acquisition order like wBagItems (Bag.order), not alphabetical
|
||||||
local function buildItems(game)
|
local function buildItems(game)
|
||||||
@@ -108,13 +109,13 @@ local function useOn(game, battle, id, target, list, moveIndex)
|
|||||||
if game.save.onBike then
|
if game.save.onBike then
|
||||||
game.save.onBike = false
|
game.save.onBike = false
|
||||||
Music.playMap(game.data, ow and ow.map.id, false)
|
Music.playMap(game.data, ow and ow.map.id, false)
|
||||||
showMessages(game, { save_name(game) .. " got off\nthe BICYCLE." })
|
showMessages(game, { Strings("%s got off\nthe BICYCLE.", save_name(game)) })
|
||||||
elseif bikeAllowed() then
|
elseif bikeAllowed() then
|
||||||
game.save.onBike = true
|
game.save.onBike = true
|
||||||
Music.playMap(game.data, ow.map.id, true)
|
Music.playMap(game.data, ow.map.id, true)
|
||||||
showMessages(game, { save_name(game) .. " got on\nthe BICYCLE!" })
|
showMessages(game, { Strings("%s got on\nthe BICYCLE!", save_name(game)) })
|
||||||
else
|
else
|
||||||
showMessages(game, { "No cycling\nallowed here." })
|
showMessages(game, { Strings("No cycling\nallowed here.") })
|
||||||
end
|
end
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
@@ -130,13 +131,14 @@ local function useOn(game, battle, id, target, list, moveIndex)
|
|||||||
return
|
return
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
showMessages(game, { "No good! It's not\neven near water." })
|
showMessages(game, { Strings("No good! It's not\neven near water.") })
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
|
|
||||||
if result == "ball" then
|
if result == "ball" then
|
||||||
if not battle then
|
if not battle then
|
||||||
showMessages(game, { "OAK: " .. game.save.player.name .. "!\nThis isn't the\ntime to use that!" })
|
showMessages(game, { Strings("OAK: %s!\nThis isn't the\ntime to use that!",
|
||||||
|
game.save.player.name) })
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
consume(game, id)
|
consume(game, id)
|
||||||
@@ -151,7 +153,7 @@ local function useOn(game, battle, id, target, list, moveIndex)
|
|||||||
local function teach()
|
local function teach()
|
||||||
if #target.moves < 4 then
|
if #target.moves < 4 then
|
||||||
table.insert(target.moves, { id = moveId, pp = mdef.pp })
|
table.insert(target.moves, { id = moveId, pp = mdef.pp })
|
||||||
showMessages(game, { ("%s learned\n%s!"):format(target.nickname or
|
showMessages(game, { Strings("%s learned\n%s!", target.nickname or
|
||||||
game.data.pokemon[target.species].name, mdef.name) })
|
game.data.pokemon[target.species].name, mdef.name) })
|
||||||
if result == "learn" then consume(game, id) end
|
if result == "learn" then consume(game, id) end
|
||||||
else
|
else
|
||||||
@@ -172,7 +174,7 @@ local function useOn(game, battle, id, target, list, moveIndex)
|
|||||||
require("src.ui.Screens").push(game, "TownMap")
|
require("src.ui.Screens").push(game, "TownMap")
|
||||||
end)
|
end)
|
||||||
if not ok then
|
if not ok then
|
||||||
showMessages(game, { "The TOWN MAP is\nunreadable here." })
|
showMessages(game, { Strings("The TOWN MAP is\nunreadable here.") })
|
||||||
end
|
end
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
@@ -184,10 +186,10 @@ local function useOn(game, battle, id, target, list, moveIndex)
|
|||||||
local t = game.data.text
|
local t = game.data.text
|
||||||
if ow and ow:hasHiddenItemLeft() then
|
if ow and ow:hasHiddenItemLeft() then
|
||||||
showMessages(game, { t._ItemfinderFoundItemText
|
showMessages(game, { t._ItemfinderFoundItemText
|
||||||
or "Yes! ITEMFINDER\nindicates there's\nan item nearby." })
|
or Strings("Yes! ITEMFINDER\nindicates there's\nan item nearby.") })
|
||||||
else
|
else
|
||||||
showMessages(game, { t._ItemfinderFoundNothingText
|
showMessages(game, { t._ItemfinderFoundNothingText
|
||||||
or "Nope! ITEMFINDER\nisn't responding." })
|
or Strings("Nope! ITEMFINDER\nisn't responding.") })
|
||||||
end
|
end
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
@@ -214,14 +216,14 @@ 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, { Strings(
|
||||||
.. "!\nThis isn't the\ntime to use that!" })
|
"OAK: %s!\nThis isn't the\ntime to use that!",
|
||||||
|
game.save.player.name) })
|
||||||
end
|
end
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
@@ -265,7 +267,7 @@ local function useOn(game, battle, id, target, list, moveIndex)
|
|||||||
if #target.moves < 4 then
|
if #target.moves < 4 then
|
||||||
table.insert(target.moves, { id = moveId, pp = mdef.pp })
|
table.insert(target.moves, { id = moveId, pp = mdef.pp })
|
||||||
local name = target.nickname or def.name
|
local name = target.nickname or def.name
|
||||||
showMessages(game, { ("%s learned\n%s!"):format(name, mdef.name) },
|
showMessages(game, { Strings("%s learned\n%s!", name, mdef.name) },
|
||||||
nextStep)
|
nextStep)
|
||||||
else
|
else
|
||||||
require("src.ui.Screens").push(game, "MoveLearnMenu",
|
require("src.ui.Screens").push(game, "MoveLearnMenu",
|
||||||
@@ -303,7 +305,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,7 +329,17 @@ 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)
|
||||||
@@ -344,8 +357,8 @@ local function useItem(game, battle, id, list)
|
|||||||
local moveDef = game.data.moves[def.machine.move]
|
local moveDef = game.data.moves[def.machine.move]
|
||||||
local moveName = moveDef and moveDef.name or def.machine.move
|
local moveName = moveDef and moveDef.name or def.machine.move
|
||||||
local booted = def.machine.kind == "HM"
|
local booted = def.machine.kind == "HM"
|
||||||
and "Booted up an HM!" or "Booted up a TM!"
|
and "Booted up an HM!" or Strings("Booted up a TM!")
|
||||||
showMessages(game, { booted, ("It contained\n%s!"):format(moveName) },
|
showMessages(game, { booted, Strings("It contained\n%s!", moveName) },
|
||||||
function() pickTargetAndUse(game, battle, id, list) end)
|
function() pickTargetAndUse(game, battle, id, list) end)
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
@@ -360,7 +373,10 @@ 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),
|
||||||
|
-- B returns to the start menu when the bag was opened from it
|
||||||
|
onCancel = opts.onCancel,
|
||||||
-- 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)
|
||||||
if not item then return end
|
if not item then return end
|
||||||
@@ -389,17 +405,23 @@ function BagMenu.new(game, opts)
|
|||||||
useItem(game, battle, id, list)
|
useItem(game, battle, id, list)
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
-- USE / TOSS submenu (the original's item options)
|
-- USE / TOSS submenu (the original's item options).
|
||||||
|
-- data/text_boxes.asm USE_TOSS_MENU_TEMPLATE: box (13,10)-(19,14),
|
||||||
|
-- text at (15,11); start_sub_menus.asm then sets wTopMenuItemY/X to
|
||||||
|
-- 11/14 for the cursor. Menu's own geometry reproduces all of that
|
||||||
|
-- from the box alone, so this needs opts rather than a change to the
|
||||||
|
-- shared Menu. The old 12/10/8/6 box was a column too wide and a row
|
||||||
|
-- too tall, which left the labels stranded near its top edge (#284).
|
||||||
local Menu = require("src.ui.Menu")
|
local Menu = require("src.ui.Menu")
|
||||||
game.stack:push(Menu.new(game, {
|
game.stack:push(Menu.new(game, {
|
||||||
{ label = "USE", onSelect = function()
|
{ label = Strings("USE"), onSelect = function()
|
||||||
useItem(game, battle, id, list)
|
useItem(game, battle, id, list)
|
||||||
end },
|
end },
|
||||||
{ label = "TOSS", onSelect = function()
|
{ label = Strings("TOSS"), onSelect = function()
|
||||||
-- KeyItemFlags + HMs decide tossability (not price:
|
-- KeyItemFlags + HMs decide tossability (not price:
|
||||||
-- MOON STONE is price 0 but tossable)
|
-- MOON STONE is price 0 but tossable)
|
||||||
if not def or def.keyItem or id:find("^HM_") then
|
if not def or def.keyItem or id:find("^HM_") then
|
||||||
showMessages(game, { "That's too impor-\ntant to toss!" })
|
showMessages(game, { Strings("That's too impor-\ntant to toss!") })
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
local QuantityBox = require("src.ui.QuantityBox")
|
local QuantityBox = require("src.ui.QuantityBox")
|
||||||
@@ -413,12 +435,12 @@ function BagMenu.new(game, opts)
|
|||||||
Bag.remove(game.save, id, qty)
|
Bag.remove(game.save, id, qty)
|
||||||
list.items = buildItems(game)
|
list.items = buildItems(game)
|
||||||
list.index = math.min(list.index, math.max(1, #list.items))
|
list.index = math.min(list.index, math.max(1, #list.items))
|
||||||
showMessages(game, { ("Threw away\n%s."):format(def and def.name or id) })
|
showMessages(game, { Strings("Threw away\n%s.", def and def.name or id) })
|
||||||
end))
|
end))
|
||||||
end,
|
end,
|
||||||
}))
|
}))
|
||||||
end },
|
end },
|
||||||
}, { tx = 12, ty = 10, tw = 8, th = 6 }))
|
}, { tx = 13, ty = 10, tw = 7, th = 5 }))
|
||||||
end,
|
end,
|
||||||
})
|
})
|
||||||
return list
|
return list
|
||||||
|
|||||||
@@ -7,6 +7,7 @@
|
|||||||
local Font = require("src.render.Font")
|
local Font = require("src.render.Font")
|
||||||
local ListMenu = require("src.ui.ListMenu")
|
local ListMenu = require("src.ui.ListMenu")
|
||||||
local Input = require("src.core.Input")
|
local Input = require("src.core.Input")
|
||||||
|
local Strings = require("src.core.Strings")
|
||||||
|
|
||||||
local BindingsMenu = setmetatable({}, { __index = ListMenu })
|
local BindingsMenu = setmetatable({}, { __index = ListMenu })
|
||||||
BindingsMenu.__index = BindingsMenu
|
BindingsMenu.__index = BindingsMenu
|
||||||
@@ -55,7 +56,9 @@ function BindingsMenu.new(game)
|
|||||||
and game.save.options.bindings
|
and game.save.options.bindings
|
||||||
local items = {}
|
local items = {}
|
||||||
for i, def in ipairs(BUTTONS) do
|
for i, def in ipairs(BUTTONS) do
|
||||||
items[i] = { label = def.label,
|
-- translated here, not in ROWS: that table is built at require
|
||||||
|
-- time, before Strings.load has a catalog to look in
|
||||||
|
items[i] = { label = Strings(def.label),
|
||||||
right = boundRight(overlay, def), button = def }
|
right = boundRight(overlay, def), button = def }
|
||||||
end
|
end
|
||||||
local self = setmetatable(ListMenu.new(game, "CONTROLS", items, {}),
|
local self = setmetatable(ListMenu.new(game, "CONTROLS", items, {}),
|
||||||
@@ -111,7 +114,7 @@ function BindingsMenu:draw()
|
|||||||
if self.capture then
|
if self.capture then
|
||||||
Font.drawBox(1, 6, 18, 4)
|
Font.drawBox(1, 6, 18, 4)
|
||||||
love.graphics.setColor(0, 0, 0, 1)
|
love.graphics.setColor(0, 0, 0, 1)
|
||||||
Font.draw("PRESS A BUTTON", 24, 60)
|
Font.draw(Strings("PRESS A BUTTON"), 24, 60)
|
||||||
love.graphics.setColor(1, 1, 1, 1)
|
love.graphics.setColor(1, 1, 1, 1)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -8,12 +8,13 @@ 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 TextBox = require("src.render.TextBox")
|
||||||
|
local Strings = require("src.core.Strings")
|
||||||
|
|
||||||
local BoxMenu = {}
|
local BoxMenu = {}
|
||||||
|
|
||||||
local function monLabel(game, mon)
|
local function monLabel(game, mon)
|
||||||
local def = game.data.pokemon[mon.species]
|
local def = game.data.pokemon[mon.species]
|
||||||
return ("%s :L%d"):format(mon.nickname or def.name, mon.level)
|
return Strings("%s :L%d", mon.nickname or def.name, mon.level)
|
||||||
end
|
end
|
||||||
|
|
||||||
local function monName(game, mon)
|
local function monName(game, mon)
|
||||||
@@ -28,13 +29,13 @@ local function monSubmenu(game, action, mon, onAction)
|
|||||||
game.stack:push(Menu.new(game, {
|
game.stack:push(Menu.new(game, {
|
||||||
{ label = action, onSelect = onAction },
|
{ label = action, onSelect = onAction },
|
||||||
{
|
{
|
||||||
label = "STATS",
|
label = Strings("STATS"),
|
||||||
keepOpen = true,
|
keepOpen = true,
|
||||||
onSelect = function()
|
onSelect = function()
|
||||||
require("src.ui.Screens").push(game, "SummaryMenu", mon)
|
require("src.ui.Screens").push(game, "SummaryMenu", mon)
|
||||||
end,
|
end,
|
||||||
},
|
},
|
||||||
{ label = "CANCEL" },
|
{ label = Strings("CANCEL") },
|
||||||
}, { tx = 9, ty = 10, tw = 11, th = 8, noSound = true }))
|
}, { tx = 9, ty = 10, tw = 11, th = 8, noSound = true }))
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -50,12 +51,12 @@ local function withdraw(game)
|
|||||||
local t = game.data.text
|
local t = game.data.text
|
||||||
if #box == 0 then
|
if #box == 0 then
|
||||||
game.stack:push(TextBox.new(game, t._NoMonText
|
game.stack:push(TextBox.new(game, t._NoMonText
|
||||||
or "What? There are\nno POKéMON here!"))
|
or Strings("What? There are\nno POKéMON here!")))
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
if #game.save.party >= Party.MAX then
|
if #game.save.party >= Party.MAX then
|
||||||
game.stack:push(TextBox.new(game, t._CantTakeMonText
|
game.stack:push(TextBox.new(game, t._CantTakeMonText
|
||||||
or "You can't take\nany more POKéMON.\fDeposit POKéMON\nfirst."))
|
or Strings("You can't take\nany more POKéMON.\fDeposit POKéMON\nfirst.")))
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
local items = {}
|
local items = {}
|
||||||
@@ -63,7 +64,7 @@ local function withdraw(game)
|
|||||||
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 (WITHDRAW)"):format(game.save.currentBox), items, {
|
Strings("BOX %d (WITHDRAW)", game.save.currentBox), items, {
|
||||||
onChoose = function(item, list)
|
onChoose = function(item, list)
|
||||||
local mon = box[item.value]
|
local mon = box[item.value]
|
||||||
if not mon then return end
|
if not mon then return end
|
||||||
@@ -78,7 +79,7 @@ local function withdraw(game)
|
|||||||
game.stringBuffer = name
|
game.stringBuffer = name
|
||||||
require("src.core.Sound").playCry(game.data, mon.species)
|
require("src.core.Sound").playCry(game.data, mon.species)
|
||||||
afterTransfer(game, list, t._MonIsTakenOutText
|
afterTransfer(game, list, t._MonIsTakenOutText
|
||||||
or (name .. " is\ntaken out.\vGot " .. name .. "."))
|
or Strings("%s is\ntaken out.\vGot %s.", name, name))
|
||||||
end)
|
end)
|
||||||
end,
|
end,
|
||||||
}))
|
}))
|
||||||
@@ -88,13 +89,13 @@ local function deposit(game)
|
|||||||
local t = game.data.text
|
local t = game.data.text
|
||||||
if #game.save.party <= 1 then
|
if #game.save.party <= 1 then
|
||||||
game.stack:push(TextBox.new(game, t._CantDepositLastMonText
|
game.stack:push(TextBox.new(game, t._CantDepositLastMonText
|
||||||
or "You can't deposit\nthe last POKéMON!"))
|
or Strings("You can't deposit\nthe last POKéMON!")))
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
local box = Boxes.active(game.save)
|
local box = Boxes.active(game.save)
|
||||||
if #box >= Boxes.CAPACITY then
|
if #box >= Boxes.CAPACITY then
|
||||||
game.stack:push(TextBox.new(game, t._BoxFullText
|
game.stack:push(TextBox.new(game, t._BoxFullText
|
||||||
or "Oops! This Box is\nfull of POKéMON."))
|
or Strings("Oops! This Box is\nfull of POKéMON.")))
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
local items = {}
|
local items = {}
|
||||||
@@ -107,12 +108,12 @@ local function deposit(game)
|
|||||||
if not mon then return end
|
if not mon then return end
|
||||||
monSubmenu(game, "DEPOSIT", mon, function()
|
monSubmenu(game, "DEPOSIT", mon, function()
|
||||||
if #game.save.party <= 1 then
|
if #game.save.party <= 1 then
|
||||||
list.footer = "You need at least\none POKéMON!"
|
list.footer = Strings("You need at least\none POKéMON!")
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
local active = Boxes.active(game.save)
|
local active = Boxes.active(game.save)
|
||||||
if #active >= Boxes.CAPACITY then
|
if #active >= Boxes.CAPACITY then
|
||||||
list.footer = ("BOX %d is full!"):format(game.save.currentBox)
|
list.footer = Strings("BOX %d is full!", game.save.currentBox)
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
table.remove(game.save.party, item.value)
|
table.remove(game.save.party, item.value)
|
||||||
@@ -122,7 +123,7 @@ local function deposit(game)
|
|||||||
game.boxNumString = tostring(game.save.currentBox)
|
game.boxNumString = tostring(game.save.currentBox)
|
||||||
require("src.core.Sound").playCry(game.data, mon.species)
|
require("src.core.Sound").playCry(game.data, mon.species)
|
||||||
afterTransfer(game, list, t._MonWasStoredText
|
afterTransfer(game, list, t._MonWasStoredText
|
||||||
or (name .. " was\nstored in Box " .. game.boxNumString .. "."))
|
or Strings("%s was\nstored in Box %s.", name, game.boxNumString))
|
||||||
end)
|
end)
|
||||||
end,
|
end,
|
||||||
}))
|
}))
|
||||||
@@ -135,7 +136,7 @@ local function release(game)
|
|||||||
local t = game.data.text
|
local t = game.data.text
|
||||||
if #box == 0 then
|
if #box == 0 then
|
||||||
game.stack:push(TextBox.new(game, t._NoMonText
|
game.stack:push(TextBox.new(game, t._NoMonText
|
||||||
or "What? There are\nno POKéMON here!"))
|
or Strings("What? There are\nno POKéMON here!")))
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
local items = {}
|
local items = {}
|
||||||
@@ -143,20 +144,20 @@ local function release(game)
|
|||||||
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, {
|
Strings("BOX %d (RELEASE)", game.save.currentBox), items, {
|
||||||
onChoose = function(_, list)
|
onChoose = function(_, list)
|
||||||
local mon = box[list.index]
|
local mon = box[list.index]
|
||||||
if not mon then return end
|
if not mon then return end
|
||||||
local name = monName(game, mon)
|
local name = monName(game, mon)
|
||||||
local ChoiceBox = require("src.ui.ChoiceBox")
|
local ChoiceBox = require("src.ui.ChoiceBox")
|
||||||
game.stack:push(TextBox.new(game,
|
game.stack:push(TextBox.new(game,
|
||||||
"Once released,\n" .. name .. " is\ngone forever. OK?", function()
|
Strings("Once released,\n%s is\ngone forever. OK?", name), 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, list.index)
|
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)))
|
Strings("%s was\nreleased outside.\fBye %s!", name, name)))
|
||||||
list:removeCurrent()
|
list:removeCurrent()
|
||||||
end, { defaultNo = true, noSound = true }))
|
end, { defaultNo = true, noSound = true }))
|
||||||
end))
|
end))
|
||||||
@@ -170,7 +171,7 @@ local function changeBox(game)
|
|||||||
for i = 1, Boxes.COUNT do
|
for i = 1, Boxes.COUNT do
|
||||||
local mark = i == game.save.currentBox and "*" or " "
|
local mark = i == game.save.currentBox and "*" or " "
|
||||||
table.insert(items, {
|
table.insert(items, {
|
||||||
label = ("%sBOX %2d"):format(mark, i),
|
label = Strings("%sBOX %2d", mark, i),
|
||||||
right = ("%d/%d"):format(#boxes[i], Boxes.CAPACITY),
|
right = ("%d/%d"):format(#boxes[i], Boxes.CAPACITY),
|
||||||
value = i,
|
value = i,
|
||||||
})
|
})
|
||||||
@@ -181,7 +182,7 @@ local function changeBox(game)
|
|||||||
-- 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")
|
||||||
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()
|
Strings("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)
|
||||||
if not yes then return end
|
if not yes then return end
|
||||||
game.save.currentBox = item.value
|
game.save.currentBox = item.value
|
||||||
@@ -197,10 +198,10 @@ end
|
|||||||
local function drawChrome(game)
|
local function drawChrome(game)
|
||||||
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)
|
||||||
Font.draw("What?", 8, 112)
|
Font.draw(Strings("What?"), 8, 112)
|
||||||
Font.drawBox(9, 14, 11, 4)
|
Font.drawBox(9, 14, 11, 4)
|
||||||
love.graphics.setColor(0, 0, 0, 1)
|
love.graphics.setColor(0, 0, 0, 1)
|
||||||
Font.draw("BOX No.", 80, 128)
|
Font.draw(Strings("BOX No."), 80, 128)
|
||||||
local n = game.save.currentBox or 1
|
local n = game.save.currentBox or 1
|
||||||
if n >= 10 then
|
if n >= 10 then
|
||||||
Font.draw(tostring(n), 136, 128)
|
Font.draw(tostring(n), 136, 128)
|
||||||
@@ -217,15 +218,15 @@ function BoxMenu.new(game)
|
|||||||
-- full interior (cursor col + label). keepOpen so WITHDRAW/DEPOSIT/
|
-- full interior (cursor col + label). keepOpen so WITHDRAW/DEPOSIT/
|
||||||
-- RELEASE/CHANGE BOX leave this menu underneath (jp BillsPCMenu).
|
-- RELEASE/CHANGE BOX leave this menu underneath (jp BillsPCMenu).
|
||||||
local menu = Menu.new(game, {
|
local menu = Menu.new(game, {
|
||||||
{ label = "WITHDRAW <PK><MN>", keepOpen = true,
|
{ label = Strings("WITHDRAW <PK><MN>"), keepOpen = true,
|
||||||
onSelect = function() withdraw(game) end },
|
onSelect = function() withdraw(game) end },
|
||||||
{ label = "DEPOSIT <PK><MN>", keepOpen = true,
|
{ label = Strings("DEPOSIT <PK><MN>"), keepOpen = true,
|
||||||
onSelect = function() deposit(game) end },
|
onSelect = function() deposit(game) end },
|
||||||
{ label = "RELEASE <PK><MN>", keepOpen = true,
|
{ label = Strings("RELEASE <PK><MN>"), keepOpen = true,
|
||||||
onSelect = function() release(game) end },
|
onSelect = function() release(game) end },
|
||||||
{ label = "CHANGE BOX", keepOpen = true,
|
{ label = Strings("CHANGE BOX"), keepOpen = true,
|
||||||
onSelect = function() changeBox(game) end },
|
onSelect = function() changeBox(game) end },
|
||||||
{ label = "SEE YA!" },
|
{ label = Strings("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 })
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
local Font = require("src.render.Font")
|
local Font = require("src.render.Font")
|
||||||
local Theme = require("src.ui.Theme")
|
local Theme = require("src.ui.Theme")
|
||||||
|
local Strings = require("src.core.Strings")
|
||||||
|
|
||||||
local ChoiceBox = {}
|
local ChoiceBox = {}
|
||||||
ChoiceBox.__index = ChoiceBox
|
ChoiceBox.__index = ChoiceBox
|
||||||
@@ -46,8 +47,8 @@ function ChoiceBox:draw()
|
|||||||
local tx, ty, tw, th = self.tx, self.ty, self.tw, self.th
|
local tx, ty, tw, th = self.tx, self.ty, self.tw, self.th
|
||||||
Font.drawBox(tx, ty, tw, th)
|
Font.drawBox(tx, ty, tw, th)
|
||||||
love.graphics.setColor(0, 0, 0, 1)
|
love.graphics.setColor(0, 0, 0, 1)
|
||||||
Font.draw("YES", (tx + 2) * 8, (ty + 1) * 8)
|
Font.draw(Strings("YES"), (tx + 2) * 8, (ty + 1) * 8)
|
||||||
Font.draw("NO", (tx + 2) * 8, (ty + 3) * 8)
|
Font.draw(Strings("NO"), (tx + 2) * 8, (ty + 3) * 8)
|
||||||
Font.drawCode(Theme.cursor, (tx + 1) * 8,
|
Font.drawCode(Theme.cursor, (tx + 1) * 8,
|
||||||
(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)
|
||||||
|
|||||||
@@ -31,6 +31,7 @@
|
|||||||
|
|
||||||
local Font = require("src.render.Font")
|
local Font = require("src.render.Font")
|
||||||
local Music = require("src.core.Music")
|
local Music = require("src.core.Music")
|
||||||
|
local Strings = require("src.core.Strings")
|
||||||
|
|
||||||
local Credits = {}
|
local Credits = {}
|
||||||
Credits.__index = Credits
|
Credits.__index = Credits
|
||||||
@@ -140,8 +141,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);
|
||||||
@@ -260,14 +262,14 @@ function Credits:drawCopyright(xoff)
|
|||||||
love.graphics.draw(self.gfImg, xoff + 80, 88)
|
love.graphics.draw(self.gfImg, xoff + 80, 88)
|
||||||
else
|
else
|
||||||
love.graphics.setColor(0, 0, 0, 1)
|
love.graphics.setColor(0, 0, 0, 1)
|
||||||
Font.draw("GAME FREAK", xoff + 80, 88)
|
Font.draw(Strings("GAME FREAK"), xoff + 80, 88)
|
||||||
love.graphics.setColor(1, 1, 1, 1)
|
love.graphics.setColor(1, 1, 1, 1)
|
||||||
end
|
end
|
||||||
else
|
else
|
||||||
love.graphics.setColor(0, 0, 0, 1)
|
love.graphics.setColor(0, 0, 0, 1)
|
||||||
Font.draw("Nintendo", xoff + 80, 56)
|
Font.draw(Strings("Nintendo"), xoff + 80, 56)
|
||||||
Font.draw("Creatures inc.", xoff + 80, 72)
|
Font.draw(Strings("Creatures inc."), xoff + 80, 72)
|
||||||
Font.draw("GAME FREAK inc.", xoff + 16, 88)
|
Font.draw(Strings("GAME FREAK inc."), xoff + 16, 88)
|
||||||
love.graphics.setColor(1, 1, 1, 1)
|
love.graphics.setColor(1, 1, 1, 1)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
@@ -297,7 +299,7 @@ function Credits:drawTheEnd()
|
|||||||
end
|
end
|
||||||
else
|
else
|
||||||
love.graphics.setColor(0, 0, 0, 1)
|
love.graphics.setColor(0, 0, 0, 1)
|
||||||
Font.draw((te and te.display) or "T H E E N D", 32, 64)
|
Font.draw((te and te.display) or Strings("T H E E N D"), 32, 64)
|
||||||
love.graphics.setColor(1, 1, 1, 1)
|
love.graphics.setColor(1, 1, 1, 1)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -8,6 +8,7 @@
|
|||||||
-- without permanently marking the mon owned.
|
-- without permanently marking the mon owned.
|
||||||
|
|
||||||
local Font = require("src.render.Font")
|
local Font = require("src.render.Font")
|
||||||
|
local Strings = require("src.core.Strings")
|
||||||
|
|
||||||
local DexEntryMenu = {}
|
local DexEntryMenu = {}
|
||||||
DexEntryMenu.__index = DexEntryMenu
|
DexEntryMenu.__index = DexEntryMenu
|
||||||
@@ -34,7 +35,9 @@ function DexEntryMenu.new(game, speciesOrOpts)
|
|||||||
local species, forceOwned = resolveArgs(speciesOrOpts)
|
local species, forceOwned = resolveArgs(speciesOrOpts)
|
||||||
local self = setmetatable({ game = game, forceOwned = forceOwned }, DexEntryMenu)
|
local self = setmetatable({ game = game, forceOwned = forceOwned }, DexEntryMenu)
|
||||||
self.def = game.data.pokemon[species]
|
self.def = game.data.pokemon[species]
|
||||||
local ok, img = pcall(love.graphics.newImage, self.def.spriteFront)
|
local path = require("src.pokemon.Sprites").path(game.data, species, "front",
|
||||||
|
{ kind = "dex" })
|
||||||
|
local ok, img = path and pcall(love.graphics.newImage, path)
|
||||||
self.sprite = ok and img or nil
|
self.sprite = ok and img or nil
|
||||||
require("src.core.Sound").playCry(game.data, species)
|
require("src.core.Sound").playCry(game.data, species)
|
||||||
return self
|
return self
|
||||||
@@ -74,8 +77,8 @@ function DexEntryMenu:draw()
|
|||||||
-- feet/inches use the dex screen's ′/″ glyphs ("HT ?′??″" in
|
-- feet/inches use the dex screen's ′/″ glyphs ("HT ?′??″" in
|
||||||
-- pokedex.asm; the tiles come from gfx/pokedex/pokedex.png via
|
-- pokedex.asm; the tiles come from gfx/pokedex/pokedex.png via
|
||||||
-- engine/gfx/load_pokedex_tiles.asm)
|
-- engine/gfx/load_pokedex_tiles.asm)
|
||||||
Font.draw(("HT %d′%02d″"):format(e.heightFt, e.heightIn or 0), 72, 44)
|
Font.draw(Strings("HT %d′%02d″", e.heightFt, e.heightIn or 0), 72, 44)
|
||||||
Font.draw(("WT %.1flb"):format((e.weight or 0) / 10), 72, 54)
|
Font.draw(Strings("WT %.1flb", (e.weight or 0) / 10), 72, 54)
|
||||||
end
|
end
|
||||||
local text = owned and e.text and self.game.data.text[e.text] or nil
|
local text = owned and e.text and self.game.data.text[e.text] or nil
|
||||||
local y = 72
|
local y = 72
|
||||||
@@ -86,7 +89,7 @@ function DexEntryMenu:draw()
|
|||||||
y = y + 10
|
y = y + 10
|
||||||
end
|
end
|
||||||
else
|
else
|
||||||
Font.draw("Data unknown.", 8, y)
|
Font.draw(Strings("Data unknown."), 8, y)
|
||||||
end
|
end
|
||||||
love.graphics.setColor(1, 1, 1, 1)
|
love.graphics.setColor(1, 1, 1, 1)
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -1,11 +1,17 @@
|
|||||||
-- The evolution movie (engine/movie/evolution.asm): the mon's pic
|
-- The evolution movie (engine/movie/evolution.asm): the mon's pic
|
||||||
-- flashes back and forth with the evolved form, speeding up, then the
|
-- flashes back and forth with the evolved form, speeding up, then the
|
||||||
-- new form appears with its cry and the congratulations text.
|
-- new form appears with its cry and the congratulations text.
|
||||||
-- B during the flash cancels ("Huh? ... stopped evolving!"? -- Gen 1
|
-- pokered engine/movie/evolution.asm (Evolution_CheckForCancel) polls the
|
||||||
-- has no cancel; the flash always completes).
|
-- joypad during the flash: holding B aborts the evolution -- the mon keeps
|
||||||
|
-- its species and _StoppedEvolvingText ("Huh? MON stopped evolving!")
|
||||||
|
-- prints. Two kinds are exempt: trade evolutions, which evos_moves.asm
|
||||||
|
-- routes past the poll entirely (wLinkState == LINK_STATE_TRADING, #213),
|
||||||
|
-- and stone evolutions, where the B press is read but thrown away because
|
||||||
|
-- ItemUseEvoStone left wForceEvolution set (#290).
|
||||||
|
|
||||||
local Font = require("src.render.Font")
|
local Font = require("src.render.Font")
|
||||||
local Music = require("src.core.Music")
|
local Music = require("src.core.Music")
|
||||||
|
local Strings = require("src.core.Strings")
|
||||||
|
|
||||||
local EvolutionState = {}
|
local EvolutionState = {}
|
||||||
EvolutionState.__index = EvolutionState
|
EvolutionState.__index = EvolutionState
|
||||||
@@ -14,7 +20,10 @@ EvolutionState.isOpaque = true
|
|||||||
-- SGB: SetPal_PokemonWholeScreen for the mon on display
|
-- SGB: SetPal_PokemonWholeScreen for the mon on display
|
||||||
function EvolutionState:sgbPalettes(game)
|
function EvolutionState:sgbPalettes(game)
|
||||||
local P = require("src.render.PaletteFX")
|
local P = require("src.render.PaletteFX")
|
||||||
local species = self.done and self.newSpecies or self.mon.species
|
-- a cancelled evolution keeps the old species (never applied), so only
|
||||||
|
-- colorize with the new form once it has actually evolved
|
||||||
|
local species = (self.done and not self.canceled) and self.newSpecies
|
||||||
|
or self.mon.species
|
||||||
local c = P.monPal(game.data, species)
|
local c = P.monPal(game.data, species)
|
||||||
if c then return { P.whole(c) } end
|
if c then return { P.whole(c) } end
|
||||||
return P.wholeNamed(game.data, "MEWMON")
|
return P.wholeNamed(game.data, "MEWMON")
|
||||||
@@ -22,24 +31,33 @@ end
|
|||||||
|
|
||||||
local FLASH_FRAMES = 220
|
local FLASH_FRAMES = 220
|
||||||
|
|
||||||
local function frontSprite(game, species)
|
local function frontSprite(game, species, mon)
|
||||||
local def = game.data.pokemon[species]
|
local path = require("src.pokemon.Sprites").path(game.data, species, "front",
|
||||||
if not (def and def.spriteFront) then return nil end
|
{ mon = mon, kind = "evolution" })
|
||||||
local ok, img = pcall(love.graphics.newImage, def.spriteFront)
|
if not path then return nil end
|
||||||
|
local ok, img = pcall(love.graphics.newImage, path)
|
||||||
return ok and img or nil
|
return ok and img or nil
|
||||||
end
|
end
|
||||||
|
|
||||||
function EvolutionState.new(game, mon, newSpecies, onDone)
|
function EvolutionState.new(game, mon, newSpecies, onDone, via)
|
||||||
local self = setmetatable({}, EvolutionState)
|
local self = setmetatable({}, EvolutionState)
|
||||||
self.game = game
|
self.game = game
|
||||||
self.mon = mon
|
self.mon = mon
|
||||||
self.newSpecies = newSpecies
|
self.newSpecies = newSpecies
|
||||||
self.onDone = onDone
|
self.onDone = onDone
|
||||||
|
self.via = via
|
||||||
|
-- evolution.asm Evolution_CheckForCancel: a B press is discarded when
|
||||||
|
-- wForceEvolution is set, and ItemUseEvoStone sets it before calling
|
||||||
|
-- TryEvolvingMon, so a stone evolution (via == "ITEM") cannot be
|
||||||
|
-- cancelled either. Only level-up and rare-candy evolutions run with
|
||||||
|
-- wForceEvolution clear and so honour B (#290, #213).
|
||||||
|
self.cancelable = (via ~= "TRADE" and via ~= "ITEM")
|
||||||
self.oldName = mon.nickname or game.data.pokemon[mon.species].name
|
self.oldName = mon.nickname or game.data.pokemon[mon.species].name
|
||||||
self.oldSprite = frontSprite(game, mon.species)
|
self.oldSprite = frontSprite(game, mon.species, mon)
|
||||||
self.newSprite = frontSprite(game, newSpecies)
|
self.newSprite = frontSprite(game, newSpecies, mon)
|
||||||
self.t = 0
|
self.t = 0
|
||||||
self.done = false
|
self.done = false
|
||||||
|
self.canceled = false
|
||||||
Music.play(game.data, Music.special(game.data, "evolution"))
|
Music.play(game.data, Music.special(game.data, "evolution"))
|
||||||
return self
|
return self
|
||||||
end
|
end
|
||||||
@@ -47,22 +65,44 @@ end
|
|||||||
function EvolutionState:update(dt)
|
function EvolutionState:update(dt)
|
||||||
self.t = self.t + 1
|
self.t = self.t + 1
|
||||||
if self.done then return end
|
if self.done then return end
|
||||||
if self.t >= FLASH_FRAMES then
|
local game = self.game
|
||||||
|
-- evos_moves.asm EvolveMon: each flash iteration polls hJoyHeld and, for
|
||||||
|
-- a cancelable evolution, aborts when B is held -- the mon keeps its
|
||||||
|
-- species (Evolution.apply never runs) and _StoppedEvolvingText prints.
|
||||||
|
if self.cancelable and game.input:isDown("b") then
|
||||||
self.done = true
|
self.done = true
|
||||||
local game = self.game
|
self.canceled = true
|
||||||
local Evolution = require("src.pokemon.Evolution")
|
|
||||||
Evolution.apply(game, self.mon, self.newSpecies)
|
|
||||||
require("src.core.Sound").playCry(game.data, self.newSpecies)
|
|
||||||
local TextBox = require("src.render.TextBox")
|
local TextBox = require("src.render.TextBox")
|
||||||
local newName = game.data.pokemon[self.newSpecies].name
|
-- mirrors data/generated/text.lua _StoppedEvolvingText
|
||||||
game.stack:push(TextBox.new(game,
|
game.stack:push(TextBox.new(game,
|
||||||
("Congratulations!\nYour %s\nevolved into\n%s!")
|
Strings("Huh? %s\nstopped evolving!", self.oldName),
|
||||||
:format(self.oldName, newName),
|
|
||||||
function()
|
function()
|
||||||
Music.restoreMap(game.data)
|
Music.restoreMap(game.data)
|
||||||
game.stack:pop() -- the evolution screen itself
|
game.stack:pop() -- the evolution screen itself
|
||||||
if self.onDone then self.onDone() end
|
if self.onDone then self.onDone() end
|
||||||
end))
|
end))
|
||||||
|
return
|
||||||
|
end
|
||||||
|
if self.t >= FLASH_FRAMES then
|
||||||
|
self.done = true
|
||||||
|
local Evolution = require("src.pokemon.Evolution")
|
||||||
|
Evolution.apply(game, self.mon, self.newSpecies, self.via)
|
||||||
|
require("src.core.Sound").playCry(game.data, self.newSpecies)
|
||||||
|
local TextBox = require("src.render.TextBox")
|
||||||
|
local newName = game.data.pokemon[self.newSpecies].name
|
||||||
|
game.stack:push(TextBox.new(game,
|
||||||
|
Strings("Congratulations!\nYour %s\nevolved into\n%s!",
|
||||||
|
self.oldName, newName),
|
||||||
|
function()
|
||||||
|
Music.restoreMap(game.data)
|
||||||
|
game.stack:pop() -- the evolution screen itself
|
||||||
|
-- Gen1 re-runs the level-up learn check on the evolved species
|
||||||
|
-- after the "evolved into" text (evos_moves.asm EvolveMon ->
|
||||||
|
-- learn_move.asm LearnMoveFromLevelUp, #12). Pop the evo screen
|
||||||
|
-- first so the "learned MOVE!" text / forget prompt push onto the
|
||||||
|
-- overworld / battle-return, not this state.
|
||||||
|
Evolution.learnEvolutionMoves(game, self.mon, self.onDone)
|
||||||
|
end))
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -73,7 +113,8 @@ function EvolutionState:draw()
|
|||||||
-- accelerating flash between the two forms
|
-- accelerating flash between the two forms
|
||||||
local sprite
|
local sprite
|
||||||
if self.done then
|
if self.done then
|
||||||
sprite = self.newSprite
|
-- a cancelled evolution settles back on the original form
|
||||||
|
sprite = self.canceled and self.oldSprite or self.newSprite
|
||||||
else
|
else
|
||||||
local period = math.max(4, 28 - math.floor(self.t / 40) * 6)
|
local period = math.max(4, 28 - math.floor(self.t / 40) * 6)
|
||||||
local showNew = math.floor(self.t / period) % 2 == 1
|
local showNew = math.floor(self.t / period) % 2 == 1
|
||||||
@@ -86,9 +127,9 @@ function EvolutionState:draw()
|
|||||||
|
|
||||||
love.graphics.setColor(0, 0, 0, 1)
|
love.graphics.setColor(0, 0, 0, 1)
|
||||||
if not self.done then
|
if not self.done then
|
||||||
Font.draw("What?", 8, 104)
|
Font.draw(Strings("What?"), 8, 104)
|
||||||
Font.draw(self.oldName .. " is", 8, 114)
|
Font.draw(self.oldName .. " is", 8, 114)
|
||||||
Font.draw("evolving!", 8, 124)
|
Font.draw(Strings("evolving!"), 8, 124)
|
||||||
end
|
end
|
||||||
love.graphics.setColor(1, 1, 1, 1)
|
love.graphics.setColor(1, 1, 1, 1)
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -11,9 +11,13 @@ function FlyMenu.new(game)
|
|||||||
local visited = game.save.visited or {}
|
local visited = game.save.visited or {}
|
||||||
local seen = {}
|
local seen = {}
|
||||||
for _, mapId in ipairs(game.data.field.flyOrder or {}) do
|
for _, mapId in ipairs(game.data.field.flyOrder or {}) do
|
||||||
-- towns only (dungeon escape spots share the table), each listed once
|
-- towns only (dungeon escape spots share the table), each listed once.
|
||||||
|
-- Indigo Plateau (tileset PLATEAU) is a valid Fly destination too, so allow
|
||||||
|
-- it past the OVERWORLD-only isOutdoor gate while the CAVERN/FACILITY escape
|
||||||
|
-- spots stay excluded (LoadTownMap_Fly cycles it like any town, #203).
|
||||||
local def = game.data.maps[mapId]
|
local def = game.data.maps[mapId]
|
||||||
if visited[mapId] and def and Map.isOutdoor(def) and not seen[mapId] then
|
if visited[mapId] and def and not seen[mapId]
|
||||||
|
and (Map.isOutdoor(def) or def.tileset == "PLATEAU") then
|
||||||
seen[mapId] = true
|
seen[mapId] = true
|
||||||
table.insert(items, {
|
table.insert(items, {
|
||||||
value = mapId,
|
value = mapId,
|
||||||
|
|||||||
@@ -7,10 +7,12 @@
|
|||||||
-- name/time/money boxes plus the dex rating. Plays Music_HallOfFame
|
-- name/time/money boxes plus the dex rating. Plays Music_HallOfFame
|
||||||
-- when the audio data has it. Calls onDone() after popping itself.
|
-- when the audio data has it. Calls onDone() after popping itself.
|
||||||
|
|
||||||
|
local Assets = require("src.render.Assets")
|
||||||
local Font = require("src.render.Font")
|
local Font = require("src.render.Font")
|
||||||
local Music = require("src.core.Music")
|
local Music = require("src.core.Music")
|
||||||
local Sound = require("src.core.Sound")
|
local Sound = require("src.core.Sound")
|
||||||
local TypeChart = require("src.battle.TypeChart")
|
local TypeChart = require("src.battle.TypeChart")
|
||||||
|
local Strings = require("src.core.Strings")
|
||||||
|
|
||||||
local HallOfFame = {}
|
local HallOfFame = {}
|
||||||
HallOfFame.__index = HallOfFame
|
HallOfFame.__index = HallOfFame
|
||||||
@@ -47,9 +49,11 @@ local FADE_FRAMES = 20
|
|||||||
-- HoFPrintTextAndDelay after each dex line
|
-- HoFPrintTextAndDelay after each dex line
|
||||||
local DEX_HOLD = 120
|
local DEX_HOLD = 120
|
||||||
|
|
||||||
|
-- through Assets.resolve so an enabled mod's overrides/ shadows these the
|
||||||
|
-- same way it shadows every other generated asset
|
||||||
local function tryImage(path)
|
local function tryImage(path)
|
||||||
if not path then return nil end
|
if not path then return nil end
|
||||||
local ok, img = pcall(love.graphics.newImage, path)
|
local ok, img = pcall(love.graphics.newImage, Assets.resolve(path))
|
||||||
return ok and img or nil
|
return ok and img or nil
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -79,7 +83,8 @@ function HallOfFame.new(game, onDone)
|
|||||||
self.timer = 0
|
self.timer = 0
|
||||||
self.phase = "mons"
|
self.phase = "mons"
|
||||||
self.sprites = {} -- species -> image or false
|
self.sprites = {} -- species -> image or false
|
||||||
self.playerPic = tryImage("assets/generated/trainer_card/red.png")
|
self.playerPic = tryImage(require("src.pokemon.Sprites").playerPath(
|
||||||
|
game.data, "front", { kind = "hof" }))
|
||||||
self.scrollX = PIC_X
|
self.scrollX = PIC_X
|
||||||
self.showHofBanner = false
|
self.showHofBanner = false
|
||||||
self.fade = 0
|
self.fade = 0
|
||||||
@@ -118,8 +123,9 @@ end
|
|||||||
function HallOfFame:spriteFor(species)
|
function HallOfFame:spriteFor(species)
|
||||||
local cached = self.sprites[species]
|
local cached = self.sprites[species]
|
||||||
if cached == nil then
|
if cached == nil then
|
||||||
local def = self.game.data.pokemon[species]
|
local path = require("src.pokemon.Sprites").path(
|
||||||
cached = tryImage(def and def.spriteFront) or false
|
self.game.data, species, "front", { kind = "hof" })
|
||||||
|
cached = tryImage(path) or false
|
||||||
self.sprites[species] = cached
|
self.sprites[species] = cached
|
||||||
end
|
end
|
||||||
return cached or nil
|
return cached or nil
|
||||||
@@ -205,13 +211,13 @@ function HallOfFame:drawMonInfo(mon)
|
|||||||
love.graphics.setColor(0, 0, 0, 1)
|
love.graphics.setColor(0, 0, 0, 1)
|
||||||
local name = mon.nickname or (def and def.name) or mon.species
|
local name = mon.nickname or (def and def.name) or mon.species
|
||||||
Font.draw(name, 1 * 8, 4 * 8)
|
Font.draw(name, 1 * 8, 4 * 8)
|
||||||
Font.draw("LEVEL/", 2 * 8, 6 * 8)
|
Font.draw(Strings("LEVEL/"), 2 * 8, 6 * 8)
|
||||||
Font.draw("TYPE1/", 2 * 8, 7 * 8)
|
Font.draw(Strings("TYPE1/"), 2 * 8, 7 * 8)
|
||||||
local t1 = def and def.types and def.types[1]
|
local t1 = def and def.types and def.types[1]
|
||||||
local t2 = def and def.types and def.types[2]
|
local t2 = def and def.types and def.types[2]
|
||||||
local dual = t2 and t2 ~= t1
|
local dual = t2 and t2 ~= t1
|
||||||
if dual then
|
if dual then
|
||||||
Font.draw("TYPE2/", 2 * 8, 8 * 8)
|
Font.draw(Strings("TYPE2/"), 2 * 8, 8 * 8)
|
||||||
end
|
end
|
||||||
-- PrintLevelCommon at (8,7): bare level digits (no <LV> tile here)
|
-- PrintLevelCommon at (8,7): bare level digits (no <LV> tile here)
|
||||||
Font.draw(tostring(mon.level), 8 * 8, 7 * 8)
|
Font.draw(tostring(mon.level), 8 * 8, 7 * 8)
|
||||||
@@ -228,7 +234,7 @@ end
|
|||||||
function HallOfFame:drawHofBanner()
|
function HallOfFame:drawHofBanner()
|
||||||
Font.drawBox(2, 13, 16, 5)
|
Font.drawBox(2, 13, 16, 5)
|
||||||
love.graphics.setColor(0, 0, 0, 1)
|
love.graphics.setColor(0, 0, 0, 1)
|
||||||
Font.draw("HALL OF FAME", 4 * 8, 15 * 8)
|
Font.draw(Strings("HALL OF FAME"), 4 * 8, 15 * 8)
|
||||||
end
|
end
|
||||||
|
|
||||||
function HallOfFame:drawPic(img)
|
function HallOfFame:drawPic(img)
|
||||||
@@ -248,11 +254,11 @@ function HallOfFame:drawPlayerStats()
|
|||||||
-- play time / money box: TextBoxBorder (0,4) b=6,c=10 → drawBox(0,4,12,8)
|
-- play time / money box: TextBoxBorder (0,4) b=6,c=10 → drawBox(0,4,12,8)
|
||||||
Font.drawBox(0, 4, 12, 8)
|
Font.drawBox(0, 4, 12, 8)
|
||||||
love.graphics.setColor(0, 0, 0, 1)
|
love.graphics.setColor(0, 0, 0, 1)
|
||||||
Font.draw("PLAY TIME", 1 * 8, 6 * 8)
|
Font.draw(Strings("PLAY TIME"), 1 * 8, 6 * 8)
|
||||||
local t = math.floor(save.playTime or 0)
|
local t = math.floor(save.playTime or 0)
|
||||||
Font.draw(("%3d:%02d"):format(math.floor(t / 3600), math.floor(t / 60) % 60),
|
Font.draw(("%3d:%02d"):format(math.floor(t / 3600), math.floor(t / 60) % 60),
|
||||||
5 * 8, 7 * 8)
|
5 * 8, 7 * 8)
|
||||||
Font.draw("MONEY", 1 * 8, 9 * 8)
|
Font.draw(Strings("MONEY"), 1 * 8, 9 * 8)
|
||||||
-- PrintBCDNumber with MONEY_SIGN; port uses ¥ like TrainerCard
|
-- PrintBCDNumber with MONEY_SIGN; port uses ¥ like TrainerCard
|
||||||
Font.draw(("¥%d"):format(save.money or 0), 4 * 8, 10 * 8)
|
Font.draw(("¥%d"):format(save.money or 0), 4 * 8, 10 * 8)
|
||||||
end
|
end
|
||||||
@@ -265,16 +271,16 @@ function HallOfFame:drawDexBox(kind)
|
|||||||
if kind == "seen" then
|
if kind == "seen" then
|
||||||
local seen, owned = self:dexSeenOwned()
|
local seen, owned = self:dexSeenOwned()
|
||||||
local seenOwned = text._DexSeenOwnedText
|
local seenOwned = text._DexSeenOwnedText
|
||||||
or "POKéDEX Seen:{NUM:wDexRatingNumMonsSeen, 1, 3}\n Owned:{NUM:wDexRatingNumMonsOwned, 1, 3}"
|
or Strings("POKéDEX Seen:{NUM:wDexRatingNumMonsSeen, 1, 3}\n Owned:{NUM:wDexRatingNumMonsOwned, 1, 3}")
|
||||||
seenOwned = seenOwned
|
seenOwned = seenOwned
|
||||||
:gsub("{NUM:wDexRatingNumMonsSeen[^}]*}", tostring(seen))
|
:gsub("{NUM:wDexRatingNumMonsSeen[^}]*}", tostring(seen))
|
||||||
:gsub("{NUM:wDexRatingNumMonsOwned[^}]*}", tostring(owned))
|
:gsub("{NUM:wDexRatingNumMonsOwned[^}]*}", tostring(owned))
|
||||||
drawTextBlock(seenOwned, 1 * 8, 14 * 8, 17 * 8)
|
drawTextBlock(seenOwned, 1 * 8, 14 * 8, 17 * 8)
|
||||||
else
|
else
|
||||||
local _, owned = self:dexSeenOwned()
|
local _, owned = self:dexSeenOwned()
|
||||||
local ratingHeader = (text._DexRatingText or "POKéDEX Rating{COLON}"):gsub("{COLON}", ":")
|
local ratingHeader = (text._DexRatingText or Strings("POKéDEX Rating{COLON}")):gsub("{COLON}", ":")
|
||||||
Font.draw(ratingHeader, 1 * 8, 14 * 8)
|
Font.draw(ratingHeader, 1 * 8, 14 * 8)
|
||||||
local rating = text[dexRatingKey(owned)] or "Keep it up!"
|
local rating = text[dexRatingKey(owned)] or Strings("Keep it up!")
|
||||||
drawTextBlock(rating, 1 * 8, 15 * 8, 17 * 8)
|
drawTextBlock(rating, 1 * 8, 15 * 8, 17 * 8)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -27,6 +27,7 @@
|
|||||||
local Font = require("src.render.Font")
|
local Font = require("src.render.Font")
|
||||||
local Music = require("src.core.Music")
|
local Music = require("src.core.Music")
|
||||||
local Sound = require("src.core.Sound")
|
local Sound = require("src.core.Sound")
|
||||||
|
local Strings = require("src.core.Strings")
|
||||||
|
|
||||||
local IntroMovie = {}
|
local IntroMovie = {}
|
||||||
IntroMovie.__index = IntroMovie
|
IntroMovie.__index = IntroMovie
|
||||||
@@ -287,7 +288,7 @@ function IntroMovie:drawSplash()
|
|||||||
end
|
end
|
||||||
-- custom studio name (replaces the GAME FREAK splash text)
|
-- custom studio name (replaces the GAME FREAK splash text)
|
||||||
love.graphics.setColor(0, 0, 0, dim and 0.35 or 1)
|
love.graphics.setColor(0, 0, 0, dim and 0.35 or 1)
|
||||||
local card = self.studio.card or "bois club games"
|
local card = self.studio.card or Strings("bois club games")
|
||||||
Font.draw(card, (160 - #card * 8) / 2, TEXT_Y)
|
Font.draw(card, (160 - #card * 8) / 2, TEXT_Y)
|
||||||
love.graphics.setColor(1, 1, 1, 1)
|
love.graphics.setColor(1, 1, 1, 1)
|
||||||
end
|
end
|
||||||
@@ -348,7 +349,7 @@ function IntroMovie:drawFight()
|
|||||||
|
|
||||||
if not gengar and not nido then
|
if not gengar and not nido then
|
||||||
love.graphics.setColor(0, 0, 0, 1)
|
love.graphics.setColor(0, 0, 0, 1)
|
||||||
Font.draw("GENGAR VS NIDORINO", (160 - 18 * 8) / 2, 64)
|
Font.draw(Strings("GENGAR VS NIDORINO"), (160 - 18 * 8) / 2, 64)
|
||||||
love.graphics.setColor(1, 1, 1, 1)
|
love.graphics.setColor(1, 1, 1, 1)
|
||||||
end
|
end
|
||||||
drawBars()
|
drawBars()
|
||||||
@@ -366,7 +367,7 @@ function IntroMovie:draw()
|
|||||||
-- custom boot card (replaces the Nintendo / GAME FREAK copyright
|
-- custom boot card (replaces the Nintendo / GAME FREAK copyright
|
||||||
-- card; no (c) glyph in the charmap, keep it ASCII-safe)
|
-- card; no (c) glyph in the charmap, keep it ASCII-safe)
|
||||||
love.graphics.setColor(0, 0, 0, 1)
|
love.graphics.setColor(0, 0, 0, 1)
|
||||||
local credit = self.studio.credit or "bois club"
|
local credit = self.studio.credit or Strings("bois club")
|
||||||
Font.draw("2026", (160 - 4 * 8) / 2, 48)
|
Font.draw("2026", (160 - 4 * 8) / 2, 48)
|
||||||
Font.draw(credit, (160 - #credit * 8) / 2, 64)
|
Font.draw(credit, (160 - #credit * 8) / 2, 64)
|
||||||
Font.draw("bryanthaboi", (160 - 11 * 8) / 2, 80)
|
Font.draw("bryanthaboi", (160 - 11 * 8) / 2, 80)
|
||||||
|
|||||||
@@ -3,7 +3,9 @@
|
|||||||
-- box and the Pokédex.
|
-- box and the Pokédex.
|
||||||
|
|
||||||
local Font = require("src.render.Font")
|
local Font = require("src.render.Font")
|
||||||
|
local Runtime = require("src.mods.Runtime")
|
||||||
local Theme = require("src.ui.Theme")
|
local Theme = require("src.ui.Theme")
|
||||||
|
local Strings = require("src.core.Strings")
|
||||||
|
|
||||||
local ListMenu = {}
|
local ListMenu = {}
|
||||||
ListMenu.__index = ListMenu
|
ListMenu.__index = ListMenu
|
||||||
@@ -15,9 +17,37 @@ function ListMenu:sgbPalettes(game)
|
|||||||
end
|
end
|
||||||
|
|
||||||
local ROWS = 7
|
local ROWS = 7
|
||||||
|
-- frames to wait before key-repeat kicks in, then between repeats
|
||||||
|
local REPEAT_DELAY = 16
|
||||||
|
local REPEAT_RATE = 4
|
||||||
|
|
||||||
|
-- ui.list_menu identity: unhooked opts pass through unchanged
|
||||||
|
local function sameOpts(opts) return opts end
|
||||||
|
|
||||||
function ListMenu.new(game, title, items, opts)
|
function ListMenu.new(game, title, items, opts)
|
||||||
opts = opts or {}
|
opts = opts or {}
|
||||||
|
-- bag / shop / dex / generic: mods may enable wrap, pageJump, keyRepeat
|
||||||
|
if Runtime.wantsHook("ui.list_menu") then
|
||||||
|
local hooked = Runtime.call("ui.list_menu", sameOpts, {
|
||||||
|
wrap = opts.wrap,
|
||||||
|
pageJump = opts.pageJump,
|
||||||
|
keyRepeat = opts.keyRepeat,
|
||||||
|
repeatDelay = opts.repeatDelay,
|
||||||
|
repeatRate = opts.repeatRate,
|
||||||
|
}, {
|
||||||
|
game = game,
|
||||||
|
title = title,
|
||||||
|
kind = opts.kind or title,
|
||||||
|
itemCount = items and #items or 0,
|
||||||
|
})
|
||||||
|
if type(hooked) == "table" then
|
||||||
|
if hooked.wrap ~= nil then opts.wrap = hooked.wrap end
|
||||||
|
if hooked.pageJump ~= nil then opts.pageJump = hooked.pageJump end
|
||||||
|
if hooked.keyRepeat ~= nil then opts.keyRepeat = hooked.keyRepeat end
|
||||||
|
if hooked.repeatDelay ~= nil then opts.repeatDelay = hooked.repeatDelay end
|
||||||
|
if hooked.repeatRate ~= nil then opts.repeatRate = hooked.repeatRate end
|
||||||
|
end
|
||||||
|
end
|
||||||
local self = setmetatable({}, ListMenu)
|
local self = setmetatable({}, ListMenu)
|
||||||
self.game = game
|
self.game = game
|
||||||
self.title = title
|
self.title = title
|
||||||
@@ -28,6 +58,12 @@ function ListMenu.new(game, title, items, opts)
|
|||||||
self.onCancel = opts.onCancel
|
self.onCancel = opts.onCancel
|
||||||
self.footer = opts.footer
|
self.footer = opts.footer
|
||||||
self.pageJump = opts.pageJump -- Left/Right move a page at a time
|
self.pageJump = opts.pageJump -- Left/Right move a page at a time
|
||||||
|
self.wrap = opts.wrap -- Up on first / Down on last wraps
|
||||||
|
self.keyRepeat = opts.keyRepeat -- hold Up/Down (and pageJump L/R) to scroll
|
||||||
|
self.repeatDelay = opts.repeatDelay or REPEAT_DELAY
|
||||||
|
self.repeatRate = opts.repeatRate or REPEAT_RATE
|
||||||
|
self.holdDir = nil
|
||||||
|
self.holdFrames = 0
|
||||||
self.onSelectKey = opts.onSelectKey -- SELECT pressed on an item
|
self.onSelectKey = opts.onSelectKey -- SELECT pressed on an item
|
||||||
-- scripted mode (the old man tutorial): update() runs the script
|
-- scripted mode (the old man tutorial): update() runs the script
|
||||||
-- every frame INSTEAD of reading input -- DisplayListMenuID's old-man
|
-- every frame INSTEAD of reading input -- DisplayListMenuID's old-man
|
||||||
@@ -46,6 +82,42 @@ function ListMenu.new(game, title, items, opts)
|
|||||||
return self
|
return self
|
||||||
end
|
end
|
||||||
|
|
||||||
|
local function moveIndex(self, delta)
|
||||||
|
local n = #self.items
|
||||||
|
if n == 0 then return end
|
||||||
|
local next = self.index + delta
|
||||||
|
if self.wrap then
|
||||||
|
next = ((next - 1) % n) + 1
|
||||||
|
else
|
||||||
|
next = math.max(1, math.min(n, next))
|
||||||
|
end
|
||||||
|
self.index = next
|
||||||
|
end
|
||||||
|
|
||||||
|
local function syncScroll(self)
|
||||||
|
if self.index - self.scroll > self.rows then
|
||||||
|
self.scroll = self.index - self.rows
|
||||||
|
end
|
||||||
|
if self.index - self.scroll < 1 then self.scroll = self.index - 1 end
|
||||||
|
end
|
||||||
|
|
||||||
|
-- edge press or key-repeat tick for a held direction
|
||||||
|
local function navPressed(self, dir)
|
||||||
|
if dir == "up" then
|
||||||
|
moveIndex(self, -1)
|
||||||
|
elseif dir == "down" then
|
||||||
|
moveIndex(self, 1)
|
||||||
|
elseif dir == "left" and self.pageJump then
|
||||||
|
moveIndex(self, -self.rows)
|
||||||
|
elseif dir == "right" and self.pageJump then
|
||||||
|
moveIndex(self, self.rows)
|
||||||
|
else
|
||||||
|
return false
|
||||||
|
end
|
||||||
|
syncScroll(self)
|
||||||
|
return true
|
||||||
|
end
|
||||||
|
|
||||||
function ListMenu:update(dt)
|
function ListMenu:update(dt)
|
||||||
if self.script then
|
if self.script then
|
||||||
self.script(self)
|
self.script(self)
|
||||||
@@ -59,14 +131,20 @@ function ListMenu:update(dt)
|
|||||||
end
|
end
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
|
|
||||||
|
local moved = false
|
||||||
if input:wasPressed("up") then
|
if input:wasPressed("up") then
|
||||||
self.index = math.max(1, self.index - 1)
|
moved = navPressed(self, "up")
|
||||||
|
self.holdDir, self.holdFrames = "up", 0
|
||||||
elseif input:wasPressed("down") then
|
elseif input:wasPressed("down") then
|
||||||
self.index = math.min(#self.items, self.index + 1)
|
moved = navPressed(self, "down")
|
||||||
|
self.holdDir, self.holdFrames = "down", 0
|
||||||
elseif self.pageJump and input:wasPressed("left") then
|
elseif self.pageJump and input:wasPressed("left") then
|
||||||
self.index = math.max(1, self.index - self.rows)
|
moved = navPressed(self, "left")
|
||||||
|
self.holdDir, self.holdFrames = "left", 0
|
||||||
elseif self.pageJump and input:wasPressed("right") then
|
elseif self.pageJump and input:wasPressed("right") then
|
||||||
self.index = math.min(#self.items, self.index + self.rows)
|
moved = navPressed(self, "right")
|
||||||
|
self.holdDir, self.holdFrames = "right", 0
|
||||||
elseif self.onSelectKey and input:wasPressed("select") then
|
elseif self.onSelectKey and input:wasPressed("select") then
|
||||||
self.onSelectKey(self.items[self.index], self)
|
self.onSelectKey(self.items[self.index], self)
|
||||||
elseif input:wasPressed("b") then
|
elseif input:wasPressed("b") then
|
||||||
@@ -80,10 +158,22 @@ function ListMenu:update(dt)
|
|||||||
end
|
end
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
if self.index - self.scroll > self.rows then
|
|
||||||
self.scroll = self.index - self.rows
|
-- hold-to-scroll (opt-in via ui.list_menu keyRepeat)
|
||||||
|
if self.keyRepeat then
|
||||||
|
local dir = self.holdDir
|
||||||
|
if dir and input:isDown(dir) then
|
||||||
|
self.holdFrames = self.holdFrames + 1
|
||||||
|
local afterDelay = self.holdFrames - self.repeatDelay
|
||||||
|
if afterDelay >= 0 and afterDelay % self.repeatRate == 0 then
|
||||||
|
navPressed(self, dir)
|
||||||
|
end
|
||||||
|
else
|
||||||
|
self.holdDir, self.holdFrames = nil, 0
|
||||||
|
end
|
||||||
end
|
end
|
||||||
if self.index - self.scroll < 1 then self.scroll = self.index - 1 end
|
|
||||||
|
if not moved then syncScroll(self) end
|
||||||
end
|
end
|
||||||
|
|
||||||
-- remove current item (e.g. consumed); keeps cursor valid
|
-- remove current item (e.g. consumed); keeps cursor valid
|
||||||
@@ -103,7 +193,7 @@ function ListMenu:draw()
|
|||||||
love.graphics.setColor(0, 0, 0, 1)
|
love.graphics.setColor(0, 0, 0, 1)
|
||||||
Font.draw(self.title, 8, 4)
|
Font.draw(self.title, 8, 4)
|
||||||
if #self.items == 0 then
|
if #self.items == 0 then
|
||||||
Font.draw("Nothing here.", 16, 64)
|
Font.draw(Strings("Nothing here."), 16, 64)
|
||||||
end
|
end
|
||||||
for row = 1, self.rows do
|
for row = 1, self.rows do
|
||||||
local i = self.scroll + row
|
local i = self.scroll + row
|
||||||
@@ -112,7 +202,10 @@ function ListMenu:draw()
|
|||||||
local y = 8 + row * 16
|
local y = 8 + row * 16
|
||||||
Font.draw(item.label, 16, y)
|
Font.draw(item.label, 16, y)
|
||||||
if item.ball then -- the Pokédex owned-ball marker tile
|
if item.ball then -- the Pokédex owned-ball marker tile
|
||||||
local bx = 16 + (#item.label + 1) * 8 + 3
|
-- one blank glyph after the name, measured in glyph advances rather
|
||||||
|
-- than bytes: NIDORAN♂/♀ carry a multi-byte charmap entry, so
|
||||||
|
-- `#item.label` overcounted by 2 and pushed their ball 16px right (#285)
|
||||||
|
local bx = 16 + Font.width(item.label) + 8 + 3
|
||||||
local by = y + 3
|
local by = y + 3
|
||||||
love.graphics.circle("fill", bx, by, 3.5)
|
love.graphics.circle("fill", bx, by, 3.5)
|
||||||
love.graphics.setColor(1, 1, 1, 1)
|
love.graphics.setColor(1, 1, 1, 1)
|
||||||
|
|||||||