mirror of
https://github.com/bryanthaboi/gen1recomp.git
synced 2026-08-12 16:31:05 +02:00
Compare commits
16 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 3b2e489325 | |||
| 10acfe0710 | |||
| 5113cfd951 | |||
| 43158d724b | |||
| f0a88ea473 | |||
| 31365d8dfd | |||
| 531a719794 | |||
| ff90062741 | |||
| 803f86d5b3 | |||
| 3448fae3f5 | |||
| 25df838b45 | |||
| be90e5b42c | |||
| 08e8dfc416 | |||
| 6a27be54de | |||
| d4055209e6 | |||
| 043f6b2426 |
@@ -25,7 +25,9 @@ body:
|
||||
options:
|
||||
- macOS
|
||||
- Windows
|
||||
- Both
|
||||
- Linux
|
||||
- Android
|
||||
- Multiple platforms
|
||||
validations:
|
||||
required: true
|
||||
|
||||
@@ -38,6 +40,17 @@ body:
|
||||
validations:
|
||||
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
|
||||
id: location
|
||||
attributes:
|
||||
|
||||
@@ -1,35 +1,53 @@
|
||||
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"]
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
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,
|
||||
and how you picture it working. A request with real detail is one someone can
|
||||
actually build without guessing at what you meant.
|
||||
and how you picture it working.
|
||||
|
||||
- type: input
|
||||
id: summary
|
||||
attributes:
|
||||
label: One line summary
|
||||
description: What you want, in a sentence.
|
||||
placeholder: Add a run toggle so you can move at bike speed without the bike
|
||||
placeholder: Add Linux AppImage releases next to the macOS and Windows builds
|
||||
validations:
|
||||
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
|
||||
id: what
|
||||
attributes:
|
||||
label: What do you want
|
||||
description: >
|
||||
Describe it properly. What is it, where does it live in the game, what does the
|
||||
player see or do. If it changes something that already exists, say what it does today
|
||||
and what it should do instead.
|
||||
Describe it properly. What is it, where does it live (launcher, options,
|
||||
engine), what does the player see or do. If it changes something that already
|
||||
exists, say what it does today and what it should do instead.
|
||||
placeholder: |
|
||||
A hold-to-run button, the way later Pokemon games do it. Hold B while moving on
|
||||
foot and the player moves at bike speed. Release and you go back to walking.
|
||||
Doesn't work indoors where the bike doesn't work, doesn't work in battles or menus.
|
||||
Ship a Linux AppImage on each release, same version as the macOS/Windows builds,
|
||||
with the same save folder layout and mod discovery path.
|
||||
validations:
|
||||
required: true
|
||||
|
||||
@@ -41,9 +59,8 @@ body:
|
||||
What's annoying or missing right now. What does this fix. If it's just because you
|
||||
think it would be fun, say that, it's a real answer.
|
||||
placeholder: |
|
||||
Backtracking across routes you've already cleared is slow, and the bike is a menu
|
||||
dive away and doesn't work in a lot of places. Running would cut a lot of dead time
|
||||
without making the game easier.
|
||||
LÖVE already runs on Linux; without a packaged build, players have to assemble
|
||||
it themselves and miss release notes / update checks.
|
||||
validations:
|
||||
required: true
|
||||
|
||||
@@ -52,14 +69,12 @@ body:
|
||||
attributes:
|
||||
label: How should it work
|
||||
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.
|
||||
placeholder: |
|
||||
- Hold B on the overworld to run
|
||||
- Same speed as the bike
|
||||
- Disabled anywhere the bike is disabled
|
||||
- Sprite should probably need a running animation, not sure what that takes
|
||||
- No effect on encounter rates
|
||||
- GitHub Releases asset next to the .dmg / .exe
|
||||
- Same options.lua / mods/ layout as desktop
|
||||
- Documented in the README install section
|
||||
validations:
|
||||
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:
|
||||
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:
|
||||
|
||||
# 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:
|
||||
# * run this workflow manually (Actions tab) and type it into "version", or
|
||||
# * 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:
|
||||
push:
|
||||
|
||||
@@ -1,13 +1,21 @@
|
||||
-- pokered/scripts/PewterNidoranHouse.asm
|
||||
-- PewterNidoranHouseNidoranText: text_far _PewterNidoranHouseNidoranText, then
|
||||
-- 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
|
||||
-- only; the cry SFX is cosmetic and has no gameplay effect.
|
||||
-- play_cry only stashes ctx.pendingCry for the very next show_text row, so it
|
||||
-- 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 {
|
||||
PEWTER_NIDORAN_HOUSE = {
|
||||
talk = {
|
||||
TEXT_PEWTERNIDORANHOUSE_NIDORAN = {
|
||||
{ "face_player" },
|
||||
-- text_asm: ld a, NIDORAN_M / call PlayCry
|
||||
{ "play_cry", "NIDORAN_M", true },
|
||||
{ "show_text", "_PewterNidoranHouseNidoranText" },
|
||||
},
|
||||
},
|
||||
|
||||
@@ -59,13 +59,17 @@ M.VIRIDIAN_CITY = {
|
||||
-- ViridianCityYoungster2CaterpieAndWeedleDescriptionText are
|
||||
-- defined without a leading underscore in pokered/text/ViridianCity.asm
|
||||
-- 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)
|
||||
local t = text(game)
|
||||
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
|
||||
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
|
||||
push(game, "Oh, OK then!", done)
|
||||
end
|
||||
|
||||
@@ -3,12 +3,16 @@
|
||||
return {
|
||||
VIRIDIAN_NICKNAME_HOUSE = {
|
||||
talk = {
|
||||
-- ViridianNicknameHouseSpearowText: text_asm that shows the text
|
||||
-- then plays a SPEAROW cry (PlayCry/WaitForSoundToFinish). The
|
||||
-- cry playback isn't in the talk-script command vocabulary, so
|
||||
-- only the flavor line is ported here.
|
||||
-- ViridianNicknameHouseSpearowText: text_asm that PrintTexts the
|
||||
-- line, then plays the SPEAROW cry (PlayCry/WaitForSoundToFinish)
|
||||
-- before TextScriptEnd. play_cry only arms the show_text row that
|
||||
-- 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 = {
|
||||
{ "show_text", "_ViridianNicknameHouseSpearowText" },
|
||||
{ "play_cry", "SPEAROW", true }, -- 1 PlayCry
|
||||
{ "show_text", "_ViridianNicknameHouseSpearowText" }, -- 2 PrintText
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -16,6 +16,14 @@ M.VIRIDIAN_MART = {
|
||||
-- StartSimulatingJoypadStates walks the player to the counter
|
||||
-- (.PlayerMovement: PAD_LEFT 1, PAD_UP 2, door (3,7) -> counter (2,5))
|
||||
-- and ViridianMartOaksParcelScript hands the parcel over. The player
|
||||
-- never presses A.
|
||||
--
|
||||
-- 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
|
||||
-- Oak's Pokedex and the old man clearing Route 2, so vanilla guarantees
|
||||
-- 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
|
||||
ow:queueScript({
|
||||
{ "show_text", "_ViridianMartClerkYouCameFromPalletTownText" },
|
||||
{ "move_player", "left", 1 },
|
||||
{ "move_player", "up", 2 },
|
||||
{ "move_player", "left", 1 },
|
||||
-- the quest text's last page is "{PLAYER} got\nOAK's PARCEL!"
|
||||
{ "give_item", "OAKS_PARCEL", 1, "_ViridianMartClerkParcelQuestText" },
|
||||
{ "set_flag", "EVENT_GOT_OAKS_PARCEL" },
|
||||
|
||||
+47
-15
@@ -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
|
||||
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
|
||||
picker (`GameActivity.showRomFilePicker`); the chosen file is copied to
|
||||
`picked_rom.gb` in the app save directory. `RomImporter` then imports on
|
||||
refocus / Choose via `findPendingRom`: only a 1 MiB `.gb` whose SHA-1 maps
|
||||
to a version that is **not** yet ready counts as pending. A leftover
|
||||
`picked_rom.gb` from Red therefore cannot block Blue's Choose (issue #167).
|
||||
After a successful import the consumed save-dir `.gb` is removed.
|
||||
On Android, `love.system.pickFile([kind])` opens the Storage Access Framework
|
||||
picker (`GameActivity.showFilePicker`); the chosen file is copied into the app
|
||||
save directory as:
|
||||
|
||||
| `kind` | Destination |
|
||||
| --- | --- |
|
||||
| nil / `"rom"` | `picked_rom.gb` (open) |
|
||||
| `"mod"` | `picked_mod.zip` (open) |
|
||||
| `"sav"` / `"save"` | `picked_save.sav` (open) |
|
||||
|
||||
Export uses a separate API: `love.system.createFile(suggestedName)` →
|
||||
`GameActivity.showCreateDocument` (`ACTION_CREATE_DOCUMENT`), which copies
|
||||
staged `pending_export.sav` to the user-chosen URI and writes `export_done.flag`
|
||||
for the launcher to acknowledge on refocus.
|
||||
|
||||
`RomImporter` then imports on refocus / Choose:
|
||||
|
||||
- **ROMs** via `findPendingRom`: only a 1 MiB `.gb` whose SHA-1 maps to a
|
||||
version that is **not** yet ready counts as pending. A leftover
|
||||
`picked_rom.gb` from Red therefore cannot block Blue's Choose (issue #167).
|
||||
- **Mods** via `findPendingMod`: Prefer `picked_mod.zip`, or (on Choose) any
|
||||
other `.zip` at the save-dir root (USB copy).
|
||||
- **Saves** via `findPendingSav`: Prefer `picked_save.sav`, or (on Choose) any
|
||||
other `.sav` at the save-dir root.
|
||||
|
||||
After a successful import the consumed save-dir file is removed.
|
||||
|
||||
**Manual check (device/emulator):** import Red → switch to Blue → Choose →
|
||||
system file picker must appear (not a silent Red re-extract) → pick Blue →
|
||||
Blue becomes ready beside Red.
|
||||
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
|
||||
|
||||
@@ -78,6 +98,10 @@ The launcher-facing API:
|
||||
- `SaveData.createSlot(version)` -> new slot id, registered but with **no
|
||||
save file written**. An empty slot means the title screen offers NEW GAME
|
||||
only, which needs no further changes.
|
||||
- `SaveData.deleteSlot(version, slotId)` removes the slot's
|
||||
main/`.bak`/`.tmp` files, drops it from the registry, and if it was active
|
||||
points active at another remaining slot (or clears active when the list is
|
||||
empty). The launcher's SAVE SLOT panel Delete control calls this.
|
||||
|
||||
## Launcher mod manager
|
||||
|
||||
@@ -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
|
||||
`RomImporter` handles a dropped ROM. A failed copy rolls its partial tree
|
||||
back, and every path unmounts and clears the staged temp file.
|
||||
- `LauncherMods.uninstall(id)` removes `mods/<id>/` and clears
|
||||
`options.mods[id]` so a later reinstall starts from the loader's default
|
||||
(enabled). The mods panel Delete control calls this and re-derives the list.
|
||||
|
||||
## Import / Export save
|
||||
|
||||
@@ -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`.
|
||||
|
||||
- **Import save** is live once the game's ROM is imported (playable). It opens
|
||||
a native `.sav` picker (`chooseSav`, the per-OS dialogs mirror `chooseZip`;
|
||||
Android has no picker and shows a drop hint). `SaveFileIO.importToSlot`
|
||||
reads the bytes (an absolute path, a dropped LOVE file, or raw bytes),
|
||||
a native `.sav` picker (`chooseSav` on desktop; on Android,
|
||||
`love.system.pickFile("sav")` → `picked_save.sav`, same SAF path as ROMs).
|
||||
`SaveFileIO.importToSlot` reads the bytes (an absolute path, a save-dir
|
||||
relative name, a dropped LOVE file, or raw bytes),
|
||||
guards the 32768-byte size, runs `SaveConvert.importSav` (which also rejects
|
||||
a bad main-data checksum), then registers a fresh slot (`SaveData.createSlot`),
|
||||
writes it (`SaveData.writeSlot`), and makes it active (`SaveData.setActiveSlot`).
|
||||
@@ -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
|
||||
`rawImport`, so this is a zero-filled template export, which is valid), and
|
||||
writes `exports/gen1recomp-<version>-<slotId>.sav` in the save directory
|
||||
(`love.filesystem.createDirectory("exports")`). It returns the absolute path
|
||||
(`love.filesystem.getSaveDirectory()`), which the notice line shows with a
|
||||
desktop "Open folder" affordance (`love.system.openURL("file://" .. dir)`).
|
||||
(`love.filesystem.createDirectory("exports")`). On desktop it returns the
|
||||
absolute path (`love.filesystem.getSaveDirectory()`), which the notice line
|
||||
shows with an "Open folder" affordance (`love.system.openURL("file://" .. dir)`).
|
||||
On Android the bytes are also staged as `pending_export.sav` and
|
||||
`love.system.createFile(suggestedName)` opens `ACTION_CREATE_DOCUMENT` so the
|
||||
player can save to Downloads / Drive / etc.; on return `export_done.flag`
|
||||
makes focus show "Save exported."
|
||||
- **Drag-drop.** `filedropped` routes a `.sav` to the import path for the
|
||||
currently active game tab; when a non-game tab (mods, or the locked yellow
|
||||
placeholder) is showing it defaults to red, the always-present first game
|
||||
|
||||
+35
-1
@@ -193,4 +193,38 @@ 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).
|
||||
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.
|
||||
|
||||
+5
-5
@@ -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
|
||||
`tools/rom_manifest.json`.
|
||||
|
||||
ROM import on Android uses `love.system.pickFile()` →
|
||||
`GameActivity.showRomFilePicker` (Storage Access Framework), which copies the
|
||||
chosen file to `picked_rom.gb` under the app save directory. `RomImporter`
|
||||
imports pending (not-yet-ready) `.gb` files from that folder on Choose /
|
||||
refocus; see `docs/launcher.md` (Android multi-ROM import). The APK payload
|
||||
ROM / mod / save import on Android uses `love.system.pickFile([kind])` →
|
||||
`GameActivity.showFilePicker` (Storage Access Framework), which copies the
|
||||
chosen file under the app save directory as `picked_rom.gb`,
|
||||
`picked_mod.zip`, or `picked_save.sav`. `RomImporter` imports pending files
|
||||
from that folder on Choose / refocus; see `docs/launcher.md`. The APK payload
|
||||
itself remains data-free (no embedded ROM or generated cache).
|
||||
|
||||
### SDK / NDK
|
||||
|
||||
@@ -18,4 +18,4 @@ android.useAndroidX=true
|
||||
android.defaults.buildfeatures.buildconfig=true
|
||||
android.nonTransitiveRClass=true
|
||||
android.nonFinalResIds=true
|
||||
app.name=Pokemon Red
|
||||
app.name=gen1recomp
|
||||
|
||||
@@ -183,13 +183,37 @@ void vibrate(double seconds)
|
||||
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();
|
||||
jclass activity = env->FindClass("org/love2d/android/GameActivity");
|
||||
|
||||
jmethodID method = env->GetStaticMethodID(activity, "showRomFilePicker", "()Z");
|
||||
jboolean result = env->CallStaticBooleanMethod(activity, method);
|
||||
jmethodID method = env->GetStaticMethodID(activity, "showFilePicker",
|
||||
"(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);
|
||||
return result;
|
||||
|
||||
@@ -63,9 +63,16 @@ void vibrate(double seconds);
|
||||
* Shows the system's "pick a document" UI (Storage Access Framework).
|
||||
* Returns true if the picker was launched; the picked file (if any) is
|
||||
* copied asynchronously by GameActivity.onActivityResult into the app's
|
||||
* external save directory, not returned here -- see src/import/RomImporter.lua.
|
||||
* 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
|
||||
|
||||
@@ -22,6 +22,8 @@
|
||||
#include "common/config.h"
|
||||
#include "System.h"
|
||||
|
||||
#include <cstring>
|
||||
|
||||
#if defined(LOVE_MACOSX)
|
||||
#include <CoreServices/CoreServices.h>
|
||||
#elif defined(LOVE_IOS)
|
||||
@@ -180,11 +182,32 @@ void System::vibrate(double seconds) const
|
||||
#endif
|
||||
}
|
||||
|
||||
bool System::pickFile() const
|
||||
bool System::pickFile(const char *kind) const
|
||||
{
|
||||
#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
|
||||
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;
|
||||
#endif
|
||||
}
|
||||
|
||||
@@ -111,9 +111,21 @@ public:
|
||||
* Android only for now; the result (if any) is not returned here -- see
|
||||
* 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.
|
||||
**/
|
||||
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.
|
||||
|
||||
@@ -97,7 +97,15 @@ int w_vibrate(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;
|
||||
}
|
||||
|
||||
@@ -117,6 +125,7 @@ static const luaL_Reg functions[] =
|
||||
{ "openURL", w_openURL },
|
||||
{ "vibrate", w_vibrate },
|
||||
{ "pickFile", w_pickFile },
|
||||
{ "createFile", w_createFile },
|
||||
{ "hasBackgroundMusic", w_hasBackgroundMusic },
|
||||
{ 0, 0 }
|
||||
};
|
||||
|
||||
@@ -22,12 +22,15 @@ package org.love2d.android;
|
||||
|
||||
import org.libsdl.app.SDLActivity;
|
||||
|
||||
import java.io.BufferedInputStream;
|
||||
import java.io.BufferedOutputStream;
|
||||
import java.io.File;
|
||||
import java.io.FileInputStream;
|
||||
import java.io.FileNotFoundException;
|
||||
import java.io.FileOutputStream;
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.io.OutputStream;
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
@@ -61,13 +64,24 @@ public class GameActivity extends SDLActivity {
|
||||
protected final int[] recordAudioRequestDummy = new int[1];
|
||||
public static final int EXTERNAL_STORAGE_REQUEST_CODE = 2;
|
||||
public static final int RECORD_AUDIO_REQUEST_CODE = 3;
|
||||
public static final int ROM_PICKER_REQUEST_CODE = 4;
|
||||
// Mirrors conf.lua's t.identity ("pokemon-love2d"): where the picked ROM
|
||||
public static final int FILE_PICKER_REQUEST_CODE = 4;
|
||||
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
|
||||
// src/import/RomImporter.lua and Filesystem::setIdentity (sets Android's
|
||||
// save directory to getExternalFilesDir()/save/<identity>).
|
||||
private static final String ROM_SAVE_IDENTITY = "pokemon-love2d";
|
||||
private static final String PICKED_ROM_FILENAME = "picked_rom.gb";
|
||||
private static 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 needToCopyGameInArchive = false;
|
||||
private boolean storagePermissionUnnecessary = false;
|
||||
@@ -341,59 +355,185 @@ public class GameActivity extends SDLActivity {
|
||||
|
||||
/**
|
||||
* Shows the system document picker (Storage Access Framework) so the
|
||||
* player can pick their ROM from anywhere (Downloads, Drive, etc.)
|
||||
* without needing to know where the app's external files folder is.
|
||||
* Requires API 19+ (ACTION_OPEN_DOCUMENT); the picked file (if any)
|
||||
* player can pick a ROM / mod / save from anywhere (Downloads, Drive,
|
||||
* etc.) without needing to know where the app's external files folder
|
||||
* is. Requires API 19+ (ACTION_OPEN_DOCUMENT); the picked file (if any)
|
||||
* arrives later in onActivityResult, not synchronously here.
|
||||
*
|
||||
* @param destFilename basename under the app save identity (e.g.
|
||||
* picked_rom.gb, picked_mod.zip, picked_save.sav)
|
||||
*/
|
||||
@Keep
|
||||
public static boolean showRomFilePicker() {
|
||||
public static boolean showFilePicker(String destFilename) {
|
||||
if (android.os.Build.VERSION.SDK_INT < 19) return false;
|
||||
GameActivity self = (GameActivity) mSingleton;
|
||||
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.addCategory(Intent.CATEGORY_OPENABLE);
|
||||
intent.setType("*/*");
|
||||
try {
|
||||
self.startActivityForResult(intent, ROM_PICKER_REQUEST_CODE);
|
||||
self.startActivityForResult(intent, FILE_PICKER_REQUEST_CODE);
|
||||
return true;
|
||||
} 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;
|
||||
}
|
||||
}
|
||||
|
||||
/** 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
|
||||
protected void onActivityResult(int requestCode, int resultCode, Intent 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) {
|
||||
Log.d("GameActivity", "ROM picker returned no file (cancelled?)");
|
||||
Log.d("GameActivity", "file picker returned no file (cancelled?)");
|
||||
return;
|
||||
}
|
||||
|
||||
Uri uri = data.getData();
|
||||
File destDir = new File(new File(getExternalFilesDir(null), "save"), ROM_SAVE_IDENTITY);
|
||||
File destDir = saveIdentityDir();
|
||||
if (!destDir.exists() && !destDir.mkdirs()) {
|
||||
Log.d("GameActivity", "could not create " + destDir);
|
||||
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;
|
||||
try {
|
||||
source = getContentResolver().openInputStream(uri);
|
||||
} catch (FileNotFoundException e) {
|
||||
Log.d("GameActivity", "could not open picked ROM: " + e.getMessage());
|
||||
Log.d("GameActivity", "could not open picked file: " + e.getMessage());
|
||||
return;
|
||||
}
|
||||
if (source == null) {
|
||||
Log.d("GameActivity", "ContentResolver returned no stream for picked ROM");
|
||||
Log.d("GameActivity", "ContentResolver returned no stream for picked file");
|
||||
return;
|
||||
}
|
||||
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
|
||||
|
||||
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,
|
||||
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 |
|
||||
| 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 |
|
||||
| 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
|
||||
|
||||
@@ -46,7 +47,8 @@ and `field`, and exercises:
|
||||
- both buses — `events:on` / `events:emit` and `hooks:wrap` — across
|
||||
`music.select`, `battle.damage`, `ui.start_menu.items`, `ui.options.rows`,
|
||||
`battle.started`, `battle.turn_started`, `battle.ended`, `flag.changed`,
|
||||
`game.ready` and `assets.transformed`
|
||||
`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:read`, `mod.log` and `mod.path`
|
||||
- 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
|
||||
```
|
||||
|
||||
`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.
|
||||
|
||||
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` |
|
||||
Binary file not shown.
|
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")
|
||||
+177
-127
@@ -26,6 +26,7 @@ local Status = require("src.battle.Status")
|
||||
local TrainerAI = require("src.battle.TrainerAI")
|
||||
local TurnOrder = require("src.battle.TurnOrder")
|
||||
local TypeChart = require("src.battle.TypeChart")
|
||||
local Strings = require("src.core.Strings")
|
||||
|
||||
local BattleState = {}
|
||||
BattleState.__index = BattleState
|
||||
@@ -208,13 +209,17 @@ local TRAINER_DVS = { attack = 9, defense = 8, speed = 8, special = 8, hp = 8 }
|
||||
|
||||
-- charge-turn texts by move id; the move record's chargeText field wins
|
||||
-- (ChargeEffect's per-move text pointers)
|
||||
-- Strings.source, not Strings: this table is built at require time, before
|
||||
-- Strings.load has a catalog, so translating here would freeze the English.
|
||||
-- The marker is a no-op that puts these lines in the catalog anyway; the
|
||||
-- lookup happens where chargeText is formatted below.
|
||||
local CHARGE_TEXT = {
|
||||
FLY = "%s\nflew up high!",
|
||||
DIG = "%s\ndug a hole!",
|
||||
RAZOR_WIND = "%s\nmade a whirlwind!",
|
||||
SOLARBEAM = "%s\ntook in sunlight!",
|
||||
SKULL_BASH = "%s\nlowered its head!",
|
||||
SKY_ATTACK = "%s\nis glowing!",
|
||||
FLY = Strings.source("%s\nflew up high!"),
|
||||
DIG = Strings.source("%s\ndug a hole!"),
|
||||
RAZOR_WIND = Strings.source("%s\nmade a whirlwind!"),
|
||||
SOLARBEAM = Strings.source("%s\ntook in sunlight!"),
|
||||
SKULL_BASH = Strings.source("%s\nlowered its head!"),
|
||||
SKY_ATTACK = Strings.source("%s\nis glowing!"),
|
||||
}
|
||||
|
||||
-- pokered's <USER>/<TARGET> text macros (home/text.asm
|
||||
@@ -299,8 +304,13 @@ local function makeBattler(data, mon, isPlayer, save)
|
||||
curStats = mon.stats,
|
||||
curTypes = def.types,
|
||||
curMoves = mon.moves,
|
||||
sprite = getImage(isPlayer and def.spriteBack or def.spriteFront,
|
||||
monPalette(data, mon.species), def.trueColor),
|
||||
sprite = (function()
|
||||
local Sprites = require("src.pokemon.Sprites")
|
||||
local path, tc = Sprites.path(data, mon.species,
|
||||
isPlayer and "back" or "front",
|
||||
{ mon = mon, kind = "battle" })
|
||||
return getImage(path, monPalette(data, mon.species), tc)
|
||||
end)(),
|
||||
}
|
||||
end
|
||||
|
||||
@@ -318,13 +328,16 @@ BattleState.makeBattler = makeBattler
|
||||
function BattleState:speciesSprite(species, isPlayerSide)
|
||||
local def = self.data.pokemon[species]
|
||||
if not def then return nil end
|
||||
local Sprites = require("src.pokemon.Sprites")
|
||||
local path, tc = Sprites.path(self.data, species,
|
||||
isPlayerSide and "back" or "front", { kind = "battle" })
|
||||
local PaletteFX = require("src.render.PaletteFX")
|
||||
local colors = PaletteFX.monPal(self.data, species, true)
|
||||
local name = "GRAYMON"
|
||||
if PaletteFX.usesGbcPack() then name = "redpp:GRAYMON" end
|
||||
return getImage(isPlayerSide and def.spriteBack or def.spriteFront,
|
||||
return getImage(path,
|
||||
colors and { name = name, colors = colors } or nil,
|
||||
def.trueColor)
|
||||
tc)
|
||||
end
|
||||
|
||||
local function markSeen(game, species)
|
||||
@@ -408,9 +421,9 @@ function BattleState.newWild(game, species, level, opts)
|
||||
self.enemy = makeBattler(game.data, Pokemon.new(game.data, species, level), false)
|
||||
markSeen(game, species)
|
||||
if opts and opts.hooked then
|
||||
self.introText = ("The hooked\n%s\nattacked!"):format(self.enemy.name)
|
||||
self.introText = Strings("The hooked\n%s\nattacked!", self.enemy.name)
|
||||
else
|
||||
self.introText = ("Wild %s\nappeared!"):format(self.enemy.name)
|
||||
self.introText = Strings("Wild %s\nappeared!", self.enemy.name)
|
||||
end
|
||||
return self
|
||||
end
|
||||
@@ -531,7 +544,7 @@ function BattleState.newTrainer(game, oppClass, partyIndex)
|
||||
-- wEnemyMonSpecies2 before the intro's SET_PAL_BATTLE
|
||||
-- (engine/battle/core.asm:6682, engine/gfx/palettes.asm SetPal_Battle)
|
||||
self.trainerPic = getImage(self.trainer.pic, namedPalette(game.data, "MEWMON"))
|
||||
self.introText = ("%s wants\nto fight!"):format(self.trainer.name)
|
||||
self.introText = Strings("%s wants\nto fight!", self.trainer.name)
|
||||
return self
|
||||
end
|
||||
|
||||
@@ -545,7 +558,7 @@ function BattleState:makeGhost()
|
||||
-- (engine/battle/core.asm InitWildBattle .isGhost)
|
||||
self.enemy.sprite = getImage("assets/generated/battle/front/ghost.png",
|
||||
monPalette(self.data, self.enemy.mon.species))
|
||||
self.introText = "The GHOST\nappeared!"
|
||||
self.introText = Strings("The GHOST\nappeared!")
|
||||
end
|
||||
|
||||
-- The old man's catch tutorial (BATTLE_TYPE_OLD_MAN,
|
||||
@@ -939,10 +952,10 @@ function BattleState:sendOutText(name)
|
||||
if e and e.hp > 0 and math.floor(e.stats.hp / 4) > 0 then
|
||||
pct = math.floor(e.hp * 25 / math.floor(e.stats.hp / 4))
|
||||
end
|
||||
if pct >= 70 then return ("Go! %s!"):format(name) end
|
||||
if pct >= 40 then return ("Do it! %s!"):format(name) end
|
||||
if pct >= 10 then return ("Get'm! %s!"):format(name) end
|
||||
return ("The enemy's weak!\nGet'm! %s!"):format(name)
|
||||
if pct >= 70 then return Strings("Go! %s!", name) end
|
||||
if pct >= 40 then return Strings("Do it! %s!", name) end
|
||||
if pct >= 10 then return Strings("Get'm! %s!", name) end
|
||||
return Strings("The enemy's weak!\nGet'm! %s!", name)
|
||||
end
|
||||
|
||||
-- audio/play_battle_music.asm: gym leaders (wGymLeaderNo) get the
|
||||
@@ -1012,37 +1025,54 @@ function BattleState:enter()
|
||||
-- MonsterPalettes[0] = PAL_MEWMON (wBattleMonSpecies is still 0 when
|
||||
-- the intro's SET_PAL_BATTLE runs -- SetPal_Battle,
|
||||
-- engine/gfx/palettes.asm:28)
|
||||
self.playerBackPic = getImage(self.demo
|
||||
and "assets/generated/battle/oldmanb.png"
|
||||
or "assets/generated/battle/redb.png",
|
||||
namedPalette(self.data, "MEWMON"))
|
||||
-- field.playerPics picks the pic (the catch tutorial's old man fights in
|
||||
-- the player's place), then the player.sprite hook gets the last word so
|
||||
-- a mod can vary it per save. It loads through getImage like every other
|
||||
-- battle pic, so a replacement keeps the SGB recolor, the transition
|
||||
-- fade, the ground-padding measurement and battle_sprite_scales.
|
||||
local backPath, backTrueColor =
|
||||
require("src.pokemon.Sprites").playerPath(self.data, "back",
|
||||
{ kind = "battle", demo = self.demo, battle = self })
|
||||
self.playerBackPic = getImage(backPath,
|
||||
namedPalette(self.data, "MEWMON"), backTrueColor)
|
||||
self.showPlayerBack = self.playerBackPic ~= nil
|
||||
-- the enemy's cry as it appears (data/pokemon/cries.asm); PlayCry sits at
|
||||
-- a different point in each battle kind, so queue it per branch
|
||||
local function queueEnemyCry()
|
||||
self:act(function()
|
||||
require("src.core.Sound").playCry(self.data, self.enemy.mon.species)
|
||||
end)
|
||||
end
|
||||
-- PrintBeginningBattleText (engine/battle/common_text.asm:10-19): a wild
|
||||
-- battle calls PlayCry BEFORE PrintText WildMonAppearedText, so the cry
|
||||
-- sounds with the "Wild X appeared!" box instead of waiting on the A
|
||||
-- press that clears its `prompt` (#303). The Silph-Scope-less tower
|
||||
-- ghost gets no cry at all (common_text.asm:43-48).
|
||||
if self.kind ~= "trainer" and self.kind ~= "link" and not self.ghost then
|
||||
queueEnemyCry()
|
||||
end
|
||||
self:say(self.introText)
|
||||
if self.kind == "trainer" then
|
||||
self:say(("%s sent\nout %s!"):format(self.trainer.name, self.enemy.name))
|
||||
self:say(Strings("%s sent\nout %s!", self.trainer.name, self.enemy.name))
|
||||
self:act(function()
|
||||
-- EnemySendOutFirstMon (core.asm:1421-1434): after the text the
|
||||
-- pic grows out of the ball (AnimateSendingOutMon), then the cry
|
||||
self.showEnemyTrainer = false
|
||||
self:startGrowIn(self.enemy)
|
||||
end)
|
||||
queueEnemyCry()
|
||||
elseif self.kind == "link" then
|
||||
-- Colosseum has no foe trainer pic, but the enemy mon still grows
|
||||
-- out of the ball after "X sent out Y!" (not the wild "already there"
|
||||
-- intro that LinkBattle previously inherited from newWild).
|
||||
self.enemySendingOut = true
|
||||
self:say(("%s sent\nout %s!"):format(self.opponentName or "FOE",
|
||||
self:say(Strings("%s sent\nout %s!", self.opponentName or "FOE",
|
||||
self.enemy.name))
|
||||
self:act(function()
|
||||
self.enemySendingOut = false
|
||||
self:startGrowIn(self.enemy)
|
||||
end)
|
||||
end
|
||||
if not self.ghost then
|
||||
-- the enemy's cry plays as it appears (data/pokemon/cries.asm)
|
||||
self:act(function()
|
||||
require("src.core.Sound").playCry(self.data, self.enemy.mon.species)
|
||||
end)
|
||||
queueEnemyCry()
|
||||
end
|
||||
if not self.safari and not self.demo then
|
||||
self:say(self:sendOutText(self.player.name))
|
||||
@@ -1210,7 +1240,7 @@ function BattleState:update(dt)
|
||||
|
||||
if self.phase == "menu" and self.safari then
|
||||
if self.safari.balls <= 0 then
|
||||
self:say("PA: You're out of\nSAFARI BALLs!\nGame over!")
|
||||
self:say(Strings("PA: You're out of\nSAFARI BALLs!\nGame over!"))
|
||||
self.phase = "messages"
|
||||
self.result = "run"
|
||||
self.afterQueue = "finish"
|
||||
@@ -1265,7 +1295,7 @@ function BattleState:update(dt)
|
||||
if input:wasPressed("a") then
|
||||
local choice = ({ "fight", "pkmn", "item", "run" })[self.menuIndex]
|
||||
if choice == "fight" and self.ghost then
|
||||
self:say(("%s is too\nscared to move!"):format(self.player.name))
|
||||
self:say(Strings("%s is too\nscared to move!", self.player.name))
|
||||
self.phase = "messages"
|
||||
self.afterQueue = "menu"
|
||||
self:act(function()
|
||||
@@ -1282,7 +1312,7 @@ function BattleState:update(dt)
|
||||
end
|
||||
if not self:playerHasPP() then
|
||||
-- _NoMovesLeftText, then Struggle engages
|
||||
self:say(("%s has no\nmoves left!"):format(self.player.name))
|
||||
self:say(Strings("%s has no\nmoves left!", self.player.name))
|
||||
self:resolveTurn({ id = "STRUGGLE", pp = 1, struggle = true })
|
||||
return
|
||||
end
|
||||
@@ -1324,11 +1354,11 @@ function BattleState:update(dt)
|
||||
end
|
||||
local mv = moves[self.moveIndex]
|
||||
if self.player.disabledSlot == self.moveIndex then
|
||||
self:say("The move is\ndisabled!")
|
||||
self:say(Strings("The move is\ndisabled!"))
|
||||
self.phase = "messages"
|
||||
self.afterQueue = "menu"
|
||||
elseif mv.pp <= 0 then
|
||||
self:say("No PP left for\nthis move!")
|
||||
self:say(Strings("No PP left for\nthis move!"))
|
||||
self.phase = "messages"
|
||||
self.afterQueue = "menu"
|
||||
else
|
||||
@@ -1372,7 +1402,7 @@ function BattleState:resolveMimic(user, target, move, moveInst)
|
||||
table.insert(self.queue, self.nextInsert, { wait = 50 })
|
||||
if target.invulnerable
|
||||
or not self:accuracyRoll(move, user, target) then
|
||||
self:sayNext("But, it failed!")
|
||||
self:sayNext(Strings("But, it failed!"))
|
||||
return
|
||||
end
|
||||
local slots = {}
|
||||
@@ -1382,7 +1412,7 @@ function BattleState:resolveMimic(user, target, move, moveInst)
|
||||
if #slots == 0 then
|
||||
-- .getRandomMove rerolls empty slots forever; a moveless target
|
||||
-- can't happen in practice, so just fail instead of hanging
|
||||
self:sayNext("But, it failed!")
|
||||
self:sayNext(Strings("But, it failed!"))
|
||||
return
|
||||
end
|
||||
if user.isPlayer and self.kind ~= "link" then
|
||||
@@ -1443,7 +1473,7 @@ function BattleState:applyMimic(user, target, moveInst, slot)
|
||||
entry.mimic = true
|
||||
self:animNext("MIMIC", user.isPlayer)
|
||||
-- _MimicLearnedMoveText: "<USER> / learned / MOVE!"
|
||||
self:sayNext(("%s\nlearned\n%s!"):format(displayName(user),
|
||||
self:sayNext(Strings("%s\nlearned\n%s!", displayName(user),
|
||||
self.data.moves[src.id].name))
|
||||
end
|
||||
|
||||
@@ -1483,7 +1513,7 @@ function BattleState:openOldManBag()
|
||||
self:ui(function()
|
||||
local list
|
||||
list = ListMenu.new(game, "ITEMS", {
|
||||
{ value = "POKE_BALL", label = "POKé BALL", right = "x50" },
|
||||
{ value = "POKE_BALL", label = Strings("POKé BALL"), right = "x50" },
|
||||
}, {
|
||||
script = function(l)
|
||||
l.scriptTimer = (l.scriptTimer or 0) + 1
|
||||
@@ -1513,7 +1543,7 @@ function BattleState:oldManThrow()
|
||||
self.phase = "messages"
|
||||
self.afterQueue = "finish"
|
||||
self.result = "run" -- nothing is kept; wBattleResult only ends the demo
|
||||
self:say("OLD MAN used\nPOKé BALL!")
|
||||
self:say(Strings("OLD MAN used\nPOKé BALL!"))
|
||||
self:act(function()
|
||||
require("src.core.Sound").play(self.data, "Ball_Toss")
|
||||
-- ItemUseBall's beat before the toss chain (like throwBall)
|
||||
@@ -1523,7 +1553,7 @@ function BattleState:oldManThrow()
|
||||
self:actNext(function()
|
||||
require("src.core.Sound").play(self.data, "Caught_Mon")
|
||||
end)
|
||||
self:sayNext(("All right!\n%s was\ncaught!"):format(self.enemy.name))
|
||||
self:sayNext(Strings("All right!\n%s was\ncaught!", self.enemy.name))
|
||||
end)
|
||||
end
|
||||
|
||||
@@ -2275,7 +2305,7 @@ function BattleState:executeAction(user, target, action)
|
||||
-- ghost battles: the ghost never attacks; its whole turn is the
|
||||
-- GetOutText (ExecuteEnemyMove -> PrintGhostText, core.asm:5462-5463)
|
||||
if self.ghost and not user.isPlayer then
|
||||
self:sayNext(self.data.text._GetOutText or "GHOST: Get out...\nGet out...")
|
||||
self:sayNext(self.data.text._GetOutText or Strings("GHOST: Get out...\nGet out..."))
|
||||
return
|
||||
end
|
||||
|
||||
@@ -2312,8 +2342,8 @@ function BattleState:executeAction(user, target, action)
|
||||
self.aiUses = self:aiUsesFor()
|
||||
markSeen(self.game, self.enemy.mon.species)
|
||||
-- _AIBattleWithdrawText: "X with-/drew Y!"
|
||||
self:sayNext(("%s with-\ndrew %s!"):format(self.trainer.name, oldName))
|
||||
self:sayNext(("%s sent\nout %s!"):format(self.trainer.name, self.enemy.name))
|
||||
self:sayNext(Strings("%s with-\ndrew %s!", self.trainer.name, oldName))
|
||||
self:sayNext(Strings("%s sent\nout %s!", self.trainer.name, self.enemy.name))
|
||||
return
|
||||
end
|
||||
|
||||
@@ -2326,7 +2356,7 @@ function BattleState:executeAction(user, target, action)
|
||||
-- 3392): sleep/freeze/held/flinch keep the mon recharging next turn
|
||||
if self:preRechargeChecks(user, target) then return end
|
||||
user.mustRecharge = nil
|
||||
self:sayNext(("%s\nmust recharge!"):format(displayName(user)))
|
||||
self:sayNext(Strings("%s\nmust recharge!", displayName(user)))
|
||||
return
|
||||
end
|
||||
if action.special == "bound" then
|
||||
@@ -2371,8 +2401,8 @@ function BattleState:statusOnomatopoeia(user, kind)
|
||||
anim = isPlayer and "CONF_PLAYER_ANIM" or "CONF_ANIM"
|
||||
end
|
||||
local text = kind == "sleep"
|
||||
and (displayName(user) .. "\nis fast asleep!")
|
||||
or (displayName(user) .. "\nis confused!")
|
||||
and Strings("%s\nis fast asleep!", displayName(user))
|
||||
or Strings("%s\nis confused!", displayName(user))
|
||||
if kind == "sleep" and isPlayer then
|
||||
self:animNext(anim, isPlayer)
|
||||
self:sayNext(text)
|
||||
@@ -2411,18 +2441,18 @@ function BattleState:preRechargeChecks(user, target)
|
||||
user.sleepTurns = (user.sleepTurns or 1) - 1
|
||||
if user.sleepTurns <= 0 then
|
||||
mon.status = nil
|
||||
self:sayNext(displayName(user) .. "\nwoke up!")
|
||||
self:sayNext(Strings("%s\nwoke up!", displayName(user)))
|
||||
else
|
||||
self:statusOnomatopoeia(user, "sleep")
|
||||
end
|
||||
return true
|
||||
end
|
||||
if mon.status == "FRZ" then
|
||||
self:sayNext(displayName(user) .. "\nis frozen solid!")
|
||||
self:sayNext(Strings("%s\nis frozen solid!", displayName(user)))
|
||||
return true
|
||||
end
|
||||
if target.trappingTurns then
|
||||
self:sayNext(displayName(user) .. "\ncan't move!")
|
||||
self:sayNext(Strings("%s\ncan't move!", displayName(user)))
|
||||
return true
|
||||
end
|
||||
if user.flinched then
|
||||
@@ -2430,7 +2460,7 @@ function BattleState:preRechargeChecks(user, target)
|
||||
-- player recharges, so the flinch eats the recharge turn and the
|
||||
-- flag survives (the Hyper Beam flinch glitch)
|
||||
user.flinched = false
|
||||
self:sayNext(displayName(user) .. "\nflinched!")
|
||||
self:sayNext(Strings("%s\nflinched!", displayName(user)))
|
||||
return true
|
||||
end
|
||||
return false
|
||||
@@ -2451,7 +2481,7 @@ function BattleState:statusInterrupt(user, target)
|
||||
{ id = "CONFUSED", power = 40, type = "NORMAL", accuracy = 100 },
|
||||
{ rng = self.rng, forceCrit = false, typeless = true,
|
||||
screens = target })
|
||||
self:sayNext("It hurt itself in\nits confusion!")
|
||||
self:sayNext(Strings("It hurt itself in\nits confusion!"))
|
||||
self:clearVolatiles(user, true)
|
||||
self:applyDamage(user, dmg)
|
||||
if user.mon.hp <= 0 then self:onFaint(user) end
|
||||
@@ -2539,7 +2569,7 @@ function BattleState:performMove(user, target, moveInst, isCalled)
|
||||
|
||||
self.moveAnimRow = nil
|
||||
if not (user.thrashTurns and moveInst == user.thrashMove and user.thrashAnnounced) then
|
||||
self:sayNext(("%s\nused %s!"):format(displayName(user), move.name))
|
||||
self:sayNext(Strings("%s\nused %s!", displayName(user), move.name))
|
||||
-- the move's animation plays right after the announcement; the
|
||||
-- damage path attaches the target's hit blink to this row so the
|
||||
-- blink follows the animation (pokered's order). Mimic is the
|
||||
@@ -2599,8 +2629,10 @@ function BattleState:performMove(user, target, moveInst, isCalled)
|
||||
self:animNext(chargeAnim, user.isPlayer)
|
||||
end
|
||||
local chargeText = move.chargeText or CHARGE_TEXT[move.id]
|
||||
or "%s\nis charging up!"
|
||||
self:sayNext(chargeText:format(displayName(user)))
|
||||
or Strings.source("%s\nis charging up!")
|
||||
-- the template is a source string (a move record may supply its own),
|
||||
-- so translate it here rather than where it was declared
|
||||
self:sayNext(Strings(chargeText, displayName(user)))
|
||||
return
|
||||
end
|
||||
|
||||
@@ -2622,7 +2654,7 @@ function BattleState:performMove(user, target, moveInst, isCalled)
|
||||
-- SleepEffect/PoisonEffect/... call PlayCurrentMoveAnimation only
|
||||
-- after the effect lands; a miss skips it
|
||||
self:cancelMoveAnim()
|
||||
self:sayNext(("%s's\nattack missed!"):format(displayName(user)))
|
||||
self:sayNext(Strings("%s's\nattack missed!", displayName(user)))
|
||||
return
|
||||
end
|
||||
local msgs = record.run(ctx)
|
||||
@@ -2640,7 +2672,7 @@ function BattleState:performMove(user, target, moveInst, isCalled)
|
||||
if move.power == 0 and not (record and record.kind == "full") then
|
||||
MoveEffects.warnUnknown(move.effect)
|
||||
self:cancelMoveAnim()
|
||||
self:sayNext("But, it failed!")
|
||||
self:sayNext(Strings("But, it failed!"))
|
||||
return
|
||||
end
|
||||
|
||||
@@ -2649,7 +2681,7 @@ function BattleState:performMove(user, target, moveInst, isCalled)
|
||||
end
|
||||
|
||||
function BattleState:continueTrapping(user, target)
|
||||
self:sayNext(("%s's\nattack continues!"):format(displayName(user)))
|
||||
self:sayNext(Strings("%s's\nattack continues!", displayName(user)))
|
||||
-- .MultiturnMoveCheck (core.asm:3554-3566) prints AttackContinuesText
|
||||
-- then jumps to GetPlayerAnimationType, so the trapping move's full
|
||||
-- animation replays each locked turn (same damage, animation shown).
|
||||
@@ -2672,15 +2704,15 @@ end
|
||||
function BattleState:continueBide(user, target)
|
||||
user.bideTurns = user.bideTurns - 1
|
||||
if user.bideTurns > 0 then
|
||||
self:sayNext(("%s\nis storing energy!"):format(displayName(user)))
|
||||
self:sayNext(Strings("%s\nis storing energy!", displayName(user)))
|
||||
return
|
||||
end
|
||||
self:sayNext(("%s\nunleashed energy!"):format(displayName(user)))
|
||||
self:sayNext(Strings("%s\nunleashed energy!", displayName(user)))
|
||||
local dmg = (user.bideDamage or 0) * 2
|
||||
user.bideTurns, user.bideDamage = nil, nil
|
||||
if dmg <= 0 then
|
||||
self:cancelMoveAnim()
|
||||
self:sayNext("But, it failed!")
|
||||
self:sayNext(Strings("But, it failed!"))
|
||||
return
|
||||
end
|
||||
self:applyDamage(target, dmg)
|
||||
@@ -2699,9 +2731,9 @@ function BattleState:applyDamage(target, dmg)
|
||||
target.substituteHP = target.substituteHP - dmg
|
||||
if target.substituteHP <= 0 then
|
||||
target.substituteHP = nil
|
||||
self:sayNext(("%s's\nSUBSTITUTE broke!"):format(displayName(target)))
|
||||
self:sayNext(Strings("%s's\nSUBSTITUTE broke!", displayName(target)))
|
||||
else
|
||||
self:sayNext(("The SUBSTITUTE\ntook damage for\n%s!"):format(displayName(target)))
|
||||
self:sayNext(Strings("The SUBSTITUTE\ntook damage for\n%s!", displayName(target)))
|
||||
end
|
||||
return dmg
|
||||
end
|
||||
@@ -2713,7 +2745,7 @@ function BattleState:applyDamage(target, dmg)
|
||||
end
|
||||
if target.rageMove and dealt > 0 then
|
||||
target.stages.attack = math.min(6, (target.stages.attack or 0) + 1)
|
||||
self:sayNext(("%s's\nRAGE is building!"):format(displayName(target)))
|
||||
self:sayNext(Strings("%s's\nRAGE is building!", displayName(target)))
|
||||
end
|
||||
return dealt
|
||||
end
|
||||
@@ -2752,7 +2784,7 @@ function BattleState:onFaint(battler)
|
||||
self:actNext(function() self:playVictoryMusic() end)
|
||||
end
|
||||
-- _EnemyMonFaintedText "Enemy X fainted!" / _PlayerMonFaintedText
|
||||
self:sayNext(("%s\nfainted!"):format(displayName(battler)))
|
||||
self:sayNext(Strings("%s\nfainted!", displayName(battler)))
|
||||
if battler.isPlayer then
|
||||
self:act(function() self:playerMonFainted() end)
|
||||
else
|
||||
@@ -2801,19 +2833,19 @@ function BattleState:enemyMonFainted()
|
||||
-- _BoostedText / _WithExpAllText end in the CONT code (\v, "...\011"
|
||||
-- in data/generated/text.lua): the box waits for A/B + ▼ then scrolls
|
||||
-- the amount line in, so it stays on-screen instead of at y=144 (#216).
|
||||
local tail = "%d EXP. Points!"
|
||||
local text = Strings.source("%s gained\n%d EXP. Points!")
|
||||
if announce == "expAll" then
|
||||
tail = "with EXP.ALL,\v" .. tail
|
||||
text = Strings.source("%s gained\nwith EXP.ALL,\v%d EXP. Points!")
|
||||
elseif mon.traded then
|
||||
tail = "a boosted\v" .. tail
|
||||
text = Strings.source("%s gained\na boosted\v%d EXP. Points!")
|
||||
end
|
||||
self:sayNext(("%s gained\n" .. tail):format(name, gained))
|
||||
self:sayNext(Strings(text, name, gained))
|
||||
end
|
||||
-- per level: GrewLevelText -> the stats window (PrintStatsBox) ->
|
||||
-- the move-learn checks (experience.asm:245-256)
|
||||
local game = self.game
|
||||
for _, lv in ipairs(levels) do
|
||||
self:sayNext(("%s grew\nto level %d!"):format(name, lv))
|
||||
self:sayNext(Strings("%s grew\nto level %d!", name, lv))
|
||||
self:uiNext(function()
|
||||
require("src.core.Sound").play(game.data, "Level_Up")
|
||||
return StatBox.new(game, mon)
|
||||
@@ -2886,10 +2918,10 @@ function BattleState:enemyMonFainted()
|
||||
if style ~= "set" and partyCount > 1 and self.player.mon.hp > 0 then
|
||||
-- _TrainerAboutToUseText: "X is" / "about to use" then cont nick,
|
||||
-- then para "Will PLAYER" / "change POKéMON?" with YES/NO.
|
||||
self:say(("%s is\nabout to use"):format(self.trainer.name))
|
||||
self:say(("%s!"):format(nextName))
|
||||
self:say(Strings("%s is\nabout to use", self.trainer.name))
|
||||
self:say(Strings("%s!", nextName))
|
||||
self:sayChoice(
|
||||
("Will %s\nchange POKéMON?"):format(self.game.save.player.name),
|
||||
Strings("Will %s\nchange POKéMON?", self.game.save.player.name),
|
||||
function(yes)
|
||||
if not yes then return end
|
||||
local game = self.game
|
||||
@@ -2922,7 +2954,7 @@ function BattleState:enemyMonFainted()
|
||||
-- (AnimateSendingOutMon) with the cry; no POOF -- that animation
|
||||
-- belongs to the player-side SendOutMon (core.asm:1757-1762)
|
||||
self.enemySendingOut = true
|
||||
self:sayNext(("%s sent\nout %s!"):format(self.trainer.name, self.enemy.name))
|
||||
self:sayNext(Strings("%s sent\nout %s!", self.trainer.name, self.enemy.name))
|
||||
self:actNext(function()
|
||||
self.enemySendingOut = false
|
||||
self:startGrowIn(self.enemy)
|
||||
@@ -2966,9 +2998,9 @@ function BattleState:enemyMonFainted()
|
||||
-- prize money
|
||||
self:actNext(function() self:playVictoryMusic() end)
|
||||
-- _TrainerDefeatedText: "<PLAYER> defeated\nTRAINER!"
|
||||
self:sayNext(("%s defeated\n%s!"):format(self.game.save.player.name,
|
||||
self:sayNext(Strings("%s defeated\n%s!", self.game.save.player.name,
|
||||
self.trainer.name))
|
||||
self:sayNext(("%s got ¥%d\nfor winning!"):format(self.game.save.player.name, prize))
|
||||
self:sayNext(Strings("%s got ¥%d\nfor winning!", self.game.save.player.name, prize))
|
||||
end
|
||||
self.result = "win"
|
||||
self.afterQueue = "finish"
|
||||
@@ -2984,7 +3016,7 @@ function BattleState:learnMove(mon, moveId)
|
||||
if #mon.moves < 4 then
|
||||
table.insert(mon.moves, { id = moveId, pp = mdef.pp })
|
||||
Runtime.emit("pokemon.move_learned", { mon = mon, moveId = moveId })
|
||||
self:sayNext(("%s learned\n%s!"):format(mon.nickname or self.data.pokemon[mon.species].name,
|
||||
self:sayNext(Strings("%s learned\n%s!", mon.nickname or self.data.pokemon[mon.species].name,
|
||||
mdef.name))
|
||||
return
|
||||
end
|
||||
@@ -3029,14 +3061,14 @@ function BattleState:playerMonFainted()
|
||||
if self.oppClass == "OPP_RIVAL1" then
|
||||
local TextBox = require("src.render.TextBox")
|
||||
local raw = (self.data.text and self.data.text._Rival1WinText)
|
||||
or "{RIVAL}: Yeah! Am\nI great or what?"
|
||||
or Strings("{RIVAL}: Yeah! Am\nI great or what?")
|
||||
self:sayNext(TextBox.substitute(self.game, raw))
|
||||
end
|
||||
-- Oak's Lab starter rival: Rival1WinText only (no blackout lines).
|
||||
-- Any other wipe, including Route 22 RIVAL1, still blacks out.
|
||||
if not BattleState.isOaksLabStarterRival(self) then
|
||||
self:sayNext(("%s is out of\nuseable POKéMON!"):format(self.game.save.player.name))
|
||||
self:sayNext(("%s blacked\nout!"):format(self.game.save.player.name))
|
||||
self:sayNext(Strings("%s is out of\nuseable POKéMON!", self.game.save.player.name))
|
||||
self:sayNext(Strings("%s blacked\nout!", self.game.save.player.name))
|
||||
end
|
||||
self.result = "lose"
|
||||
self.afterQueue = "finish"
|
||||
@@ -3049,7 +3081,7 @@ function BattleState:playerMonFainted()
|
||||
-- battles go straight to the party menu (the menu-phase guard).
|
||||
if self.kind ~= "wild" then return end
|
||||
local game = self.game
|
||||
self:say(self.data.text._UseNextMonText or "Use next POKéMON?")
|
||||
self:say(self.data.text._UseNextMonText or Strings("Use next POKéMON?"))
|
||||
self:ui(function()
|
||||
local ChoiceBox = require("src.ui.ChoiceBox")
|
||||
return ChoiceBox.new(game, function(yes)
|
||||
@@ -3057,11 +3089,11 @@ function BattleState:playerMonFainted()
|
||||
local pSpd = (game.save.party[1].stats or { speed = 0 }).speed or 0
|
||||
if self:runRoll(pSpd, TurnOrder.effectiveSpeed(self.enemy)) then
|
||||
require("src.core.Sound").play(self.data, "Run")
|
||||
self:say("Got away safely!")
|
||||
self:say(Strings("Got away safely!"))
|
||||
self.result = "run"
|
||||
self.afterQueue = "finish"
|
||||
else
|
||||
self:say("Can't escape!")
|
||||
self:say(Strings("Can't escape!"))
|
||||
end
|
||||
end)
|
||||
end)
|
||||
@@ -3081,7 +3113,7 @@ function BattleState:openReplacementMenu()
|
||||
forceSwitch = true,
|
||||
onSwitch = function(mon)
|
||||
if mon.hp <= 0 then
|
||||
self:say("There's no will\nto fight!")
|
||||
self:say(Strings("There's no will\nto fight!"))
|
||||
return -- the menu-phase guard reopens the menu
|
||||
end
|
||||
self:restoreMimicked(self.player)
|
||||
@@ -3125,7 +3157,7 @@ function BattleState:safariAction(choice)
|
||||
|
||||
if choice == "run" then
|
||||
require("src.core.Sound").play(self.data, "Run")
|
||||
self:say("Got away safely!")
|
||||
self:say(Strings("Got away safely!"))
|
||||
self.result = "run"
|
||||
self.afterQueue = "finish"
|
||||
return
|
||||
@@ -3133,7 +3165,7 @@ function BattleState:safariAction(choice)
|
||||
|
||||
if choice == "ball" then
|
||||
st.balls = st.balls - 1
|
||||
self:say(("%s used\nSAFARI BALL!"):format(playerName))
|
||||
self:say(Strings("%s used\nSAFARI BALL!", playerName))
|
||||
self:act(function()
|
||||
require("src.core.Sound").play(self.data, "Ball_Toss")
|
||||
self.lastBall = "SAFARI_BALL"
|
||||
@@ -3150,7 +3182,7 @@ function BattleState:safariAction(choice)
|
||||
self:actNext(function()
|
||||
require("src.core.Sound").play(self.data, "Caught_Mon")
|
||||
end)
|
||||
self:sayNext(("All right!\n%s was\ncaught!"):format(self.enemy.name))
|
||||
self:sayNext(Strings("All right!\n%s was\ncaught!", self.enemy.name))
|
||||
-- same ItemUseBall .captured flow as a regular ball
|
||||
self:act(function() self:storeCaughtMon() end)
|
||||
else
|
||||
@@ -3162,12 +3194,12 @@ function BattleState:safariAction(choice)
|
||||
end
|
||||
|
||||
if choice == "bait" then
|
||||
self:say(("%s threw some\nBAIT."):format(playerName))
|
||||
self:say(Strings("%s threw some\nBAIT.", playerName))
|
||||
self.safariCatchRate = math.floor(self.safariCatchRate / 2)
|
||||
self.baitFactor = math.min(255, self.baitFactor + self.rng(1, 5))
|
||||
self.escapeFactor = 0
|
||||
else -- rock
|
||||
self:say(("%s threw a\nROCK."):format(playerName))
|
||||
self:say(Strings("%s threw a\nROCK.", playerName))
|
||||
self.safariCatchRate = math.min(255, self.safariCatchRate * 2)
|
||||
self.escapeFactor = math.min(255, self.escapeFactor + self.rng(1, 5))
|
||||
self.baitFactor = 0
|
||||
@@ -3183,13 +3215,13 @@ end
|
||||
function BattleState:safariEnemyTurn()
|
||||
if self.baitFactor > 0 then
|
||||
self.baitFactor = self.baitFactor - 1
|
||||
self:sayNext(("Wild %s\nis eating!"):format(self.enemy.name))
|
||||
self:sayNext(Strings("Wild %s\nis eating!", self.enemy.name))
|
||||
elseif self.escapeFactor > 0 then
|
||||
self.escapeFactor = self.escapeFactor - 1
|
||||
if self.escapeFactor == 0 then
|
||||
self.safariCatchRate = self.enemy.def.catchRate
|
||||
end
|
||||
self:sayNext(("Wild %s\nis angry!"):format(self.enemy.name))
|
||||
self:sayNext(Strings("Wild %s\nis angry!", self.enemy.name))
|
||||
end
|
||||
self:act(function()
|
||||
local speed = self.enemy.curStats.speed % 256
|
||||
@@ -3205,7 +3237,7 @@ function BattleState:safariEnemyTurn()
|
||||
fled = self.rng(0, 255) < b
|
||||
end
|
||||
if fled then
|
||||
self:sayNext(("Wild %s\nran!"):format(self.enemy.name))
|
||||
self:sayNext(Strings("Wild %s\nran!", self.enemy.name))
|
||||
self:actNext(function()
|
||||
require("src.core.Sound").play(self.data, "Run")
|
||||
startPicKind(self:picFxFor(self.enemy), "slideOff")
|
||||
@@ -3258,7 +3290,12 @@ function BattleState:tryRun()
|
||||
self.phase = "messages"
|
||||
self.afterQueue = "menu"
|
||||
if self.kind == "trainer" then
|
||||
self:say("No! There's no\nrunning from a\ntrainer battle!")
|
||||
-- _NoRunningText is three lines in a two-line box, so the third arrives
|
||||
-- on a \v scroll (ContText: ▼ then a button press) rather than a \n.
|
||||
-- Spelling it with three \n dropped "trainer battle!" and handed the
|
||||
-- menu straight back (#239).
|
||||
self:say(self.data.text._NoRunningText
|
||||
or Strings("No! There's no\nrunning from a\vtrainer battle!"))
|
||||
return
|
||||
end
|
||||
-- modified in-battle speeds (stat stages + paralysis), like the
|
||||
@@ -3267,11 +3304,11 @@ function BattleState:tryRun()
|
||||
TurnOrder.effectiveSpeed(self.enemy))
|
||||
if escaped then
|
||||
require("src.core.Sound").play(self.data, "Run")
|
||||
self:say("Got away safely!")
|
||||
self:say(Strings("Got away safely!"))
|
||||
self.result = "run"
|
||||
self.afterQueue = "finish"
|
||||
else
|
||||
self:say("Can't escape!")
|
||||
self:say(Strings("Can't escape!"))
|
||||
self:act(function()
|
||||
self:executeAction(self.enemy, self.player, self:enemyAction())
|
||||
end)
|
||||
@@ -3304,13 +3341,13 @@ end
|
||||
function BattleState:ballMissMessage(shakes)
|
||||
local t = self.data.text
|
||||
if shakes == 0 then
|
||||
return t._ItemUseBallText01 or "You missed the\nPOKéMON!"
|
||||
return t._ItemUseBallText01 or Strings("You missed the\nPOKéMON!")
|
||||
elseif shakes == 1 then
|
||||
return t._ItemUseBallText02 or "Darn! The POKéMON\nbroke free!"
|
||||
return t._ItemUseBallText02 or Strings("Darn! The POKéMON\nbroke free!")
|
||||
elseif shakes == 2 then
|
||||
return (t._ItemUseBallText03 or "Aww! It appeared\nto be caught!"):gsub("%s+$", "")
|
||||
return (t._ItemUseBallText03 or Strings("Aww! It appeared\nto be caught!")):gsub("%s+$", "")
|
||||
end
|
||||
return t._ItemUseBallText04 or "Shoot! It was so\nclose too!"
|
||||
return t._ItemUseBallText04 or Strings("Shoot! It was so\nclose too!")
|
||||
end
|
||||
|
||||
-- AskName (engine/menus/naming_screen.asm): ClearSprites, wild field blank,
|
||||
@@ -3321,7 +3358,7 @@ function BattleState:askNicknameUI(mon, displayName)
|
||||
self.lockedBall = nil
|
||||
self.blankForAskName = true
|
||||
local TextBox = require("src.render.TextBox")
|
||||
local text = ("Do you want to\ngive a nickname\nto %s?"):format(displayName)
|
||||
local text = Strings("Do you want to\ngive a nickname\nto %s?", displayName)
|
||||
local label = game.data.text and game.data.text._DoYouWantToNicknameText
|
||||
if label then
|
||||
-- extractor CONT is \t; TextBox scrolls on \n/\v
|
||||
@@ -3332,7 +3369,7 @@ function BattleState:askNicknameUI(mon, displayName)
|
||||
self.blankForAskName = false
|
||||
if not yes then return end
|
||||
pcall(Screens.push, game, "NamingScreen", {
|
||||
title = "NICKNAME?", maxLen = 10,
|
||||
title = Strings("NICKNAME?"), maxLen = 10,
|
||||
onDone = function(name)
|
||||
if name and #name > 0 then mon.nickname = name end
|
||||
end,
|
||||
@@ -3360,7 +3397,7 @@ function BattleState:storeCaughtMon()
|
||||
stampOT(game.save, self.enemy.mon)
|
||||
if isNew then
|
||||
-- _ItemUseBallText06 + ShowPokedexData
|
||||
self:sayNext(("New POKéDEX data\nwill be added for\n%s!"):format(self.enemy.name))
|
||||
self:sayNext(Strings("New POKéDEX data\nwill be added for\n%s!", self.enemy.name))
|
||||
self:uiNext(function()
|
||||
return self:buildScreen("DexEntryMenu", species)
|
||||
end)
|
||||
@@ -3381,10 +3418,10 @@ function BattleState:storeCaughtMon()
|
||||
askCaughtNickname()
|
||||
-- _ItemUseBallText07/08 keyed on EVENT_MET_BILL
|
||||
local pc = (game.save.flags and game.save.flags.EVENT_MET_BILL)
|
||||
and "BILL's PC" or "someone's PC"
|
||||
self:sayNext(("%s was\ntransferred to\n%s!"):format(self.enemy.name, pc))
|
||||
and "BILL's PC" or Strings("someone's PC")
|
||||
self:sayNext(Strings("%s was\ntransferred to\n%s!", self.enemy.name, pc))
|
||||
else
|
||||
self:sayNext("But every BOX\nis full!")
|
||||
self:sayNext(Strings("But every BOX\nis full!"))
|
||||
end
|
||||
end
|
||||
Runtime.emit("pokemon.caught", {
|
||||
@@ -3438,13 +3475,13 @@ end
|
||||
|
||||
-- called by BagMenu when a ball is thrown
|
||||
function BattleState:throwBall(ball)
|
||||
self:say(("%s used\n%s!"):format(self.game.save.player.name,
|
||||
self:say(Strings("%s used\n%s!", self.game.save.player.name,
|
||||
self.data.items[ball].name))
|
||||
self:act(function()
|
||||
require("src.core.Sound").play(self.data, "Ball_Toss")
|
||||
if self.kind ~= "wild" then
|
||||
self:sayNext("The TRAINER\nblocked the BALL!")
|
||||
self:sayNext("Don't be a thief!")
|
||||
self:sayNext(Strings("The TRAINER\nblocked the BALL!"))
|
||||
self:sayNext(Strings("Don't be a thief!"))
|
||||
return
|
||||
end
|
||||
if self.ghost then
|
||||
@@ -3454,8 +3491,8 @@ function BattleState:throwBall(ball)
|
||||
-- ($10 anim data, no wobbles), and the turn is spent like any
|
||||
-- failed throw
|
||||
self:animNext(self:tossAnimFor(ball), true, nil, ball)
|
||||
self:sayNext("It dodged the\nthrown BALL!")
|
||||
self:sayNext("This POKéMON\ncan't be caught!")
|
||||
self:sayNext(Strings("It dodged the\nthrown BALL!"))
|
||||
self:sayNext(Strings("This POKéMON\ncan't be caught!"))
|
||||
self:act(function()
|
||||
self:executeAction(self.enemy, self.player, self:enemyAction())
|
||||
end)
|
||||
@@ -3478,7 +3515,7 @@ function BattleState:throwBall(ball)
|
||||
self:actNext(function()
|
||||
require("src.core.Sound").play(self.data, "Caught_Mon")
|
||||
end)
|
||||
self:sayNext(("All right!\n%s was\ncaught!"):format(self.enemy.name))
|
||||
self:sayNext(Strings("All right!\n%s was\ncaught!", self.enemy.name))
|
||||
self:act(function() self:storeCaughtMon() end)
|
||||
else
|
||||
self:sayNext(self:ballMissMessage(shakes))
|
||||
@@ -3498,9 +3535,9 @@ function BattleState:openParty()
|
||||
battle = self,
|
||||
onSwitch = function(mon)
|
||||
if mon == self.player.mon then
|
||||
self:say(("%s is\nalready out!"):format(self.player.name))
|
||||
self:say(Strings("%s is\nalready out!", self.player.name))
|
||||
elseif mon.hp <= 0 then
|
||||
self:say("There's no will\nto fight!")
|
||||
self:say(Strings("There's no will\nto fight!"))
|
||||
else
|
||||
self:resolveSwitch(mon)
|
||||
end
|
||||
@@ -3526,7 +3563,7 @@ end
|
||||
function BattleState:finish()
|
||||
if self.payDay and self.result == "win" then
|
||||
self.game.save.money = self.game.save.money + self.payDay
|
||||
self:say(("%s picked up\n¥%d!"):format(self.game.save.player.name, self.payDay))
|
||||
self:say(Strings("%s picked up\n¥%d!", self.game.save.player.name, self.payDay))
|
||||
self.payDay = nil
|
||||
self.afterQueue = "finish"
|
||||
self.phase = "messages"
|
||||
@@ -3573,9 +3610,11 @@ local HudTiles = require("src.render.HudTiles")
|
||||
local hudTile = HudTiles.tile
|
||||
local drawHPBar = HudTiles.drawHPBar
|
||||
|
||||
-- CenterMonName: 1-2 letter names print two tiles right, 3-4 one tile
|
||||
-- CenterMonName: 1-2 letter names print two tiles right, 3-4 one tile.
|
||||
-- Counted in glyphs, not bytes: a nickname carrying "é" or "♂" is one
|
||||
-- charmap sequence per glyph, and byte length would push it a tile left.
|
||||
local function nameX(tx, name)
|
||||
local n = #name
|
||||
local n = #Font.split(name)
|
||||
return tx * 8 + (n <= 2 and 16 or n <= 4 and 8 or 0)
|
||||
end
|
||||
|
||||
@@ -3868,8 +3907,11 @@ function BattleState:colorMode()
|
||||
if g and g.newCanvas and g.setScissor and g.setShader and g.getCanvas
|
||||
and love.image and PaletteFX.pack(self.data)
|
||||
and PaletteFX.shader() then
|
||||
local ok1, bg = pcall(g.newCanvas, 160, 144)
|
||||
local ok2, wv = pcall(g.newCanvas, 160, 144)
|
||||
-- 160x144 real pixels, not DPI units, or the colored battle background
|
||||
-- resamples against the UI canvas on mobile (#208; PixelCanvas.lua)
|
||||
local PixelCanvas = require("src.render.PixelCanvas")
|
||||
local ok1, bg = pcall(PixelCanvas.new, 160, 144)
|
||||
local ok2, wv = pcall(PixelCanvas.new, 160, 144)
|
||||
if ok1 and ok2 and bg and wv then
|
||||
self.bgCanvas, self.waveCanvas = bg, wv
|
||||
ready = true
|
||||
@@ -4301,7 +4343,10 @@ end
|
||||
function BattleState:drawTextArea()
|
||||
Font.drawBox(0, 12, 20, 6)
|
||||
love.graphics.setColor(0, 0, 0, 1)
|
||||
if self.phase == "messages" and self.current then
|
||||
if self.phase == "messages" and (self.current or self.animPlaying) then
|
||||
-- during the move animation self.current is nil but shown still holds
|
||||
-- the "used X!" lines; keep drawing them like pokered, whose move
|
||||
-- animations only touch sprites and never the textbox tilemap (#296)
|
||||
-- rolling 2-line window: shown[1] at row y=112, shown[2] at y=128 (battle
|
||||
-- text uses every other tile row, hlcoord *,14 / *,16). scrollPx animates
|
||||
-- the lines up one row (ScrollTextUpOneLine) so a 3rd line scrolls into
|
||||
@@ -4329,9 +4374,9 @@ function BattleState:drawTextArea()
|
||||
-- -- next to FIGHT (9,14) for the first 80 frames, then ITEM (9,16)
|
||||
Font.drawBox(8, 12, 12, 6)
|
||||
love.graphics.setColor(0, 0, 0, 1)
|
||||
Font.draw("FIGHT", 80, 112)
|
||||
Font.draw(Strings("FIGHT"), 80, 112)
|
||||
Font.drawCode(0xE1, 128, 112); Font.drawCode(0xE2, 136, 112)
|
||||
Font.draw("ITEM", 80, 128); Font.draw("RUN", 128, 128)
|
||||
Font.draw(Strings("ITEM"), 80, 128); Font.draw(Strings("RUN"), 128, 128)
|
||||
Font.drawCode(0xED, 72, (self.demoTimer or 0) <= 80 and 112 or 128)
|
||||
elseif self.phase == "menu" then
|
||||
local col = (self.menuIndex - 1) % 2
|
||||
@@ -4340,16 +4385,16 @@ function BattleState:drawTextArea()
|
||||
-- SAFARI_BATTLE_MENU_TEMPLATE: full-width box, "BALLx BAIT /
|
||||
-- THROW ROCK RUN" from (2,14)
|
||||
Font.drawBox(0, 12, 20, 6)
|
||||
Font.draw("BALLx", 16, 112); Font.draw("BAIT", 112, 112)
|
||||
Font.draw("THROW ROCK", 16, 128); Font.draw("RUN", 112, 128)
|
||||
Font.draw(Strings("BALLx"), 16, 112); Font.draw(Strings("BAIT"), 112, 112)
|
||||
Font.draw(Strings("THROW ROCK"), 16, 128); Font.draw(Strings("RUN"), 112, 128)
|
||||
Font.drawCode(0xED, (col == 0 and 8 or 104), 112 + row * 16)
|
||||
else
|
||||
-- BATTLE_MENU_TEMPLATE: box (8,12)-(19,17), "FIGHT <PK><MN> /
|
||||
-- ITEM RUN" from (10,14); cursor columns 9 / 15
|
||||
Font.drawBox(8, 12, 12, 6)
|
||||
Font.draw("FIGHT", 80, 112)
|
||||
Font.draw(Strings("FIGHT"), 80, 112)
|
||||
Font.drawCode(0xE1, 128, 112); Font.drawCode(0xE2, 136, 112)
|
||||
Font.draw("ITEM", 80, 128); Font.draw("RUN", 128, 128)
|
||||
Font.draw(Strings("ITEM"), 80, 128); Font.draw(Strings("RUN"), 128, 128)
|
||||
Font.drawCode(0xED, (col == 0 and 72 or 120), 112 + row * 16)
|
||||
end
|
||||
elseif self.phase == "moveSelect" then
|
||||
@@ -4374,10 +4419,10 @@ function BattleState:drawTextArea()
|
||||
local sel = self.player.curMoves[self.moveIndex]
|
||||
if sel then
|
||||
if self.player.disabledSlot == self.moveIndex then
|
||||
Font.draw("disabled!", 8, 80)
|
||||
Font.draw(Strings("disabled!"), 8, 80)
|
||||
else
|
||||
local def = self.data.moves[sel.id]
|
||||
Font.draw("TYPE/", 8, 72)
|
||||
Font.draw(Strings("TYPE/"), 8, 72)
|
||||
-- the type record's display name (a mod type shows its name, and
|
||||
-- PSYCHIC_TYPE prints PSYCHIC like the original)
|
||||
Font.draw(def.type and TypeChart.displayName(def.type) or "", 16, 80)
|
||||
@@ -4473,6 +4518,11 @@ function BattleState:draw()
|
||||
love.graphics.rectangle("fill", 0, 0, 160, 144)
|
||||
end
|
||||
love.graphics.setColor(1, 1, 1, 1)
|
||||
-- battle.overlay: shiny sparkles, custom HUD chrome, etc. Draw-only;
|
||||
-- the vanilla link is a no-op so an empty chain costs nothing.
|
||||
if Runtime.wantsHook("battle.overlay") then
|
||||
Runtime.call("battle.overlay", function() end, self)
|
||||
end
|
||||
end
|
||||
|
||||
return BattleState
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
local MoveEffects = require("src.battle.MoveEffects")
|
||||
local Runtime = require("src.mods.Runtime")
|
||||
local StatusRegistry = require("src.battle.StatusRegistry")
|
||||
local Strings = require("src.core.Strings")
|
||||
|
||||
local EffectRegistry = {}
|
||||
|
||||
@@ -91,7 +92,7 @@ function EffectRegistry.runDamaging(battle, ctx, record)
|
||||
if target.invulnerable and not neverMiss then
|
||||
-- Explosion/Selfdestruct still animate on a miss (HandleIfPlayerMoveMissed)
|
||||
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
|
||||
end
|
||||
|
||||
@@ -113,7 +114,7 @@ function EffectRegistry.runDamaging(battle, ctx, record)
|
||||
if not battle:accuracyRoll(move, user, target) then
|
||||
-- Explosion/Selfdestruct still animate on a miss (HandleIfPlayerMoveMissed)
|
||||
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
|
||||
if record and record.onMiss then record.onMiss(ctx, "accuracy") end
|
||||
user.trappingTurns = nil
|
||||
@@ -140,7 +141,7 @@ function EffectRegistry.runDamaging(battle, ctx, record)
|
||||
end
|
||||
if not counterable or (battle.lastDamage or 0) == 0 then
|
||||
battle:cancelMoveAnim()
|
||||
battle:sayNext(("%s's\nattack missed!"):format(displayName(user)))
|
||||
battle:sayNext(Strings("%s's\nattack missed!", displayName(user)))
|
||||
return
|
||||
end
|
||||
dmg = math.min(65535, battle.lastDamage * 2)
|
||||
@@ -163,14 +164,14 @@ function EffectRegistry.runDamaging(battle, ctx, record)
|
||||
if info.typeMult == 0 then
|
||||
-- type immunity zeros damage and sets wMoveMissed in Gen 1, so no anim
|
||||
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
|
||||
return
|
||||
end
|
||||
if info.missed then
|
||||
-- 0.25x floored the damage to zero: the original registers a miss
|
||||
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
|
||||
return
|
||||
end
|
||||
@@ -214,12 +215,12 @@ function EffectRegistry.runDamaging(battle, ctx, record)
|
||||
-- multi-hit loop (core.asm .moveDidNotMiss before the jump back
|
||||
-- to GetPlayerAnimationType), so crit/effectiveness reprint on
|
||||
-- every strike -- damage was only rolled once
|
||||
if info.crit then battle:sayNext("Critical hit!") end
|
||||
if info.ohko then battle:sayNext("One-hit KO!") end
|
||||
if info.crit then battle:sayNext(Strings("Critical hit!")) end
|
||||
if info.ohko then battle:sayNext(Strings("One-hit KO!")) end
|
||||
if info.typeMult > 10 then
|
||||
battle:sayNext("It's super\neffective!")
|
||||
battle:sayNext(Strings("It's super\neffective!"))
|
||||
elseif info.typeMult < 10 then
|
||||
battle:sayNext("It's not very\neffective...")
|
||||
battle:sayNext(Strings("It's not very\neffective..."))
|
||||
end
|
||||
if Runtime.wants("battle.damage_dealt") then
|
||||
Runtime.emit("battle.damage_dealt", {
|
||||
@@ -237,9 +238,9 @@ function EffectRegistry.runDamaging(battle, ctx, record)
|
||||
if hits > 1 then
|
||||
-- player: _MultiHitText; enemy: _HitXTimesText (always plural)
|
||||
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
|
||||
battle:sayNext(("Hit %d times!"):format(hits))
|
||||
battle:sayNext(Strings("Hit %d times!", hits))
|
||||
end
|
||||
end
|
||||
|
||||
@@ -251,7 +252,7 @@ function EffectRegistry.runDamaging(battle, ctx, record)
|
||||
elseif moveInst.struggle then
|
||||
-- struggle recoils even when its effect id resolves to no record
|
||||
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)
|
||||
end
|
||||
|
||||
|
||||
+65
-58
@@ -15,6 +15,7 @@ local Logger = require("src.core.Logger")
|
||||
local StatusRegistry = require("src.battle.StatusRegistry")
|
||||
local TurnOrder = require("src.battle.TurnOrder")
|
||||
local TypeChart = require("src.battle.TypeChart")
|
||||
local Strings = require("src.core.Strings")
|
||||
|
||||
local MoveEffects = {}
|
||||
|
||||
@@ -36,14 +37,14 @@ local STAT_LABEL = {
|
||||
local function changeStage(battle, who, stat, delta, fromEnemy)
|
||||
if fromEnemy and (who.substituteHP or who.mist) then
|
||||
if who.mist then
|
||||
return { displayName(who) .. " is\nprotected by MIST!" }
|
||||
return { Strings("%s is\nprotected by MIST!", displayName(who)) }
|
||||
end
|
||||
return { "But, it failed!" }
|
||||
return { Strings("But, it failed!") }
|
||||
end
|
||||
local cur = who.stages[stat] or 0
|
||||
local new = math.max(-6, math.min(6, cur + delta))
|
||||
if new == cur then
|
||||
return { ("Nothing happened!") }
|
||||
return { Strings("Nothing happened!") }
|
||||
end
|
||||
who.stages[stat] = new
|
||||
-- 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
|
||||
-- two-stage variants scroll "greatly" onto a third line
|
||||
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
|
||||
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
|
||||
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
|
||||
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
|
||||
MoveEffects.changeStage = changeStage
|
||||
|
||||
@@ -88,10 +89,10 @@ end
|
||||
local function statusMove(status)
|
||||
return function(battle, user, target, move)
|
||||
if target.mon.status then
|
||||
return { "But, it failed!" }
|
||||
return { Strings("But, it failed!") }
|
||||
end
|
||||
if status == "PSN" and target.substituteHP then
|
||||
return { "But, it failed!" }
|
||||
return { Strings("But, it failed!") }
|
||||
end
|
||||
local msgs = inflictStatus(battle, target, status, {
|
||||
toxic = move and move.id == "TOXIC",
|
||||
@@ -99,7 +100,7 @@ local function statusMove(status)
|
||||
source = move and move.id,
|
||||
})
|
||||
if #msgs == 0 then
|
||||
return { "But, it failed!" }
|
||||
return { Strings("But, it failed!") }
|
||||
end
|
||||
return msgs
|
||||
end
|
||||
@@ -111,7 +112,7 @@ local function statusSide(status, chance)
|
||||
-- target (regardless of the burn roll)
|
||||
if move and move.type == "FIRE" and target.mon.status == "FRZ" then
|
||||
target.mon.status = nil
|
||||
return { ("Fire defrosted\n%s!"):format(displayName(target)) }
|
||||
return { Strings("Fire defrosted\n%s!", displayName(target)) }
|
||||
end
|
||||
if battle.rng(0, 255) >= chance then return {} end
|
||||
return inflictStatus(battle, target, status, {
|
||||
@@ -144,10 +145,10 @@ end
|
||||
|
||||
local function confuse(battle, target, pierceSub)
|
||||
if target.confusedTurns or (target.substituteHP and not pierceSub) then
|
||||
return { "But, it failed!" }
|
||||
return { Strings("But, it failed!") }
|
||||
end
|
||||
target.confusedTurns = battle.rng(2, 5)
|
||||
return { ("%s\nbecame confused!"):format(displayName(target)) }
|
||||
return { Strings("%s\nbecame confused!", displayName(target)) }
|
||||
end
|
||||
|
||||
-- ---------------------------------------------------------------------
|
||||
@@ -181,53 +182,53 @@ MoveEffects.primary = {
|
||||
LEECH_SEED_EFFECT = function(battle, user, target)
|
||||
-- leech_seed.asm has no substitute check: seeding lands through one
|
||||
if target.leechSeeded then
|
||||
return { "But, it failed!" }
|
||||
return { Strings("But, it failed!") }
|
||||
end
|
||||
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
|
||||
target.leechSeeded = true
|
||||
return { ("%s\nwas seeded!"):format(displayName(target)) }
|
||||
return { Strings("%s\nwas seeded!", displayName(target)) }
|
||||
end,
|
||||
|
||||
HEAL_EFFECT = function(battle, user, target, move)
|
||||
local mon = user.mon
|
||||
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.status = "SLP"
|
||||
user.sleepTurns = 2
|
||||
user.toxicCounter = nil
|
||||
return { ("%s\nstarted sleeping!"):format(displayName(user)) }
|
||||
return { Strings("%s\nstarted sleeping!", displayName(user)) }
|
||||
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))
|
||||
return { ("%s\nregained health!"):format(displayName(user)) }
|
||||
return { Strings("%s\nregained health!", displayName(user)) }
|
||||
end,
|
||||
|
||||
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
|
||||
return { ("%s's\nprotected against\nspecial attacks!"):format(displayName(user)) }
|
||||
return { Strings("%s's\nprotected against\nspecial attacks!", displayName(user)) }
|
||||
end,
|
||||
|
||||
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
|
||||
return { ("%s\ngained armor!"):format(displayName(user)) }
|
||||
return { Strings("%s\ngained armor!", displayName(user)) }
|
||||
end,
|
||||
|
||||
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
|
||||
-- _ShroudedInMistText (lowercase "mist")
|
||||
return { ("%s's\nshrouded in mist!"):format(displayName(user)) }
|
||||
return { Strings("%s's\nshrouded in mist!", displayName(user)) }
|
||||
end,
|
||||
|
||||
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
|
||||
return { ("%s's\ngetting pumped!"):format(displayName(user)) }
|
||||
return { Strings("%s's\ngetting pumped!", displayName(user)) }
|
||||
end,
|
||||
|
||||
HAZE_EFFECT = function(battle, user, target)
|
||||
@@ -254,33 +255,33 @@ MoveEffects.primary = {
|
||||
target.skipMove = true
|
||||
end
|
||||
target.mon.status = nil
|
||||
return { "All STATUS changes\nare eliminated!" }
|
||||
return { Strings("All STATUS changes\nare eliminated!") }
|
||||
end,
|
||||
|
||||
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)
|
||||
-- substitute.asm only fails on subtraction underflow (current HP
|
||||
-- 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 engine next checks HP, not here)
|
||||
if user.mon.hp < cost then
|
||||
return { "Too weak to make\na SUBSTITUTE!" }
|
||||
return { Strings("Too weak to make\na SUBSTITUTE!") }
|
||||
end
|
||||
user.mon.hp = user.mon.hp - cost
|
||||
user.substituteHP = cost + 1
|
||||
-- _SubstituteText
|
||||
return { "It created a\nSUBSTITUTE!" }
|
||||
return { Strings("It created a\nSUBSTITUTE!") }
|
||||
end,
|
||||
|
||||
CONVERSION_EFFECT = function(battle, user, target)
|
||||
-- conversion.asm fails against a mid-Fly/Dig target (INVULNERABLE)
|
||||
if target.invulnerable then
|
||||
return { "But, it failed!" }
|
||||
return { Strings("But, it failed!") }
|
||||
end
|
||||
user.curTypes = { target.curTypes[1], target.curTypes[2] }
|
||||
-- _ConvertedTypeText
|
||||
return { ("Converted type to\n%s's!"):format(displayName(target)) }
|
||||
return { Strings("Converted type to\n%s's!", displayName(target)) }
|
||||
end,
|
||||
|
||||
-- MIMIC_EFFECT lives in BattleState:resolveMimic: MimicEffect
|
||||
@@ -311,27 +312,27 @@ MoveEffects.primary = {
|
||||
table.insert(user.curMoves, { id = mv.id, pp = 5, mimic = true })
|
||||
end
|
||||
-- _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,
|
||||
|
||||
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 = {}
|
||||
for i, mv in ipairs(target.curMoves) do
|
||||
if mv.pp > 0 then table.insert(usable, i) 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)]
|
||||
target.disabledSlot = slot
|
||||
target.disabledTurns = battle.rng(1, 8)
|
||||
local id = target.curMoves[slot].id
|
||||
-- _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) }
|
||||
end,
|
||||
|
||||
SPLASH_EFFECT = function()
|
||||
return { "No effect!" }
|
||||
return { Strings("No effect!") }
|
||||
end,
|
||||
}
|
||||
|
||||
@@ -428,7 +429,9 @@ local function drainHalf(text)
|
||||
local mon = ctx.user.mon
|
||||
mon.hp = math.min(mon.stats.hp, mon.hp + heal)
|
||||
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
|
||||
|
||||
@@ -436,7 +439,7 @@ end
|
||||
-- flags the miss before the special-damage override)
|
||||
local function immuneMsg(ctx)
|
||||
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
|
||||
return nil
|
||||
end
|
||||
@@ -498,12 +501,12 @@ MoveEffects.full = {
|
||||
-- removed): overkill and substitute hits recoil at full strength
|
||||
local recoil = math.max(1, math.floor(ctx.rawDamage
|
||||
/ (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)
|
||||
end,
|
||||
},
|
||||
DRAIN_HP_EFFECT = {
|
||||
afterDamage = drainHalf("Sucked health from\n%s!"),
|
||||
afterDamage = drainHalf(Strings.source("Sucked health from\n%s!")),
|
||||
},
|
||||
DREAM_EATER_EFFECT = {
|
||||
-- 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
|
||||
return true
|
||||
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
|
||||
@@ -557,7 +560,7 @@ MoveEffects.full = {
|
||||
user.thrashTurns, user.thrashMove, user.thrashAnnounced = nil, nil, nil
|
||||
if not user.confusedTurns then
|
||||
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
|
||||
@@ -566,7 +569,7 @@ MoveEffects.full = {
|
||||
JUMP_KICK_EFFECT = {
|
||||
onMiss = function(ctx, reason)
|
||||
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)
|
||||
end,
|
||||
},
|
||||
@@ -581,8 +584,12 @@ MoveEffects.full = {
|
||||
},
|
||||
HYPER_BEAM_EFFECT = {
|
||||
afterDamage = function(ctx)
|
||||
-- no recharge when the target faints OR its substitute breaks
|
||||
if ctx.target.mon.hp > 0 and not ctx.brokeSub then
|
||||
-- Gen 1: no recharge when the target faints OR its substitute breaks.
|
||||
-- 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
|
||||
end
|
||||
end,
|
||||
@@ -591,7 +598,7 @@ MoveEffects.full = {
|
||||
afterDamage = function(ctx)
|
||||
local battle = ctx.battle
|
||||
battle.payDay = (battle.payDay or 0) + 2 * ctx.user.mon.level
|
||||
ctx.say("Coins scattered\neverywhere!")
|
||||
ctx.say(Strings("Coins scattered\neverywhere!"))
|
||||
end,
|
||||
},
|
||||
SWIFT_EFFECT = { neverMiss = true },
|
||||
@@ -606,7 +613,7 @@ MoveEffects.full = {
|
||||
local user = ctx.user
|
||||
user.bideTurns = ctx.rng(2, 3)
|
||||
user.bideDamage = 0
|
||||
ctx.say(("%s\nis storing energy!"):format(displayName(user)))
|
||||
ctx.say(Strings("%s\nis storing energy!", displayName(user)))
|
||||
end,
|
||||
},
|
||||
SWITCH_AND_TELEPORT_EFFECT = {
|
||||
@@ -627,27 +634,27 @@ MoveEffects.full = {
|
||||
end
|
||||
if ok 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
|
||||
ctx.say(("%s\nwas blown away!"):format(displayName(target)))
|
||||
ctx.say(Strings("%s\nwas blown away!", displayName(target)))
|
||||
else
|
||||
ctx.say(("%s\nran from battle!"):format(displayName(user)))
|
||||
ctx.say(Strings("%s\nran from battle!", displayName(user)))
|
||||
end
|
||||
battle.result = "run"
|
||||
battle.afterQueue = "finish"
|
||||
elseif move.id == "TELEPORT" then
|
||||
battle:cancelMoveAnim()
|
||||
ctx.say("But, it failed!")
|
||||
ctx.say(Strings("But, it failed!"))
|
||||
else
|
||||
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
|
||||
elseif move.id == "TELEPORT" then
|
||||
battle:cancelMoveAnim()
|
||||
ctx.say("But, it failed!")
|
||||
ctx.say(Strings("But, it failed!"))
|
||||
else
|
||||
battle:cancelMoveAnim()
|
||||
ctx.say(("%s\nis unaffected!"):format(displayName(target)))
|
||||
ctx.say(Strings("%s\nis unaffected!", displayName(target)))
|
||||
end
|
||||
end,
|
||||
},
|
||||
@@ -665,7 +672,7 @@ MoveEffects.full = {
|
||||
callsMove = function(ctx)
|
||||
local last = ctx.target.lastMove
|
||||
if not last then
|
||||
ctx.say("The MIRROR MOVE\nfailed!")
|
||||
ctx.say(Strings("The MIRROR MOVE\nfailed!"))
|
||||
return nil
|
||||
end
|
||||
return last
|
||||
|
||||
+31
-21
@@ -5,6 +5,8 @@
|
||||
-- and residual sweep. Callers without a battle (pure-module tests) fall
|
||||
-- back to the vanilla records, which is bit-identical behavior.
|
||||
|
||||
local Strings = require("src.core.Strings")
|
||||
|
||||
local Status = {}
|
||||
|
||||
-- pokered's <USER>/<TARGET> text macros print "Enemy " before the enemy
|
||||
@@ -28,8 +30,11 @@ local function hasType(battler, wanted)
|
||||
end
|
||||
|
||||
-- shared PSN/BRN residual: 1/16 max HP, multiplied (and advanced) by the
|
||||
-- Toxic counter (HandlePoisonBurnLeechSeed)
|
||||
local function damageOverTime(what)
|
||||
-- Toxic counter (HandlePoisonBurnLeechSeed). The caller passes the whole
|
||||
-- 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)
|
||||
local mon = battler.mon
|
||||
local base = math.max(1, math.floor(mon.stats.hp / 16))
|
||||
@@ -39,10 +44,15 @@ local function damageOverTime(what)
|
||||
battler.toxicCounter = battler.toxicCounter + 1
|
||||
end
|
||||
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
|
||||
|
||||
-- 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
|
||||
-- residual sweep, the inflict text/immunities (StatusRegistry.inflict),
|
||||
-- the catch/wobble bonuses (Catching.attempt), the HUD label, and the
|
||||
@@ -57,13 +67,13 @@ Status.RECORDS = {
|
||||
battler.sleepTurns = (battler.sleepTurns or 1) - 1
|
||||
if battler.sleepTurns <= 0 then
|
||||
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
|
||||
return false, { name(battler) .. "\nis fast asleep!" }
|
||||
return false, { Strings("%s\nis fast asleep!", name(battler)) }
|
||||
end,
|
||||
onInflict = function(battle, target, opts, display)
|
||||
target.sleepTurns = battle.rng(1, 7)
|
||||
return { ("%s\nfell asleep!"):format(display) }
|
||||
return { Strings("%s\nfell asleep!", display) }
|
||||
end,
|
||||
},
|
||||
FRZ = {
|
||||
@@ -71,35 +81,35 @@ Status.RECORDS = {
|
||||
catchBonus = 25, shakeBonus = 10,
|
||||
beforeMovePriority = 30,
|
||||
beforeMove = function(battler)
|
||||
return false, { name(battler) .. "\nis frozen solid!" }
|
||||
return false, { Strings("%s\nis frozen solid!", name(battler)) }
|
||||
end,
|
||||
canInflict = function(target) return not hasType(target, "ICE") end,
|
||||
onInflict = function(_, _, _, display)
|
||||
return { ("%s\nwas frozen solid!"):format(display) }
|
||||
return { Strings("%s\nwas frozen solid!", display) }
|
||||
end,
|
||||
},
|
||||
PSN = {
|
||||
id = "PSN", label = "PSN", hudLabel = "PSN",
|
||||
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,
|
||||
onInflict = function(_, target, opts, display)
|
||||
if opts.toxic then
|
||||
target.toxicCounter = 1
|
||||
-- _BadlyPoisonedText
|
||||
return { ("%s's\nbadly poisoned!"):format(display) }
|
||||
return { Strings("%s's\nbadly poisoned!", display) }
|
||||
end
|
||||
return { ("%s\nwas poisoned!"):format(display) }
|
||||
return { Strings("%s\nwas poisoned!", display) }
|
||||
end,
|
||||
},
|
||||
BRN = {
|
||||
id = "BRN", label = "BRN", hudLabel = "BRN",
|
||||
catchBonus = 12, shakeBonus = 5,
|
||||
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,
|
||||
onInflict = function(_, _, _, display)
|
||||
return { ("%s\nwas burned!"):format(display) }
|
||||
return { Strings("%s\nwas burned!", display) }
|
||||
end,
|
||||
},
|
||||
PAR = {
|
||||
@@ -110,7 +120,7 @@ Status.RECORDS = {
|
||||
beforeMove = function(battler, rng)
|
||||
-- cp 25 percent / jr nc: fully paralyzed on rand < 63 (63/256)
|
||||
if rng(0, 255) < 63 then
|
||||
return false, { name(battler) .. "'s\nfully paralyzed!" }
|
||||
return false, { Strings("%s's\nfully paralyzed!", name(battler)) }
|
||||
end
|
||||
return true, {}
|
||||
end,
|
||||
@@ -120,7 +130,7 @@ Status.RECORDS = {
|
||||
end,
|
||||
onInflict = function(_, _, _, display)
|
||||
-- _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,
|
||||
},
|
||||
}
|
||||
@@ -156,7 +166,7 @@ function Status.beforeMove(battler, rng, battle)
|
||||
end
|
||||
if battler.flinched then
|
||||
battler.flinched = false
|
||||
return false, { name(battler) .. "\nflinched!" }
|
||||
return false, { Strings("%s\nflinched!", name(battler)) }
|
||||
end
|
||||
local record = Status.recordFor(battleStatuses(battle), mon.status)
|
||||
local handler = record and record.beforeMove
|
||||
@@ -174,23 +184,23 @@ function Status.beforeMove(battler, rng, battle)
|
||||
end
|
||||
if battler.boundTurns and battler.boundTurns > 0 then
|
||||
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
|
||||
end
|
||||
if battler.disabledTurns then
|
||||
battler.disabledTurns = battler.disabledTurns - 1
|
||||
if battler.disabledTurns <= 0 then
|
||||
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
|
||||
if battler.confusedTurns then
|
||||
battler.confusedTurns = battler.confusedTurns - 1
|
||||
if battler.confusedTurns <= 0 then
|
||||
battler.confusedTurns = nil
|
||||
table.insert(msgs, name(battler) .. "\nsnapped out of\nconfusion!")
|
||||
table.insert(msgs, Strings("%s\nsnapped out of\nconfusion!", name(battler)))
|
||||
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)
|
||||
if rng(0, 255) < 128 then
|
||||
return false, msgs, true -- hurt itself
|
||||
@@ -231,7 +241,7 @@ function Status.residual(battler, opponent, battle)
|
||||
dmg = math.min(dmg, mon.hp)
|
||||
mon.hp = 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
|
||||
return msgs
|
||||
end
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
|
||||
local Runtime = require("src.mods.Runtime")
|
||||
local Status = require("src.battle.Status")
|
||||
local Strings = require("src.core.Strings")
|
||||
|
||||
local StatusRegistry = {}
|
||||
|
||||
@@ -45,7 +46,7 @@ function StatusRegistry.inflict(battle, target, status, opts)
|
||||
if record and record.onInflict then
|
||||
msgs = record.onInflict(battle, target, opts, display)
|
||||
else
|
||||
msgs = { ("%s\nwas afflicted\nby %s!"):format(display,
|
||||
msgs = { Strings("%s\nwas afflicted\nby %s!", display,
|
||||
record and record.label or tostring(status)) }
|
||||
end
|
||||
Runtime.emit("battle.status_inflicted", {
|
||||
|
||||
@@ -19,6 +19,7 @@
|
||||
-- enemy PP — Gen 1 AI never reads wEnemyMonPP).
|
||||
|
||||
local TypeChart = require("src.battle.TypeChart")
|
||||
local Strings = require("src.core.Strings")
|
||||
|
||||
local TrainerAI = {}
|
||||
|
||||
@@ -99,7 +100,7 @@ function TrainerAI.useItem(battle, item)
|
||||
local enemy = battle.enemy
|
||||
local trainerName = battle.trainer.name
|
||||
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
|
||||
enemy.mon.status = nil
|
||||
enemy.toxicCounter = nil
|
||||
@@ -112,10 +113,10 @@ function TrainerAI.useItem(battle, item)
|
||||
elseif X_STAT[item] then
|
||||
local stat = X_STAT[item]
|
||||
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
|
||||
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
|
||||
return msgs
|
||||
end
|
||||
|
||||
@@ -16,4 +16,7 @@ return {
|
||||
-- Wild/trainer enemies never spend PP (DecrementPP only touches the
|
||||
-- player side in pokered). They therefore never Struggle from empty PP.
|
||||
enemyUnlimitedPP = true,
|
||||
-- Gen 1 Hyper Beam: no recharge when the target faints (or its
|
||||
-- substitute breaks). Set false to always recharge like Gen 2+.
|
||||
hyperBeamSkipRechargeOnKO = true,
|
||||
}
|
||||
|
||||
@@ -11,4 +11,6 @@ return {
|
||||
focusEnergyBug = false,
|
||||
-- Gen 2+ style: AI opponents deplete PP and can Struggle when empty.
|
||||
enemyUnlimitedPP = false,
|
||||
-- Gen 2+: Hyper Beam always forces a recharge turn, even on a KO.
|
||||
hyperBeamSkipRechargeOnKO = false,
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
local Json = require("src.link.Json")
|
||||
local Logger = require("src.core.Logger")
|
||||
local Runtime = require("src.mods.Runtime")
|
||||
local Strings = require("src.core.Strings")
|
||||
|
||||
local DiscordPresence = {
|
||||
APP_ID = "1529183141267374262",
|
||||
@@ -436,7 +437,7 @@ end
|
||||
local function buildActivity()
|
||||
local details, activityState
|
||||
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"
|
||||
elseif state.activity == "exploring" and state.location then
|
||||
details = state.location
|
||||
@@ -550,20 +551,20 @@ local function subscribe(game)
|
||||
|
||||
track("battle.started", function(ev)
|
||||
local battle = ev and ev.battle
|
||||
local label = "In battle"
|
||||
local label = Strings("In battle")
|
||||
if ev and ev.kind == "wild" then
|
||||
local name = speciesName(game, ev.species)
|
||||
if name then
|
||||
label = "Battling wild " .. name
|
||||
if ev.level then label = label .. " Lv" .. tostring(ev.level) end
|
||||
else
|
||||
label = "Wild battle"
|
||||
label = Strings("Wild battle")
|
||||
end
|
||||
elseif ev and ev.kind == "trainer" then
|
||||
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
|
||||
label = "Link battle"
|
||||
label = Strings("Link battle")
|
||||
end
|
||||
local mapId = state.mapId
|
||||
or (game.save and game.save.player and game.save.player.map)
|
||||
@@ -594,7 +595,7 @@ local function subscribe(game)
|
||||
state.mapId = nil
|
||||
setPresence({
|
||||
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,
|
||||
})
|
||||
end
|
||||
|
||||
@@ -53,6 +53,9 @@ function Game:load()
|
||||
require("src.render.Font").load(Data)
|
||||
-- menu cursor/border/geometry constants; field.theme restyles them
|
||||
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
|
||||
StateStack:init()
|
||||
@@ -96,6 +99,20 @@ function Game:load()
|
||||
end
|
||||
|
||||
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
|
||||
|
||||
-- the merged field.boot: spawn, names, money and the naming presets a
|
||||
|
||||
+31
-1
@@ -24,7 +24,31 @@ local FILTER_HIGHGAIN = { 0.4, 0.16, 0.064 }
|
||||
local filterLevel = 0
|
||||
|
||||
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
|
||||
|
||||
-- Source:setFilter needs OpenAL EFX; the pcall degrades to unfiltered
|
||||
@@ -408,6 +432,12 @@ end
|
||||
-- restores the map theme after a one-shot jingle
|
||||
function Music.update(data)
|
||||
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`
|
||||
-- frames, then drop one level (FadeOutAudio decrements both rAUDVOL
|
||||
-- nibbles when its counter reaches 0); at level 0 the music stops.
|
||||
|
||||
+51
-6
@@ -552,6 +552,44 @@ function SaveData.writeSlot(version, slotId, saveTable)
|
||||
return true
|
||||
end
|
||||
|
||||
-- Delete a registered slot: remove its main/.bak/.tmp files, drop it from the
|
||||
-- options registry, and if it was active point active at another remaining
|
||||
-- slot (or clear active when the list is empty). Returns true, or false +
|
||||
-- an error string when the id is unknown / not registered.
|
||||
function SaveData.deleteSlot(version, slotId)
|
||||
version = version or GameVersion.get()
|
||||
if not knownVersion(version) then return false, "unknown version" end
|
||||
if type(slotId) ~= "string" or slotId == "" then
|
||||
return false, "missing slot id"
|
||||
end
|
||||
local fs = persistFs(nil)
|
||||
ensureVersionSlots(version, fs)
|
||||
local opts = SaveData.loadOptions(fs)
|
||||
opts.saveSlots = opts.saveSlots or {}
|
||||
local reg = opts.saveSlots[version]
|
||||
if not reg or not reg.list then return false, "slot not registered" end
|
||||
local found, idx = false, nil
|
||||
for i, id in ipairs(reg.list) do
|
||||
if id == slotId then found = true; idx = i; break end
|
||||
end
|
||||
if not found then return false, "slot not registered" end
|
||||
|
||||
local main, bak, tmp = slotNames(version, slotId)
|
||||
remove(fs, main)
|
||||
remove(fs, bak)
|
||||
remove(fs, tmp)
|
||||
|
||||
table.remove(reg.list, idx)
|
||||
if reg.active == slotId then
|
||||
reg.active = reg.list[1] -- may be nil when the list is now empty
|
||||
end
|
||||
opts.saveSlots[version] = reg
|
||||
SaveData.saveOptions(opts, fs)
|
||||
slotsChecked[version] = true
|
||||
activeSlotCache[version] = reg.active or false
|
||||
return true
|
||||
end
|
||||
|
||||
-- Test seam: drop the process-global slot cache so a suite can exercise
|
||||
-- migration/resolution against a freshly injected filesystem. Unused by
|
||||
-- the game, which resolves each version exactly once per boot.
|
||||
@@ -1117,9 +1155,16 @@ function SaveData.defaultHeal(boot)
|
||||
end
|
||||
|
||||
-- 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
|
||||
-- retargets LAST_MAP exits (Red's house mats) at the heal-point town so
|
||||
-- leaving the house does not dump the player back at Indigo Plateau.
|
||||
-- after jp Init; this port relocates CONTINUE instead, and retargets
|
||||
-- LAST_MAP exits (Red's house mats) at the heal-point town so leaving the
|
||||
-- house does not dump the player back at Indigo Plateau.
|
||||
--
|
||||
-- The landing spot is the blackout point, not the NewGameWarp bedroom:
|
||||
-- HallOfFameResetEventsAndSaveScript sets wLastBlackoutMap := PALLET_TOWN,
|
||||
-- so resuming puts the player outside the front door. Spawning them back
|
||||
-- in the upstairs bedroom was a port-only detour (#253). Non-vanilla
|
||||
-- spawns are unaffected -- defaultHeal returns boot's own start map for
|
||||
-- those, which is what the bedroom branch resolved to anyway.
|
||||
-- Marks postGameHomeOk so a later intentional HoF save is not relocated.
|
||||
function SaveData.applyPostGameHome(save, boot)
|
||||
boot = type(boot) == "table" and boot or {}
|
||||
@@ -1127,9 +1172,9 @@ function SaveData.applyPostGameHome(save, boot)
|
||||
save.lastHeal = { map = heal.map, x = heal.x, y = heal.y }
|
||||
save.lastOutdoor = { id = heal.map, x = heal.x, y = heal.y }
|
||||
save.player = save.player or {}
|
||||
save.player.map = boot.startMap or "REDS_HOUSE_2F"
|
||||
save.player.x = boot.startX or 3
|
||||
save.player.y = boot.startY or 6
|
||||
save.player.map = heal.map
|
||||
save.player.x = heal.x
|
||||
save.player.y = heal.y
|
||||
save.player.facing = boot.startFacing or "down"
|
||||
save.postGameHomeOk = true
|
||||
return heal
|
||||
|
||||
@@ -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
|
||||
+269
-60
@@ -1,4 +1,5 @@
|
||||
local GameVersion = require("src.core.GameVersion")
|
||||
local Strings = require("src.core.Strings")
|
||||
|
||||
local RomImporter = {}
|
||||
RomImporter.__index = RomImporter
|
||||
@@ -290,6 +291,39 @@ local function findPendingRom(ready)
|
||||
return nil
|
||||
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)
|
||||
promptName = promptName or "Pokemon"
|
||||
local prompt = "Choose your " .. promptName .. " ROM"
|
||||
@@ -320,10 +354,10 @@ local function chooseRom(promptName)
|
||||
end
|
||||
|
||||
-- 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
|
||||
-- panel steers that case to the drag-drop hint instead.
|
||||
-- Returns the chosen absolute path or nil. Android uses love.system.pickFile
|
||||
-- ("mod") instead -- see RomImporter:chooseMod.
|
||||
local function chooseZip()
|
||||
local prompt = "Choose a mod .zip"
|
||||
local prompt = Strings("Choose a mod .zip")
|
||||
local platform = love.system.getOS()
|
||||
if platform == "OS X" then
|
||||
return commandOutput(
|
||||
@@ -351,10 +385,10 @@ local function chooseZip()
|
||||
end
|
||||
|
||||
-- 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;
|
||||
-- the SAVE FILES card steers that case to the drag-drop hint instead.
|
||||
-- dialogs). Returns the chosen absolute path or nil. Android uses
|
||||
-- love.system.pickFile("sav") instead -- see RomImporter:chooseSaveImport.
|
||||
local function chooseSav()
|
||||
local prompt = "Choose a .sav save file"
|
||||
local prompt = Strings("Choose a .sav save file")
|
||||
local platform = love.system.getOS()
|
||||
if platform == "OS X" then
|
||||
return commandOutput(
|
||||
@@ -429,10 +463,16 @@ function RomImporter.new(onComplete, opts)
|
||||
-- affordance (desktop love.system.openURL).
|
||||
saveNotice = {},
|
||||
-- MODS panel state (pass 3): mods is the cached LauncherMods.list() array
|
||||
-- (refreshed lazily on first draw and after any toggle/install); modScroll
|
||||
-- is the list scroll offset (px, clamped in draw); modNotice is the last
|
||||
-- install result { ok, text } shown as a line above the list.
|
||||
-- (refreshed lazily on first draw and after any toggle/install/delete);
|
||||
-- modScroll is the list scroll offset (px, clamped in draw); modNotice is
|
||||
-- the last install/delete result { ok, text } shown as a line above the list.
|
||||
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)
|
||||
|
||||
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
|
||||
-- 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
|
||||
-- it into the save directory, so a pending-ROM rescan on refocus picks it
|
||||
-- up without the player needing to tap the button again.
|
||||
-- it into the save directory, so a pending-file rescan on refocus picks it
|
||||
-- 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)
|
||||
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
|
||||
local name, data = findPendingRom(self.ready)
|
||||
if name then self:startData(data, name) end
|
||||
@@ -672,13 +742,38 @@ function RomImporter:_installMod(source)
|
||||
self.tab = "mods"
|
||||
end
|
||||
|
||||
-- "Import mod .zip" button: open a native picker (desktop) and install the
|
||||
-- pick. Android has no picker, so it points the player at the drop path.
|
||||
-- Remove an installed mod from the save-dir mods/ tree and refresh the panel.
|
||||
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()
|
||||
if self.workState == "working" then return end
|
||||
if self.android then
|
||||
self.modNotice =
|
||||
{ ok = false, text = "Drop a mod .zip onto the window to install it." }
|
||||
local name = findPendingMod(true)
|
||||
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
|
||||
end
|
||||
local path = chooseZip()
|
||||
@@ -722,13 +817,26 @@ function RomImporter:_importSave(version, source)
|
||||
end
|
||||
end
|
||||
|
||||
-- "Import save" button: open a native .sav picker (desktop) and import the pick.
|
||||
-- Android has no picker, so it points the player at the drop path.
|
||||
-- "Import save" button: open a native .sav picker and import the pick.
|
||||
-- Android mirrors ROM / mod import via love.system.pickFile("sav").
|
||||
function RomImporter:chooseSaveImport(version)
|
||||
if self.workState == "working" then return end
|
||||
if self.android then
|
||||
self.saveNotice[version] =
|
||||
{ ok = false, text = "Drop a .sav onto the window to import it." }
|
||||
local name = findPendingSav(true)
|
||||
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
|
||||
end
|
||||
local path = chooseSav()
|
||||
@@ -736,16 +844,58 @@ function RomImporter:chooseSaveImport(version)
|
||||
end
|
||||
|
||||
-- "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)
|
||||
-- on the SAVE FILES card.
|
||||
-- directory's exports/ folder. On desktop, show the path with an open-folder
|
||||
-- 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)
|
||||
if self.workState == "working" then return end
|
||||
local ok, res = require("src.import.SaveFileIO").exportActiveSlot(version)
|
||||
if ok then
|
||||
local dir = res:match("^(.*)[/\\][^/\\]+$")
|
||||
self.saveNotice[version] = { ok = true, text = "Exported to " .. res, dir = dir }
|
||||
else
|
||||
if not ok then
|
||||
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
|
||||
|
||||
@@ -1277,7 +1427,7 @@ function RomImporter:draw()
|
||||
self.updateButton = { x = rect.x, y = rect.y, width = rect.width,
|
||||
height = rect.height, action = "download" }
|
||||
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
|
||||
local rect = actionButton("Open releases")
|
||||
self.updateButton = { x = rect.x, y = rect.y, width = rect.width,
|
||||
@@ -1427,9 +1577,17 @@ function RomImporter:mousepressed(x, y, button)
|
||||
end
|
||||
return
|
||||
end
|
||||
-- SAVE SLOT rows. On desktop a press only ARMS a click: _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.
|
||||
-- SAVE SLOT rows / Delete. Delete is checked first so a tap on the Delete
|
||||
-- label never also selects the row. On desktop a press only ARMS a click:
|
||||
-- _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
|
||||
if inside(r, x, y) then
|
||||
if self.android then
|
||||
@@ -1445,12 +1603,18 @@ function RomImporter:mousepressed(x, y, button)
|
||||
self:_newSlot(self.panelVersion); return
|
||||
end
|
||||
-- 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
|
||||
-- press so _updateSlotDrag can tell a click from a drag-scroll (Android, with
|
||||
-- no pointer polling, toggles on press).
|
||||
-- conflict); Delete fires immediately; a toggle switch, which lives in the
|
||||
-- scrollable list, only ARMS a press so _updateSlotDrag can tell a click from
|
||||
-- a drag-scroll (Android, with no pointer polling, toggles on press).
|
||||
if inside(self.modImportRect, x, y) then
|
||||
self:chooseMod(); return
|
||||
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
|
||||
if inside(r, x, y) then
|
||||
if self.android then
|
||||
@@ -1555,7 +1719,7 @@ function RomImporter:_playButton(x, y, w, h, gameName, ready, locked)
|
||||
col(PAL.disabledInk, 0.3)
|
||||
love.graphics.rectangle("line", x, y, w, h, r, r)
|
||||
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")
|
||||
self.playButtonRect = nil
|
||||
end
|
||||
@@ -1567,13 +1731,13 @@ function RomImporter:_drawTabBar(x, y, w, h, chip)
|
||||
local s, pulse = self._s, self.pulse
|
||||
local tabs = {
|
||||
{ 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,
|
||||
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,
|
||||
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,
|
||||
under = PAL.modDot, label = "MODS" },
|
||||
under = PAL.modDot, label = Strings("MODS") },
|
||||
}
|
||||
local gap = 10 * s
|
||||
local r = 12 * s
|
||||
@@ -1632,7 +1796,7 @@ function RomImporter:_drawTabBar(x, y, w, h, chip)
|
||||
local ready = 0
|
||||
for _, v in ipairs(GameVersion.ORDER) do if self.ready[v] then ready = ready + 1 end end
|
||||
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)
|
||||
if x + w - lw > cursorX + 8 * s then
|
||||
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)
|
||||
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 romState, romDetail, romBtnLabel, romBtnEnabled, romProgress
|
||||
if locked then
|
||||
@@ -1703,12 +1867,12 @@ function RomImporter:_drawGamePanel(version, x, y, w, h)
|
||||
romDetail = self.detail or ""
|
||||
romProgress = self.progress or 0
|
||||
elseif ready then
|
||||
romState = self.romName[version] or "ROM imported"
|
||||
romState = self.romName[version] or Strings("ROM imported")
|
||||
romDetail = "Verified."
|
||||
romBtnLabel, romBtnEnabled = "Re-import ROM", true
|
||||
elseif erroring then
|
||||
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
|
||||
elseif notice then
|
||||
romState = "No ROM imported"
|
||||
@@ -1759,7 +1923,8 @@ function RomImporter:_drawGamePanel(version, x, y, w, h)
|
||||
elseif locked then
|
||||
sfHintText, sfHintCol = "Not available yet.", PAL.warning
|
||||
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
|
||||
sfHintText, sfHintCol =
|
||||
"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
|
||||
iy = iy + 4 * s
|
||||
love.graphics.setFont(self.hintFont)
|
||||
local label = "Open folder"
|
||||
local label = Strings("Open folder")
|
||||
local lw = self.hintFont:getWidth(label)
|
||||
local frect = { x = ix, y = iy, width = lw, height = self.hintFont:getHeight(),
|
||||
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.",
|
||||
rx + 12 * s, listTop + listH / 2 - self.hintFont:getHeight() / 2,
|
||||
rw - 24 * s, "center")
|
||||
self.slotRects = {}
|
||||
self.slotDeleteRects = {}
|
||||
elseif listH > 0 then
|
||||
local nameH = self.slotNameFont:getHeight()
|
||||
local metaH = self.labelFont:getHeight()
|
||||
@@ -2017,6 +2184,7 @@ function RomImporter:_drawSaveSlotPanel(version, x, y, w, h)
|
||||
self.slotScroll[version] = scroll
|
||||
|
||||
self.slotRects = {}
|
||||
self.slotDeleteRects = {}
|
||||
love.graphics.setScissor(math.floor(rx), math.floor(listTop),
|
||||
math.ceil(rw), math.ceil(listH))
|
||||
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)
|
||||
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
|
||||
local pillW = 0
|
||||
if selected then
|
||||
@@ -2047,21 +2229,20 @@ function RomImporter:_drawSaveSlotPanel(version, x, y, w, h)
|
||||
|
||||
love.graphics.setFont(self.slotNameFont)
|
||||
col(PAL.white)
|
||||
local name = slot.name or "NEW GAME"
|
||||
printB(ellipsize(self.slotNameFont, name, rw - 24 * s - pillW),
|
||||
local name = slot.name or Strings("NEW GAME")
|
||||
printB(ellipsize(self.slotNameFont, name, rw - 24 * s - math.max(pillW, rightReserve)),
|
||||
rx + 12 * s, ry + rowPadV)
|
||||
|
||||
local metaTxt
|
||||
if slot.exists and slot.meta then
|
||||
metaTxt = ("%d badges - %s - %d caught"):format(
|
||||
slot.meta.badges or 0, slot.meta.timeText or "0:00",
|
||||
metaTxt = Strings("%d badges - %s - %d caught", slot.meta.badges or 0, slot.meta.timeText or "0:00",
|
||||
slot.meta.dexCount or 0)
|
||||
else
|
||||
metaTxt = "empty slot"
|
||||
end
|
||||
love.graphics.setFont(self.labelFont)
|
||||
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)
|
||||
|
||||
-- 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] =
|
||||
{ x = rx, y = vy, width = rw, height = vy2 - vy, id = slot.id }
|
||||
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
|
||||
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
|
||||
love.graphics.setFont(self.hintFont)
|
||||
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)
|
||||
|
||||
local btnLabel = "Import mod .zip"
|
||||
@@ -2165,15 +2352,15 @@ function RomImporter:_drawModsPanel(x, y, w, h)
|
||||
|
||||
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)
|
||||
if self.modNotice then
|
||||
col(self.modNotice.ok and PAL.green or PAL.red)
|
||||
love.graphics.printf(self.modNotice.text, x, top, w, "left")
|
||||
else
|
||||
col(PAL.warning)
|
||||
love.graphics.printf(self.android and "Copy a mod .zip via USB."
|
||||
or "Or drop a mod .zip onto the window.", x, top, w, "left")
|
||||
love.graphics.printf(self.android and "Or copy a mod .zip via USB."
|
||||
or Strings("Or drop a mod .zip onto the window."), x, top, w, "left")
|
||||
end
|
||||
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)
|
||||
love.graphics.setFont(self.hintFont)
|
||||
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")
|
||||
self.modRects = {}
|
||||
self.modDeleteRects = {}
|
||||
self._modMax = 0
|
||||
return
|
||||
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 cardGap, cardR = 10 * s, 14 * s
|
||||
local tw, th = 52 * s, 28 * s
|
||||
local innerW = w - 2 * padH
|
||||
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)
|
||||
local nameH = self.stateFont:getHeight()
|
||||
@@ -2210,7 +2402,8 @@ function RomImporter:_drawModsPanel(x, y, w, h)
|
||||
for i, m in ipairs(mods) do
|
||||
local chipText = modStatusChip(m.status)
|
||||
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 descH = 0
|
||||
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 cardH = math.max(contentH, padV * 2 + clusterH)
|
||||
layout[i] = { h = cardH, leftW = leftW, clusterW = clusterW,
|
||||
chipText = chipText, chipW = chipW }
|
||||
chipText = chipText, chipW = chipW, delW = delW }
|
||||
total = total + cardH
|
||||
end
|
||||
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)
|
||||
self.modScroll = scroll
|
||||
self.modRects = {}
|
||||
self.modDeleteRects = {}
|
||||
|
||||
love.graphics.setScissor(math.floor(x), math.floor(top),
|
||||
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")
|
||||
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 clusterY = cy + (cardH - clusterH) / 2
|
||||
local _, chipColor = modStatusChip(m.status)
|
||||
@@ -2303,14 +2497,29 @@ function RomImporter:_drawModsPanel(x, y, w, h)
|
||||
col(PAL.white)
|
||||
love.graphics.circle("fill", kcx, ty + th / 2, kd / 2)
|
||||
|
||||
-- hit rect clipped to the visible list band (a partly-scrolled toggle is
|
||||
-- only clickable where it actually shows)
|
||||
-- Delete under the toggle
|
||||
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 vy2 = math.min(trect.y + trect.height, top + listH)
|
||||
if vy2 > vy then
|
||||
self.modRects[#self.modRects + 1] =
|
||||
{ x = trect.x, y = vy, width = trect.width, height = vy2 - vy, id = m.id }
|
||||
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
|
||||
cy = cy + cardH + cardGap
|
||||
end
|
||||
|
||||
@@ -47,11 +47,19 @@ local function readSource(source)
|
||||
return source
|
||||
end
|
||||
local f, openErr = io.open(source, "rb")
|
||||
if not f then return nil, "could not read the save file: " .. tostring(openErr) end
|
||||
local data = f:read("*a")
|
||||
f:close()
|
||||
if type(data) ~= "string" then return nil, "the save file was empty" end
|
||||
return data
|
||||
if f then
|
||||
local data = f:read("*a")
|
||||
f:close()
|
||||
if type(data) ~= "string" then return nil, "the save file was empty" end
|
||||
return data
|
||||
end
|
||||
-- Android SAF drops (picked_save.sav) and USB copies land in the LOVE save
|
||||
-- directory; io.open cannot see them, so fall back to love.filesystem.
|
||||
if love and love.filesystem and love.filesystem.read then
|
||||
local data = love.filesystem.read(source)
|
||||
if type(data) == "string" then return data end
|
||||
end
|
||||
return nil, "could not read the save file: " .. tostring(openErr)
|
||||
end
|
||||
|
||||
-- importToSlot(source, version) -> ok, slotIdOrErr
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
|
||||
local Pokemon = require("src.pokemon.Pokemon")
|
||||
local Flags = require("src.script.Flags")
|
||||
local Strings = require("src.core.Strings")
|
||||
|
||||
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"
|
||||
or itemId == "RARE_CANDY" or itemId == "COIN_CASE"
|
||||
or (itemDef and itemDef.machine)) 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
|
||||
|
||||
if BALLS[itemId] then
|
||||
@@ -138,11 +139,12 @@ function ItemEffects.use(data, save, itemId, target, battle, moveIndex, ow)
|
||||
local mapId, npc = adjacentSleepingSnorlax(save, ow)
|
||||
if npc then
|
||||
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
|
||||
-- otherwise: play the tune, nothing happens (ItemUsePokeFlute's
|
||||
-- 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
|
||||
local woke = false
|
||||
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
|
||||
for _, mon in ipairs(battle.enemyParty or {}) do wake(mon) end
|
||||
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
|
||||
return "flute", { ("%s played the\nPOKé FLUTE."):format(save.player.name),
|
||||
"All sleeping\nPOKéMON woke up!" }
|
||||
return "flute", { Strings("%s played the\nPOKé FLUTE.", save.player.name),
|
||||
Strings("All sleeping\nPOKéMON woke up!") }
|
||||
end
|
||||
|
||||
-- battle-only items
|
||||
if X_ITEMS[itemId] or itemId == "DIRE_HIT" or itemId == "GUARD_SPEC"
|
||||
or itemId == "POKE_DOLL" 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
|
||||
local b = battle.player
|
||||
if itemId == "X_ACCURACY" then
|
||||
-- ItemUseXAccuracy sets USING_X_ACCURACY: moves never miss
|
||||
-- (not an accuracy stage)
|
||||
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
|
||||
if X_ITEMS[itemId] then
|
||||
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
|
||||
-- just prints "Nothing happened!"
|
||||
if cur >= 6 then
|
||||
return "consumed", { "Nothing happened!" }
|
||||
return "consumed", { Strings("Nothing happened!") }
|
||||
end
|
||||
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
|
||||
-- ItemUseDireHit/ItemUseGuardSpec always set the bit and consume
|
||||
-- the item, even when it is already active
|
||||
if itemId == "DIRE_HIT" then
|
||||
b.focusEnergy = true
|
||||
return "consumed", { ("%s's\ngetting pumped!"):format(b.name) }
|
||||
return "consumed", { Strings("%s's\ngetting pumped!", b.name) }
|
||||
end
|
||||
if itemId == "GUARD_SPEC" then
|
||||
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
|
||||
if itemId == "POKE_DOLL" then
|
||||
if battle.kind ~= "wild" then
|
||||
-- ItemUsePokeDoll jumps to ItemUseNotTime in trainer battles
|
||||
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
|
||||
return "consumed_escape", { "The wild POKéMON\nran away!" }
|
||||
return "consumed_escape", { Strings("The wild POKéMON\nran away!") }
|
||||
end
|
||||
end
|
||||
|
||||
@@ -213,7 +215,7 @@ function ItemEffects.use(data, save, itemId, target, battle, moveIndex, ow)
|
||||
-- restore every move with no menu.
|
||||
if itemId == "ETHER" or itemId == "MAX_ETHER"
|
||||
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 full = itemId == "MAX_ETHER" 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
|
||||
end
|
||||
if not restored then
|
||||
return "failed", { "It won't have\nany effect." }
|
||||
return "failed", { Strings("It won't have\nany effect.") }
|
||||
end
|
||||
return "consumed", { ("%s's PP\nwas restored!"):format(monName(data, target)) }
|
||||
return "consumed", { Strings("%s's PP\nwas restored!", monName(data, target)) }
|
||||
end
|
||||
|
||||
local heal = HEAL_AMOUNT[itemId]
|
||||
@@ -250,17 +252,17 @@ function ItemEffects.use(data, save, itemId, target, battle, moveIndex, ow)
|
||||
target.status = nil
|
||||
cureActiveToxic(battle, target)
|
||||
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
|
||||
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
|
||||
if itemId == "MAX_POTION" or itemId == "FULL_RESTORE" then
|
||||
target.hp = target.stats.hp
|
||||
else
|
||||
target.hp = math.min(target.stats.hp, target.hp + heal)
|
||||
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
|
||||
target.status = nil
|
||||
cureActiveToxic(battle, target)
|
||||
@@ -272,27 +274,27 @@ function ItemEffects.use(data, save, itemId, target, battle, moveIndex, ow)
|
||||
local cures = STATUS_HEAL[itemId]
|
||||
if cures 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
|
||||
target.status = nil
|
||||
cureActiveToxic(battle, target)
|
||||
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
|
||||
|
||||
if itemId == "REVIVE" or itemId == "MAX_REVIVE" 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
|
||||
target.status = nil
|
||||
target.hp = itemId == "REVIVE" and math.floor(target.stats.hp / 2) or target.stats.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
|
||||
|
||||
if itemId == "RARE_CANDY" 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
|
||||
local Growth = require("src.pokemon.Growth")
|
||||
local Stats = require("src.pokemon.Stats")
|
||||
@@ -302,56 +304,56 @@ function ItemEffects.use(data, save, itemId, target, battle, moveIndex, ow)
|
||||
local old = target.stats
|
||||
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))
|
||||
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 }
|
||||
end
|
||||
|
||||
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]
|
||||
for _, evo in ipairs(speciesDef.evolutions) do
|
||||
if evo.method == "ITEM" and evo.item == itemId then
|
||||
return "consumed", nil, { evolveTo = evo.species }
|
||||
end
|
||||
end
|
||||
return "failed", { "It won't have\nany effect." }
|
||||
return "failed", { Strings("It won't have\nany effect.") }
|
||||
end
|
||||
|
||||
-- vitamins: +2560 stat exp, refused at 25600+ (ItemUseVitamin,
|
||||
-- engine/items/item_effects.asm)
|
||||
local vitaminStat = VITAMINS[itemId]
|
||||
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 {}
|
||||
local cur = target.statExp[vitaminStat] or 0
|
||||
if cur >= 25600 then
|
||||
return "failed", { "It won't have\nany effect." }
|
||||
return "failed", { Strings("It won't have\nany effect.") }
|
||||
end
|
||||
target.statExp[vitaminStat] = math.min(65535, cur + 2560)
|
||||
local Stats = require("src.pokemon.Stats")
|
||||
target.stats = Stats.calc(data.pokemon[target.species], target.level,
|
||||
target.dvs, target.statExp)
|
||||
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()) }
|
||||
end
|
||||
|
||||
-- PP UP boosts the move the player picked (ItemUsePPUp's move menu)
|
||||
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 mdef = mv and data.moves[mv.id]
|
||||
if mdef and (mv.ppUps or 0) < 3 then
|
||||
mv.ppUps = (mv.ppUps or 0) + 1
|
||||
-- each PP UP adds maxPP/5 uses on top of the base maximum
|
||||
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
|
||||
return "failed", { "It won't have\nany effect." }
|
||||
return "failed", { Strings("It won't have\nany effect.") }
|
||||
end
|
||||
|
||||
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 ok = false
|
||||
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
|
||||
-- ItemUseNotTime/NoCyclingAllowedHere paths are silent)
|
||||
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
|
||||
for _, mv in ipairs(target.moves) do
|
||||
if mv.id == itemDef.machine.move then
|
||||
return "failed", { "It knows that\nmove already!" }
|
||||
return "failed", { Strings("It knows that\nmove already!") }
|
||||
end
|
||||
end
|
||||
-- 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 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
|
||||
return "fish", itemId
|
||||
end
|
||||
|
||||
if itemId == "BICYCLE" 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
|
||||
return "bicycle"
|
||||
end
|
||||
@@ -392,26 +394,26 @@ function ItemEffects.use(data, save, itemId, target, battle, moveIndex, ow)
|
||||
end
|
||||
if itemId == "TOWN_MAP" 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
|
||||
return "townmap"
|
||||
end
|
||||
if itemId == "ITEMFINDER" 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
|
||||
return "itemfinder"
|
||||
end
|
||||
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
|
||||
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
|
||||
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
|
||||
|
||||
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
|
||||
|
||||
return ItemEffects
|
||||
|
||||
+25
-24
@@ -21,6 +21,7 @@ local Logger = require("src.core.Logger")
|
||||
local Protocol = require("src.link.Protocol")
|
||||
local Runtime = require("src.mods.Runtime")
|
||||
local TurnOrder = require("src.battle.TurnOrder")
|
||||
local Strings = require("src.core.Strings")
|
||||
|
||||
local LinkBattle = {}
|
||||
|
||||
@@ -177,18 +178,18 @@ function LinkBattle.new(game, net, opts)
|
||||
local theirName = opts.theirName or "FOE"
|
||||
|
||||
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
|
||||
|
||||
-- both parties pass through the same pack->unpack clamp on both
|
||||
-- machines, so the copies are identical everywhere
|
||||
local unpackOpts = { strict = opts.strict or false, forceLevel = opts.forceLevel }
|
||||
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)
|
||||
if not myParty then return nil, myErr end
|
||||
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)
|
||||
if not theirParty then return nil, theirErr end
|
||||
if #myParty == 0 or #theirParty == 0 then
|
||||
@@ -232,7 +233,7 @@ function LinkBattle.new(game, net, opts)
|
||||
self.enemyParty = theirParty
|
||||
self.playerParty = myParty -- intro ball row uses the clamped copies
|
||||
self.opponentName = theirName
|
||||
self.introText = ("%s wants\nto battle!"):format(theirName)
|
||||
self.introText = Strings("%s wants\nto battle!", theirName)
|
||||
self.remoteHashes = {}
|
||||
self.localHashes = {}
|
||||
self.remoteParts = {}
|
||||
@@ -292,7 +293,7 @@ function LinkBattle.new(game, net, opts)
|
||||
battle = s, side = s.sides[2], battler = s.enemy, previous = previous,
|
||||
})
|
||||
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.enemySendingOut = false
|
||||
s:startGrowIn(s.enemy)
|
||||
@@ -315,8 +316,9 @@ function LinkBattle.new(game, net, opts)
|
||||
tostring(turn), component, tostring(localH), tostring(remoteH))
|
||||
Runtime.emit("link.desync", { turn = turn, component = component,
|
||||
localHash = localH, remoteHash = remoteH })
|
||||
endAsDraw(s, ("Link desync!\n%s differs.\fAre both games\nrunning the same\nmods?")
|
||||
:format(component))
|
||||
endAsDraw(s, Strings(
|
||||
"Link desync!\n%s differs.\fAre both games\nrunning the same\nmods?",
|
||||
component))
|
||||
end
|
||||
|
||||
-- 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)
|
||||
if myMsg.kind == "run" or theirMsg.kind == "run" then
|
||||
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
|
||||
end
|
||||
s.phase = "messages"
|
||||
@@ -479,9 +481,9 @@ function LinkBattle.new(game, net, opts)
|
||||
party = myParty,
|
||||
onSwitch = function(mon)
|
||||
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
|
||||
s:say("There's no will\nto fight!")
|
||||
s:say(Strings("There's no will\nto fight!"))
|
||||
else
|
||||
s:resolveSwitch(mon)
|
||||
end
|
||||
@@ -491,7 +493,7 @@ function LinkBattle.new(game, net, opts)
|
||||
end
|
||||
|
||||
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.afterQueue = "menu"
|
||||
end
|
||||
@@ -509,7 +511,7 @@ function LinkBattle.new(game, net, opts)
|
||||
return
|
||||
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))
|
||||
s.result = "lose"
|
||||
s.afterQueue = "finish"
|
||||
@@ -522,7 +524,7 @@ function LinkBattle.new(game, net, opts)
|
||||
return
|
||||
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))
|
||||
s.result = "win"
|
||||
s.afterQueue = "finish"
|
||||
@@ -543,13 +545,13 @@ function LinkBattle.new(game, net, opts)
|
||||
-- only a draw if our own simulation hasn't already decided
|
||||
-- (the winner's bye can arrive while we're still animating)
|
||||
if not s.result then
|
||||
endAsDraw(s, ("%s left the\nbattle."):format(theirName))
|
||||
endAsDraw(s, Strings("%s left the\nbattle.", theirName))
|
||||
end
|
||||
elseif msg.type == "forfeit" then
|
||||
-- the peer's own shot clock ran out; unlike a mutual RUN/desync
|
||||
-- draw, this has a definite winner (us)
|
||||
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
|
||||
else
|
||||
-- a tournament control message (bracket_update, the next
|
||||
@@ -582,7 +584,7 @@ function LinkBattle.new(game, net, opts)
|
||||
if s.turnClock <= 0 then
|
||||
s.turnClockActive = false
|
||||
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
|
||||
elseif opts.turnLimit then
|
||||
s.turnClockActive = false
|
||||
@@ -643,17 +645,16 @@ function LinkBattle.newSpectator(game, net, opts)
|
||||
local guestName = opts.guestName or "GUEST"
|
||||
|
||||
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
|
||||
|
||||
local unpackOpts = { strict = opts.strict or false, forceLevel = opts.forceLevel }
|
||||
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)
|
||||
if not hostParty then return nil, hostErr end
|
||||
local guestParty, guestErr = unpackParty(game, opts.guestParty, unpackOpts, function(p, why)
|
||||
return ("%s's %s can't\nbattle on this\ngame.\n(%s)"):format(
|
||||
guestName, tostring(p.species), tostring(why))
|
||||
return Strings("%s's %s can't\nbattle on this\ngame.\n(%s)", guestName, tostring(p.species), tostring(why))
|
||||
end)
|
||||
if not guestParty then return nil, guestErr end
|
||||
if #hostParty == 0 or #guestParty == 0 then
|
||||
@@ -672,7 +673,7 @@ function LinkBattle.newSpectator(game, net, opts)
|
||||
self.enemyParty = guestParty
|
||||
self.playerParty = hostParty
|
||||
self.opponentName = guestName
|
||||
self.introText = ("%s vs %s!"):format(hostName, guestName)
|
||||
self.introText = Strings("%s vs %s!", hostName, guestName)
|
||||
|
||||
local function orderMove(action)
|
||||
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,
|
||||
})
|
||||
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.enemySendingOut = false
|
||||
s:startGrowIn(s.enemy)
|
||||
@@ -789,7 +790,7 @@ function LinkBattle.newSpectator(game, net, opts)
|
||||
return
|
||||
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.afterQueue = "finish"
|
||||
end
|
||||
@@ -801,7 +802,7 @@ function LinkBattle.newSpectator(game, net, opts)
|
||||
return
|
||||
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.afterQueue = "finish"
|
||||
end
|
||||
|
||||
+46
-45
@@ -11,6 +11,7 @@ local Protocol = require("src.link.Protocol")
|
||||
local Runtime = require("src.mods.Runtime")
|
||||
local Screens = require("src.ui.Screens")
|
||||
local TextBox = require("src.render.TextBox")
|
||||
local Strings = require("src.core.Strings")
|
||||
|
||||
local LinkState = {}
|
||||
LinkState.__index = LinkState
|
||||
@@ -90,7 +91,7 @@ function LinkState.newJoinOnline(game, code)
|
||||
self.stage = "onlineJoining"
|
||||
else
|
||||
self.stage = "menu" -- exitWith below needs a real stage to unwind from
|
||||
self:exitWith("Link error:\n" .. (self.net.error or "?"))
|
||||
self:exitWith(Strings("Link error:\n%s", self.net.error or "?"))
|
||||
end
|
||||
return self
|
||||
end
|
||||
@@ -174,7 +175,7 @@ function LinkState:update(dt)
|
||||
if self.net then
|
||||
self.net:update()
|
||||
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
|
||||
end
|
||||
-- the peer vanished without a bye (only once the inbox is drained,
|
||||
@@ -183,7 +184,7 @@ function LinkState:update(dt)
|
||||
and not PRE_CONNECT_STAGES[self.stage] and self.stage ~= "addrEntry"
|
||||
and self.stage ~= "codeEntry" and self.stage ~= "notice"
|
||||
and self.stage ~= "battleRunning" then
|
||||
self:exitWith("The link was\nbroken.")
|
||||
self:exitWith(Strings("The link was\nbroken."))
|
||||
return
|
||||
end
|
||||
end
|
||||
@@ -201,7 +202,7 @@ function LinkState:update(dt)
|
||||
self.index = 1
|
||||
elseif self.index == 2 or self.index == 3 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
|
||||
end
|
||||
if self.index == 2 then
|
||||
@@ -227,7 +228,7 @@ function LinkState:update(dt)
|
||||
if self.net:host() then
|
||||
self.stage = "hosting"
|
||||
else
|
||||
self:exitWith("Link error:\n" .. (self.net.error or "?"))
|
||||
self:exitWith(Strings("Link error:\n%s", self.net.error or "?"))
|
||||
end
|
||||
else
|
||||
self.stage = "addrEntry"
|
||||
@@ -246,7 +247,7 @@ function LinkState:update(dt)
|
||||
if self.net:hostOnline() then
|
||||
self.stage = "onlineHosting"
|
||||
else
|
||||
self:exitWith("Link error:\n" .. (self.net.error or "?"))
|
||||
self:exitWith(Strings("Link error:\n%s", self.net.error or "?"))
|
||||
end
|
||||
else
|
||||
self.stage = "codeEntry"
|
||||
@@ -284,7 +285,7 @@ function LinkState:update(dt)
|
||||
if self.net:joinOnline(nil, code) then
|
||||
self.stage = "onlineJoining"
|
||||
else
|
||||
self:exitWith("Link error:\n" .. (self.net.error or "?"))
|
||||
self:exitWith(Strings("Link error:\n%s", self.net.error or "?"))
|
||||
end
|
||||
end
|
||||
|
||||
@@ -323,7 +324,7 @@ function LinkState:update(dt)
|
||||
if self.net:join(table.concat(octets, ".")) then
|
||||
self.stage = "joining"
|
||||
else
|
||||
self:exitWith("Link error:\n" .. (self.net.error or "?"))
|
||||
self:exitWith(Strings("Link error:\n%s", self.net.error or "?"))
|
||||
end
|
||||
end
|
||||
|
||||
@@ -433,7 +434,7 @@ function LinkState:update(dt)
|
||||
end
|
||||
if not battle then
|
||||
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
|
||||
end
|
||||
self.game.stack:push(battle)
|
||||
@@ -491,8 +492,8 @@ function LinkState:updateTrade(input)
|
||||
local t = self.trade
|
||||
|
||||
if t.stage == "cancelled" then
|
||||
self:exitWith(t.error and ("The trade stopped:\n%s."):format(t.error)
|
||||
or "The trade was\ncancelled.")
|
||||
self:exitWith(t.error and Strings("The trade stopped:\n%s.", t.error)
|
||||
or Strings("The trade was\ncancelled."))
|
||||
return
|
||||
end
|
||||
if t.stage == "done" then
|
||||
@@ -520,7 +521,7 @@ function LinkState:updateTrade(input)
|
||||
enemyOtId = received.otId,
|
||||
onDone = function()
|
||||
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()
|
||||
if evoTo then
|
||||
-- via="TRADE": a trade evolution cannot be B-cancelled
|
||||
@@ -547,7 +548,7 @@ function LinkState:updateTrade(input)
|
||||
-- the trade) -- B is dead after that, matching the A branch's own
|
||||
-- self.confirmed == nil guard
|
||||
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
|
||||
if t:canPick(self.index) then
|
||||
self.net:send(t:pick(self.index))
|
||||
@@ -574,30 +575,30 @@ end
|
||||
function LinkState:draw()
|
||||
if self.stage == "menu" then
|
||||
drawTitle("BOIS CLUB LIVE")
|
||||
Font.draw("LINK CABLE (LAN)", 32, 44)
|
||||
Font.draw("ONLINE MATCH", 32, 60)
|
||||
Font.draw("TOURNAMENT", 32, 76)
|
||||
Font.draw(Strings("LINK CABLE (LAN)"), 32, 44)
|
||||
Font.draw(Strings("ONLINE MATCH"), 32, 60)
|
||||
Font.draw(Strings("TOURNAMENT"), 32, 76)
|
||||
Font.drawCode(CURSOR, 24, 44 + (self.index - 1) * 16)
|
||||
|
||||
elseif self.stage == "lanMenu" then
|
||||
drawTitle("LINK CABLE (LAN)")
|
||||
Font.draw("HOST A GAME", 32, 48)
|
||||
Font.draw("JOIN A GAME", 32, 68)
|
||||
Font.draw(Strings("HOST A GAME"), 32, 48)
|
||||
Font.draw(Strings("JOIN A GAME"), 32, 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
|
||||
drawTitle("ONLINE MATCH")
|
||||
Font.draw("HOST ONLINE", 32, 48)
|
||||
Font.draw("JOIN ONLINE", 32, 68)
|
||||
Font.draw(Strings("HOST ONLINE"), 32, 48)
|
||||
Font.draw(Strings("JOIN ONLINE"), 32, 68)
|
||||
Font.drawCode(CURSOR, 24, self.index == 1 and 48 or 68)
|
||||
|
||||
elseif self.stage == "onlineHosting" then
|
||||
drawTitle("HOSTING ONLINE")
|
||||
Font.draw("Tell your friend", 16, 40)
|
||||
Font.draw("the code:", 16, 52)
|
||||
Font.draw(Strings("Tell your friend"), 16, 40)
|
||||
Font.draw(Strings("the code:"), 16, 52)
|
||||
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
|
||||
drawTitle("ENTER CODE")
|
||||
@@ -609,18 +610,18 @@ function LinkState:draw()
|
||||
Font.drawCode(0xEE, x, 76) -- ▼ under the active slot
|
||||
end
|
||||
end
|
||||
Font.draw("A: connect B: back", 8, 128)
|
||||
Font.draw(Strings("A: connect B: back"), 8, 128)
|
||||
|
||||
elseif self.stage == "onlineJoining" then
|
||||
drawTitle("CONNECTING...")
|
||||
Font.draw("Calling...", 8, 56)
|
||||
Font.draw(Strings("Calling..."), 8, 56)
|
||||
Font.draw(self.net.target or "", 8, 72)
|
||||
|
||||
elseif self.stage == "hosting" then
|
||||
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("Waiting for join...", 8, 96)
|
||||
Font.draw(Strings("Waiting for join..."), 8, 96)
|
||||
|
||||
elseif self.stage == "addrEntry" then
|
||||
drawTitle("ENTER HOST ADDRESS")
|
||||
@@ -635,34 +636,34 @@ function LinkState:draw()
|
||||
for octet = 1, 3 do
|
||||
Font.draw(".", 16 + octet * 32 - 8, 64)
|
||||
end
|
||||
Font.draw("Port: " .. Net.defaultPort(), 16, 96)
|
||||
Font.draw("A: connect B: back", 8, 128)
|
||||
Font.draw(Strings("Port: %s", Net.defaultPort()), 16, 96)
|
||||
Font.draw(Strings("A: connect B: back"), 8, 128)
|
||||
|
||||
elseif self.stage == "joining" then
|
||||
drawTitle("JOINING...")
|
||||
Font.draw("Calling...", 8, 56)
|
||||
Font.draw(Strings("Calling..."), 8, 56)
|
||||
Font.draw(self.net.target or "", 8, 72)
|
||||
|
||||
elseif self.stage == "modeSelect" then
|
||||
drawTitle("CONNECTED!")
|
||||
Font.draw("TRADE", 32, 48)
|
||||
Font.draw("BATTLE", 32, 68)
|
||||
Font.draw(Strings("TRADE"), 32, 48)
|
||||
Font.draw(Strings("BATTLE"), 32, 68)
|
||||
Font.drawCode(CURSOR, 24, self.index == 1 and 48 or 68)
|
||||
|
||||
elseif self.stage == "battleOptions" then
|
||||
drawTitle("BATTLE OPTIONS")
|
||||
Font.draw("LEVELS:", 16, 56)
|
||||
Font.draw(Strings("LEVELS:"), 16, 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
|
||||
drawTitle("CONNECTED!")
|
||||
if self.stage == "waitHello" then
|
||||
Font.draw("Checking the", 16, 56)
|
||||
Font.draw("other game...", 16, 72)
|
||||
Font.draw(Strings("Checking the"), 16, 56)
|
||||
Font.draw(Strings("other game..."), 16, 72)
|
||||
else
|
||||
Font.draw("Waiting for the", 16, 56)
|
||||
Font.draw("host to choose...", 16, 72)
|
||||
Font.draw(Strings("Waiting for the"), 16, 56)
|
||||
Font.draw(Strings("host to choose..."), 16, 72)
|
||||
end
|
||||
|
||||
elseif self.stage == "notice" then
|
||||
@@ -671,12 +672,12 @@ function LinkState:draw()
|
||||
if i > 8 then break end -- what fits above the prompt row
|
||||
Font.draw(line, 8, 24 + (i - 1) * 12)
|
||||
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
|
||||
drawTitle("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
|
||||
local def = self.game.data.pokemon[mon.species]
|
||||
local label = (mon.nickname or def.name):sub(1, 8)
|
||||
@@ -684,7 +685,7 @@ function LinkState:draw()
|
||||
Font.draw(label, 16, 20 + i * 12)
|
||||
if i == self.index then Font.drawCode(CURSOR, 8, 20 + i * 12) end
|
||||
end
|
||||
Font.draw("THEIRS", 84, 20)
|
||||
Font.draw(Strings("THEIRS"), 84, 20)
|
||||
for i, mon in ipairs(t.theirParty or {}) do
|
||||
local def = self.game.data.pokemon[mon.species]
|
||||
Font.draw((mon.nickname or def.name):sub(1, 8), 92, 20 + i * 12)
|
||||
@@ -695,16 +696,16 @@ function LinkState:draw()
|
||||
elseif t.stage == "waitParty" then hint = "Exchanging data..."
|
||||
elseif t.stage == "picking" then
|
||||
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 == "confirming" then
|
||||
hint = self.confirmed and "Waiting..." or "A: trade B: cancel"
|
||||
hint = self.confirmed and "Waiting..." or Strings("A: trade B: cancel")
|
||||
end
|
||||
Font.draw(hint or "", 8, 132)
|
||||
|
||||
elseif self.stage == "battleWait" or self.stage == "battleRunning" then
|
||||
drawTitle("LINK BATTLE")
|
||||
Font.draw("Exchanging data...", 16, 64)
|
||||
Font.draw(Strings("Exchanging data..."), 16, 64)
|
||||
end
|
||||
love.graphics.setColor(1, 1, 1, 1)
|
||||
end
|
||||
|
||||
+13
-9
@@ -26,6 +26,7 @@
|
||||
|
||||
local Json = require("src.link.Json")
|
||||
local Logger = require("src.core.Logger")
|
||||
local Strings = require("src.core.Strings")
|
||||
|
||||
local hasEnet, enet = pcall(require, "enet")
|
||||
if not hasEnet then enet = nil end
|
||||
@@ -173,7 +174,7 @@ function Net:connectTCP(addr)
|
||||
tcp:settimeout(5)
|
||||
local ok, err = tcp:connect(host, port)
|
||||
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
|
||||
end
|
||||
tcp:settimeout(0)
|
||||
@@ -236,11 +237,14 @@ local function handleGenericRelayControl(self, msg)
|
||||
self.paired = true
|
||||
return true
|
||||
elseif msg.type == "join_error" then
|
||||
self.error = ({
|
||||
not_found = "That code wasn't\nfound.",
|
||||
full = "That game already\nhas two players.",
|
||||
expired = "That code has\nexpired.",
|
||||
})[msg.reason] or ("Couldn't join:\n%s"):format(tostring(msg.reason))
|
||||
self.error = Strings(({
|
||||
not_found = Strings.source("That code wasn't\nfound."),
|
||||
full = Strings.source("That game already\nhas two players."),
|
||||
expired = Strings.source("That code has\nexpired."),
|
||||
})[msg.reason] or "")
|
||||
if self.error == "" then
|
||||
self.error = Strings("Couldn't join:\n%s", tostring(msg.reason))
|
||||
end
|
||||
self.closed = true
|
||||
return true
|
||||
elseif msg.type == "peer_gone" then
|
||||
@@ -328,7 +332,7 @@ function Net:update()
|
||||
-- an unreachable join target surfaces as a service error
|
||||
-- (ICMP unreachable on the connected UDP socket)
|
||||
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
|
||||
self.error = tostring(event)
|
||||
end
|
||||
@@ -358,14 +362,14 @@ function Net:update()
|
||||
self.closed = true
|
||||
if not self.paired then
|
||||
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
|
||||
if self.mode == "joining" and not self.paired
|
||||
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
|
||||
pcall(function() self.peer:disconnect_now() end)
|
||||
end
|
||||
|
||||
+35
-35
@@ -18,6 +18,7 @@ local Protocol = require("src.link.Protocol")
|
||||
local Runtime = require("src.mods.Runtime")
|
||||
local Sound = require("src.core.Sound")
|
||||
local TextBox = require("src.render.TextBox")
|
||||
local Strings = require("src.core.Strings")
|
||||
|
||||
local Tournament = {}
|
||||
Tournament.__index = Tournament
|
||||
@@ -128,7 +129,7 @@ end
|
||||
function Tournament:startHosting()
|
||||
self.net = Net.new()
|
||||
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
|
||||
end
|
||||
local size, minL, maxL = partyStats(self.game.save.party)
|
||||
@@ -151,7 +152,7 @@ end
|
||||
function Tournament:startJoining(code)
|
||||
self.net = Net.new()
|
||||
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
|
||||
end
|
||||
local size, minL, maxL = partyStats(self.game.save.party)
|
||||
@@ -171,9 +172,9 @@ end
|
||||
-- -------------------------------------------------------------------
|
||||
|
||||
local JOIN_ERROR_TEXT = {
|
||||
not_found = "That code wasn't\nfound.",
|
||||
already_started = "That tournament\nhas already begun.",
|
||||
expired = "That code has\nexpired.",
|
||||
not_found = Strings.source("That code wasn't\nfound."),
|
||||
already_started = Strings.source("That tournament\nhas already begun."),
|
||||
expired = Strings.source("That code has\nexpired."),
|
||||
}
|
||||
|
||||
function Tournament:handleMessage(msg)
|
||||
@@ -198,17 +199,17 @@ function Tournament:handleMessage(msg)
|
||||
end
|
||||
elseif msg.type == "tournament_host_error" then
|
||||
if msg.reason == "party_ineligible" then
|
||||
self:exitWith(("Can't host:\nneed %d Pokemon\nLv %s-%s."):format(
|
||||
msg.requiredPartySize, levelLabel(msg.minLevel), levelLabel(msg.maxLevel)))
|
||||
self:exitWith(Strings("Can't host:\nneed %d Pokemon\nLv %s-%s.", msg.requiredPartySize, levelLabel(msg.minLevel), levelLabel(msg.maxLevel)))
|
||||
else
|
||||
self:exitWith("Couldn't host\nthat tournament.")
|
||||
self:exitWith(Strings("Couldn't host\nthat tournament."))
|
||||
end
|
||||
elseif msg.type == "tournament_join_error" then
|
||||
if msg.reason == "party_ineligible" then
|
||||
self:exitWith(("Your party needs\n%d Pokemon, Lv\n%s-%s."):format(
|
||||
msg.requiredPartySize, levelLabel(msg.minLevel), levelLabel(msg.maxLevel)))
|
||||
self:exitWith(Strings("Your party needs\n%d Pokemon, Lv\n%s-%s.", msg.requiredPartySize, levelLabel(msg.minLevel), levelLabel(msg.maxLevel)))
|
||||
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
|
||||
elseif msg.type == "tournament_roster" then
|
||||
self.roster = msg.players
|
||||
@@ -281,7 +282,7 @@ function Tournament:beginMatchBattle()
|
||||
-- shouldn't happen (both sides already passed the online-play mods
|
||||
-- gate), but a mismatched engine/build is still possible -- bail out
|
||||
-- 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
|
||||
end
|
||||
self.linkSeed = self.isHost and love.math.random(1, 2 ^ 30) or nil
|
||||
@@ -351,11 +352,11 @@ function Tournament:update(dt)
|
||||
self.net:update()
|
||||
if self.net.error and self.stage ~= "menu" and self.stage ~= "hostSettings"
|
||||
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
|
||||
end
|
||||
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
|
||||
end
|
||||
end
|
||||
@@ -381,7 +382,7 @@ function Tournament:update(dt)
|
||||
and LinkBattle.newHost(self.game, self.net, self.pendingBattleOpts)
|
||||
or LinkBattle.newGuest(self.game, self.net, self.pendingBattleOpts)
|
||||
if not battle then
|
||||
self:exitWith(why or "Link battle\ncan't start.")
|
||||
self:exitWith(why or Strings("Link battle\ncan't start."))
|
||||
return
|
||||
end
|
||||
-- anything after `party` in this same batch belongs to the
|
||||
@@ -418,7 +419,7 @@ function Tournament:update(dt)
|
||||
forceLevel = levelForWire(self.settings.forceLevel),
|
||||
})
|
||||
if not battle then
|
||||
self:exitWith(why or "Can't watch this\nmatch.")
|
||||
self:exitWith(why or Strings("Can't watch this\nmatch."))
|
||||
return
|
||||
end
|
||||
for j = #msgs, i + 1, -1 do
|
||||
@@ -541,8 +542,8 @@ local SETTINGS_LABELS = { "POKEMON", "MIN LV", "MAX LV", "TIMER", "LEVELS", "PLA
|
||||
function Tournament:draw()
|
||||
if self.stage == "menu" then
|
||||
drawTitle("TOURNAMENT")
|
||||
Font.draw("HOST", 32, 48)
|
||||
Font.draw("JOIN", 32, 68)
|
||||
Font.draw(Strings("HOST"), 32, 48)
|
||||
Font.draw(Strings("JOIN"), 32, 68)
|
||||
Font.drawCode(CURSOR, 24, self.index == 1 and 48 or 68)
|
||||
|
||||
elseif self.stage == "hostSettings" then
|
||||
@@ -561,7 +562,7 @@ function Tournament:draw()
|
||||
Font.draw(values[i], 96, y)
|
||||
if i == self.settingsIndex then Font.drawCode(CURSOR, 8, y) end
|
||||
end
|
||||
Font.draw("START: create", 8, 128)
|
||||
Font.draw(Strings("START: create"), 8, 128)
|
||||
|
||||
elseif self.stage == "codeEntry" then
|
||||
drawTitle("ENTER CODE")
|
||||
@@ -573,28 +574,27 @@ function Tournament:draw()
|
||||
Font.drawCode(0xEE, x, 76)
|
||||
end
|
||||
end
|
||||
Font.draw("A: join B: back", 8, 128)
|
||||
Font.draw(Strings("A: join B: back"), 8, 128)
|
||||
|
||||
elseif self.stage == "registering" then
|
||||
drawTitle("CONNECTING...")
|
||||
Font.draw("B: cancel", 8, 128)
|
||||
Font.draw(Strings("B: cancel"), 8, 128)
|
||||
|
||||
elseif self.stage == "bracket" or self.stage == "matchHello"
|
||||
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
|
||||
local y = 20
|
||||
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
|
||||
for _, m in ipairs(round.matches) do
|
||||
local line
|
||||
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
|
||||
local mark = m.state == "live" and "*" or (m.winner and "" or "")
|
||||
line = ("%s%s vs %s%s"):format(
|
||||
m.winner == m.a and ">" or " ", m.a or "?",
|
||||
line = Strings("%s%s vs %s%s", m.winner == m.a and ">" or " ", m.a or "?",
|
||||
m.b or "?", m.winner == m.b and "<" or (mark == "*" and " *" or ""))
|
||||
end
|
||||
Font.draw(line, 12, y)
|
||||
@@ -604,11 +604,11 @@ function Tournament:draw()
|
||||
end
|
||||
else
|
||||
if self.participating == false then
|
||||
Font.draw("(organizing --", 16, 32)
|
||||
Font.draw("not playing)", 16, 42)
|
||||
Font.draw(Strings("(organizing --"), 16, 32)
|
||||
Font.draw(Strings("not playing)"), 16, 42)
|
||||
end
|
||||
Font.draw("Waiting for", 16, 48)
|
||||
Font.draw("players to join:", 16, 60)
|
||||
Font.draw(Strings("Waiting for"), 16, 48)
|
||||
Font.draw(Strings("players to join:"), 16, 60)
|
||||
local y = 60
|
||||
for i, name in ipairs(self.roster) do
|
||||
y = 60 + i * 10
|
||||
@@ -620,18 +620,18 @@ function Tournament:draw()
|
||||
end
|
||||
end
|
||||
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
|
||||
Font.draw("B: cancel", 8, 132)
|
||||
Font.draw(Strings("B: cancel"), 8, 132)
|
||||
end
|
||||
|
||||
elseif self.stage == "done" then
|
||||
drawTitle("TOURNAMENT OVER")
|
||||
if self.champion then
|
||||
Font.draw(("%s is the"):format(self.champion), 16, 56)
|
||||
Font.draw("champion!", 16, 68)
|
||||
Font.draw(Strings("%s is the", self.champion), 16, 56)
|
||||
Font.draw(Strings("champion!"), 16, 68)
|
||||
end
|
||||
Font.draw("A: continue", 8, 128)
|
||||
Font.draw(Strings("A: continue"), 8, 128)
|
||||
end
|
||||
love.graphics.setColor(1, 1, 1, 1)
|
||||
end
|
||||
|
||||
@@ -3,11 +3,12 @@
|
||||
-- manifests only. The full loader (src/mods/Loader.lua) still owns the real
|
||||
-- load at boot; this reads the same options.mods enable-state the loader
|
||||
-- writes, derives per-mod status with the pure ManagerState.resolveToggle,
|
||||
-- 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
|
||||
-- no filesystem, so the engine tier can table-drive it; the discovery and
|
||||
-- install paths reach for love.filesystem and SaveData.
|
||||
-- no filesystem, so the engine tier can table-drive it; the discovery,
|
||||
-- install, and uninstall paths reach for love.filesystem and SaveData.
|
||||
|
||||
local Manifest = require("src.mods.Manifest")
|
||||
local ManagerState = require("src.mods.ManagerState")
|
||||
@@ -332,4 +333,34 @@ function LauncherMods.installZip(source)
|
||||
return true, manifest.id
|
||||
end
|
||||
|
||||
-- uninstall(id) -> true | nil, errString
|
||||
-- Removes mods/<id>/ from the save directory and clears options.mods[id] so the
|
||||
-- loader and in-game manager no longer see it. Rejects unknown / missing ids.
|
||||
-- Does not touch other mods' enable state.
|
||||
function LauncherMods.uninstall(id)
|
||||
if type(id) ~= "string" or id == "" then
|
||||
return nil, "missing mod id"
|
||||
end
|
||||
if id:find("[/\\]") or id == "." or id == ".." then
|
||||
return nil, "invalid mod id"
|
||||
end
|
||||
if not (love and love.filesystem) then
|
||||
return nil, "mod uninstall needs LOVE"
|
||||
end
|
||||
local fs = love.filesystem
|
||||
local dest = "mods/" .. id
|
||||
if not fs.getInfo(dest) then
|
||||
return nil, "mod '" .. id .. "' is not installed"
|
||||
end
|
||||
removeTree(dest)
|
||||
-- Drop the enable flag so a reinstall of the same id starts from the
|
||||
-- loader's default (enabled) rather than a stale false.
|
||||
local options = SaveData.loadOptions()
|
||||
if options.mods and options.mods[id] ~= nil then
|
||||
options.mods[id] = nil
|
||||
SaveData.saveOptions(options)
|
||||
end
|
||||
return true
|
||||
end
|
||||
|
||||
return LauncherMods
|
||||
|
||||
@@ -68,6 +68,7 @@ local devShim = { installed = false, permissions = {}, warned = {}, depth = 0 }
|
||||
local SUPPORTED_REQUIRES = {
|
||||
["src.mods.Semver"] = true,
|
||||
["src.audio.ChipAsm"] = true,
|
||||
["src.pokemon.Stats"] = true, -- Stats.isShiny / calc for indicator mods
|
||||
}
|
||||
|
||||
local function scanRequire(name)
|
||||
|
||||
+21
-20
@@ -10,6 +10,7 @@ local Semver = require("src.mods.Semver")
|
||||
local Version = require("src.core.Version")
|
||||
local Theme = require("src.ui.Theme")
|
||||
local OptionRows = require("src.ui.OptionRows")
|
||||
local Strings = require("src.core.Strings")
|
||||
|
||||
local ManagerState = {}
|
||||
ManagerState.__index = ManagerState
|
||||
@@ -269,7 +270,7 @@ function ManagerState:modRows()
|
||||
end
|
||||
end
|
||||
if #rows == 0 then
|
||||
rows[1] = { header = true, label = "NO MODS INSTALLED" }
|
||||
rows[1] = { header = true, label = Strings("NO MODS INSTALLED") }
|
||||
end
|
||||
return rows
|
||||
end
|
||||
@@ -281,7 +282,7 @@ function ManagerState:profileRows()
|
||||
rows[#rows + 1] = { profile = p, label = p.name,
|
||||
glyph = opts.activeProfile == p.name and GLYPH.errored or " " }
|
||||
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,
|
||||
label = opts.activeProfile and "[AD-HOC]" or "[AD-HOC] (LIVE)" }
|
||||
return rows
|
||||
@@ -316,30 +317,30 @@ function ManagerState:detailRows(m)
|
||||
rows[#rows + 1] = { label = m.enabled and "DISABLE" or "ENABLE",
|
||||
action = function() self:beginToggle(m) end }
|
||||
if self:schemaFor(m) then
|
||||
rows[#rows + 1] = { label = "OPTIONS..",
|
||||
rows[#rows + 1] = { label = Strings("OPTIONS.."),
|
||||
action = function() self:openOptions(m) end }
|
||||
end
|
||||
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 }
|
||||
end
|
||||
if m.error then
|
||||
rows[#rows + 1] = { label = "VIEW ERROR..",
|
||||
rows[#rows + 1] = { label = Strings("VIEW ERROR.."),
|
||||
action = function() self:goTo("errors") 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
|
||||
end
|
||||
|
||||
function ManagerState:applyRows()
|
||||
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)
|
||||
end }
|
||||
rows[#rows + 1] = { label = "DISCARD CHANGES", action = function()
|
||||
rows[#rows + 1] = { label = Strings("DISCARD CHANGES"), action = function()
|
||||
self:discardChanges()
|
||||
end }
|
||||
rows[#rows + 1] = { label = "BACK", action = function() self:goBack() end }
|
||||
rows[#rows + 1] = { label = Strings("BACK"), action = function() self:goBack() end }
|
||||
return rows
|
||||
end
|
||||
|
||||
@@ -352,7 +353,7 @@ function ManagerState:permissionRows(m)
|
||||
label = info and info.text or name }
|
||||
end
|
||||
if #rows == 0 then
|
||||
rows[1] = { inert = true, label = "DATA & API ONLY" }
|
||||
rows[1] = { inert = true, label = Strings("DATA & API ONLY") }
|
||||
end
|
||||
return rows
|
||||
end
|
||||
@@ -564,7 +565,7 @@ function ManagerState:openCascade(r, m, want)
|
||||
else
|
||||
local dep = self.byId[r.alsoDisable[1]]
|
||||
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
|
||||
self.overlay = { kind = "confirm", lines = lines, index = 1,
|
||||
onYes = function() self:commitToggle(r.apply) end }
|
||||
@@ -700,7 +701,7 @@ end
|
||||
function ManagerState:saveCurrentAs()
|
||||
local NamingScreen = require("src.ui.NamingScreen")
|
||||
self.game.stack:push(NamingScreen.new(self.game, {
|
||||
title = "PROFILE NAME?",
|
||||
title = Strings("PROFILE NAME?"),
|
||||
maxLen = 10,
|
||||
onDone = function(name)
|
||||
local opts = self:optionsTable()
|
||||
@@ -726,7 +727,7 @@ end
|
||||
function ManagerState:renameProfile(p)
|
||||
local NamingScreen = require("src.ui.NamingScreen")
|
||||
self.game.stack:push(NamingScreen.new(self.game, {
|
||||
title = "RENAME?",
|
||||
title = Strings("RENAME?"),
|
||||
maxLen = 10,
|
||||
default = p.name,
|
||||
onDone = function(name)
|
||||
@@ -886,7 +887,7 @@ function ManagerState:buildOptionRows(m, schema)
|
||||
end }
|
||||
end
|
||||
end
|
||||
rows[#rows + 1] = { id = "__reset", label = "RESET DEFAULTS",
|
||||
rows[#rows + 1] = { id = "__reset", label = Strings("RESET DEFAULTS"),
|
||||
value = function() return "" end,
|
||||
activate = function()
|
||||
for _, row in ipairs(schema) do
|
||||
@@ -1047,7 +1048,7 @@ function ManagerState:drawApply()
|
||||
y = y + 1
|
||||
end
|
||||
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
|
||||
end
|
||||
local rows = self:rowsForScreen()
|
||||
@@ -1076,12 +1077,12 @@ function ManagerState:drawOverlay()
|
||||
end
|
||||
if overlay.kind == "confirm" then
|
||||
local yesY = ty + #lines + 1
|
||||
Font.draw("YES", 5 * 8, yesY * 8)
|
||||
Font.draw("NO", 5 * 8, (yesY + 1) * 8)
|
||||
Font.draw(Strings("YES"), 5 * 8, yesY * 8)
|
||||
Font.draw(Strings("NO"), 5 * 8, (yesY + 1) * 8)
|
||||
Font.drawCode(Theme.cursor, 4 * 8,
|
||||
(overlay.index == 1 and yesY or yesY + 1) * 8)
|
||||
else
|
||||
Font.draw("A:OK", 5 * 8, (ty + #lines + 1) * 8)
|
||||
Font.draw(Strings("A:OK"), 5 * 8, (ty + #lines + 1) * 8)
|
||||
end
|
||||
end
|
||||
|
||||
@@ -1090,7 +1091,7 @@ function ManagerState:draw()
|
||||
OptionRows.draw(self.game, self.optionRows or {}, self.cursor,
|
||||
self.scroll or 0)
|
||||
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)
|
||||
if self.overlay then self:drawOverlay() end
|
||||
return
|
||||
@@ -1099,7 +1100,7 @@ function ManagerState:draw()
|
||||
love.graphics.rectangle("fill", 0, 0, 160, 144)
|
||||
love.graphics.setColor(1, 1, 1, 1)
|
||||
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
|
||||
self:drawList()
|
||||
elseif self.screen == "detail" then
|
||||
|
||||
+31
-2
@@ -584,6 +584,17 @@ R.text = {
|
||||
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);
|
||||
-- shared by every namespace that plays a chip program
|
||||
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
|
||||
scale = f.numRange(0.25, 4.0),
|
||||
},
|
||||
example = 'mod.content.battle_sprite_scales:register("abra_back", ' ..
|
||||
'{ path = "assets/generated/battle/back/abrab.png", scale = 1.5 })',
|
||||
example = [[
|
||||
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
|
||||
@@ -1146,6 +1169,12 @@ R.field = {
|
||||
name = f.opt(f.str) })),
|
||||
nest = f.opt(f.any) },
|
||||
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
|
||||
boot = f.rec{
|
||||
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 Stats = require("src.pokemon.Stats")
|
||||
local TextBox = require("src.render.TextBox")
|
||||
local Strings = require("src.core.Strings")
|
||||
|
||||
local Evolution = {}
|
||||
|
||||
@@ -23,7 +24,7 @@ Evolution.METHODS = {
|
||||
return trigger.kind == "levelup" and mon.level >= (evo.level or 0)
|
||||
end,
|
||||
describe = function(evo)
|
||||
return ("Level %d"):format(evo.level or 0)
|
||||
return Strings("Level %d", evo.level or 0)
|
||||
end,
|
||||
},
|
||||
ITEM = {
|
||||
@@ -139,7 +140,7 @@ function Evolution.learnEvolutionMoves(game, mon, onDone)
|
||||
table.insert(mon.moves, { id = moveId, pp = mdef.pp })
|
||||
Runtime.emit("pokemon.move_learned", { mon = mon, moveId = moveId })
|
||||
game.stack:push(TextBox.new(game,
|
||||
("%s learned\n%s!"):format(name, mdef.name), nextStep))
|
||||
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)
|
||||
@@ -160,8 +161,8 @@ function Evolution.evolve(game, mon, newSpecies, onDone, via)
|
||||
Music.play(game.data, Music.special(game.data, "evolution"))
|
||||
local oldName = mon.nickname or game.data.pokemon[mon.species].name
|
||||
Evolution.apply(game, mon, newSpecies, via)
|
||||
local msg = ("What?\n%s is\nevolving!\fCongratulations!\nYour %s\nevolved into\n%s!")
|
||||
:format(oldName, oldName, game.data.pokemon[newSpecies].name)
|
||||
local msg = Strings("What?\n%s is\nevolving!\fCongratulations!\nYour %s\nevolved into\n%s!",
|
||||
oldName, oldName, game.data.pokemon[newSpecies].name)
|
||||
game.stack:push(TextBox.new(game, msg, function()
|
||||
Music.restoreMap(game.data)
|
||||
-- re-run the evolved species' level-up learn check before onDone
|
||||
|
||||
@@ -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))
|
||||
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
|
||||
|
||||
+69
-20
@@ -102,35 +102,84 @@ local function pageFor(code)
|
||||
return nil
|
||||
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
|
||||
-- render as space (and are reported once).
|
||||
local reported = {}
|
||||
function Font.encode(text)
|
||||
local codes = {}
|
||||
local i = 1
|
||||
while i <= #text do
|
||||
local candidates = state.byFirstByte[text:byte(i)]
|
||||
local matched = false
|
||||
if candidates 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
|
||||
for _, span in ipairs(Font.split(text)) do
|
||||
local code = span.code
|
||||
if not code then
|
||||
local ch = text:sub(span.from, span.to)
|
||||
if not reported[ch] and text:byte(span.from) >= 32 then
|
||||
reported[ch] = true
|
||||
require("src.core.Logger").warn("font: no glyph for %q", ch)
|
||||
end
|
||||
codes[#codes + 1] = 0x7F -- space
|
||||
i = i + 1
|
||||
code = SPACE
|
||||
end
|
||||
codes[#codes + 1] = code
|
||||
end
|
||||
return codes
|
||||
end
|
||||
|
||||
@@ -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
|
||||
+28
-8
@@ -10,6 +10,8 @@ local Zoom = require("src.render.Zoom")
|
||||
local Tilt = require("src.render.Tilt")
|
||||
local PaletteFX = require("src.render.PaletteFX")
|
||||
local Pipelines = require("src.render.Pipelines")
|
||||
local PixelCanvas = require("src.render.PixelCanvas")
|
||||
local Runtime = require("src.mods.Runtime")
|
||||
|
||||
local Renderer = {}
|
||||
|
||||
@@ -78,8 +80,10 @@ local function displayMetrics()
|
||||
end
|
||||
|
||||
function Renderer:init()
|
||||
self.canvas = love.graphics.newCanvas(self.WIDTH, self.HEIGHT)
|
||||
self.canvas:setFilter("nearest", "nearest")
|
||||
-- 160x144 real pixels, never DPI-scaled: see src/render/PixelCanvas.lua
|
||||
-- (#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.worldActive = false
|
||||
-- tilt mode only: a transparent overlay canvas the size of the world
|
||||
@@ -230,8 +234,7 @@ function Renderer:beginWorldPass()
|
||||
-- the view size every frame, so without this the superseded canvases
|
||||
-- pile up in VRAM until a GC finalizer happens to run
|
||||
if self.worldCanvas and self.worldCanvas.release then self.worldCanvas:release() end
|
||||
self.worldCanvas = love.graphics.newCanvas(vw, vh)
|
||||
self.worldCanvas:setFilter("nearest", "nearest")
|
||||
self.worldCanvas = PixelCanvas.new(vw, vh, "nearest")
|
||||
end
|
||||
self.worldActive = true
|
||||
PaletteFX.setPass("world")
|
||||
@@ -259,8 +262,7 @@ function Renderer:beginUprightPass()
|
||||
if not self.uprightCanvas or self.uprightCanvas:getWidth() ~= cw
|
||||
or self.uprightCanvas:getHeight() ~= ch then
|
||||
if self.uprightCanvas and self.uprightCanvas.release then self.uprightCanvas:release() end
|
||||
self.uprightCanvas = love.graphics.newCanvas(cw, ch)
|
||||
self.uprightCanvas:setFilter("nearest", "nearest")
|
||||
self.uprightCanvas = PixelCanvas.new(cw, ch, "nearest")
|
||||
end
|
||||
self.uprightActive = true
|
||||
PaletteFX.setPass(nil)
|
||||
@@ -354,8 +356,7 @@ function Renderer:drawTiltedWorld(zoneList, sx, sy, wox, woy, target)
|
||||
if not self.tiltCanvas or self.tiltCanvas:getWidth() ~= wvw
|
||||
or self.tiltCanvas:getHeight() ~= wvh then
|
||||
if self.tiltCanvas and self.tiltCanvas.release then self.tiltCanvas:release() end
|
||||
self.tiltCanvas = love.graphics.newCanvas(wvw, wvh)
|
||||
self.tiltCanvas:setFilter("linear", "linear")
|
||||
self.tiltCanvas = PixelCanvas.new(wvw, wvh, "linear")
|
||||
end
|
||||
|
||||
love.graphics.setCanvas(self.tiltCanvas)
|
||||
@@ -464,6 +465,14 @@ function Renderer:endFrame(zones, worldZones)
|
||||
if needPresent then
|
||||
if not self.presentCanvas or self.presentCanvas:getWidth() ~= ww
|
||||
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:setFilter("linear", "linear")
|
||||
end
|
||||
@@ -486,6 +495,17 @@ function Renderer:endFrame(zones, worldZones)
|
||||
love.graphics.setColor(clearR, clearG, clearB, 1)
|
||||
love.graphics.rectangle("fill", 0, 0, ww, wh)
|
||||
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 (sx, sy) LOVE-unit scales into origin (bx, by),
|
||||
-- scissored to the (boxX, boxY, boxW, boxH) screen rect. zoneSx/zoneSy
|
||||
|
||||
+31
-4
@@ -92,13 +92,28 @@ end
|
||||
-- pokered ContText waits for A/B + ▼ before scrolling that line in.
|
||||
function TextBox.paginate(text, maxCols)
|
||||
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 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)
|
||||
while #line > maxCols do
|
||||
local cut = maxCols
|
||||
for i = maxCols, 1, -1 do
|
||||
if line:sub(i, i) == " " then cut = i break end
|
||||
while true do
|
||||
local spans = Font.split(line)
|
||||
local fit = Font.spansFitting(spans, budget)
|
||||
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
|
||||
table.insert(lines, line:sub(1, cut))
|
||||
table.insert(conts, wait)
|
||||
@@ -167,6 +182,18 @@ function TextBox:update(dt)
|
||||
if self.autoSrc and self.autoSrc.isPlaying and self.autoSrc:isPlaying() then
|
||||
return -- the cry is still sounding (WaitForSoundToFinish)
|
||||
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
|
||||
local delay = self.auto.delay or 3
|
||||
-- auto.onOverlap: fired once when the delay elapses but before the
|
||||
|
||||
@@ -542,7 +542,10 @@ end
|
||||
local function bakeBorderFill(self, block)
|
||||
local border = self.map.tileset.blocks[block + 1]
|
||||
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.setCanvas(canvas)
|
||||
love.graphics.clear(1, 1, 1, 1)
|
||||
|
||||
+26
-7
@@ -4,19 +4,38 @@
|
||||
-- zoom. Persisted as save.options.zoom (default 0 = FIT).
|
||||
-- Spec: docs/new-features.md (survey zoom)
|
||||
|
||||
local Runtime = require("src.mods.Runtime")
|
||||
|
||||
local Zoom = {}
|
||||
|
||||
Zoom.offset = 0
|
||||
|
||||
-- effective integer scale s' in [1, 2*S]
|
||||
function Zoom.scale(S)
|
||||
return math.max(1, math.min(2 * S, S + Zoom.offset))
|
||||
end
|
||||
|
||||
-- legal offset range for a given fit scale
|
||||
-- legal offset range for a given fit scale (vanilla: survey at 1 px/world
|
||||
-- through 2× fit). zoom.range may widen or shrink the window.
|
||||
function Zoom.offsetRange(S)
|
||||
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
|
||||
|
||||
function Zoom.clampOffset(offset, S)
|
||||
|
||||
+29
-14
@@ -10,6 +10,7 @@ local Flags = require("src.script.Flags")
|
||||
local Logger = require("src.core.Logger")
|
||||
local Screens = require("src.ui.Screens")
|
||||
local TextBox = require("src.render.TextBox")
|
||||
local Strings = require("src.core.Strings")
|
||||
|
||||
local Commands = {}
|
||||
|
||||
@@ -74,6 +75,9 @@ end
|
||||
-- <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
|
||||
-- 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)
|
||||
local text = ctx.game.data.text[textId]
|
||||
if not text and ctx.overworld then
|
||||
@@ -100,10 +104,12 @@ function Commands.show_text(ctx, textId, subs)
|
||||
local opts
|
||||
if ctx.pendingCry then
|
||||
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()
|
||||
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
|
||||
-- text_opts armed the next box: auto = true is the plain no-button-wait
|
||||
-- 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)
|
||||
if not require("src.inventory.Bag").add(ctx.save, itemId, count or 1) then
|
||||
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
|
||||
end
|
||||
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")
|
||||
if gotText ~= false then
|
||||
Commands.show_text(ctx, gotText
|
||||
or "{PLAYER} got\n" .. ctx.game.stringBuffer .. "!")
|
||||
or Strings("{PLAYER} got\n%s!", ctx.game.stringBuffer))
|
||||
end
|
||||
end
|
||||
|
||||
@@ -469,18 +475,26 @@ function Commands.play_once(ctx, songId)
|
||||
runner:yield()
|
||||
end
|
||||
|
||||
-- play_cry <species>: PlayCry (home/audio.asm). The text_asm bodies that
|
||||
-- use it run text_far (a no-button-wait "...@" string) -> PlayCry ->
|
||||
-- WaitForSoundToFinish, all within the same text ID -- the cry only starts
|
||||
-- once the box has finished typing, and the box then auto-closes (no A
|
||||
-- press) the instant the cry finishes, rather than firing immediately
|
||||
-- play_cry <species> [waitForButton]: PlayCry (home/audio.asm). The
|
||||
-- text_asm bodies that use it run text_far (a no-button-wait "...@" string)
|
||||
-- -> PlayCry -> WaitForSoundToFinish, all within the same text ID -- the cry
|
||||
-- only starts once the box has finished typing, and the box then auto-closes
|
||||
-- (no A press) the instant the cry finishes, rather than firing immediately
|
||||
-- alongside the typewriter effect. Script rows run strictly in order, so
|
||||
-- this stashes the species on ctx for the show_text row that always
|
||||
-- immediately follows it (Power Plant Zapdos, Seafoam Articuno, Victory
|
||||
-- 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.
|
||||
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.pendingCryWait = waitForButton or nil
|
||||
end
|
||||
|
||||
-- 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
|
||||
Commands.show_text(ctx, "_DoYouWantToNicknameText", { RAM = name })
|
||||
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
|
||||
local ChoiceBox = require("src.ui.ChoiceBox")
|
||||
ctx.game.stack:push(ChoiceBox.new(ctx.game, function(yes)
|
||||
@@ -527,7 +541,7 @@ local function askNickname(ctx, mon)
|
||||
return
|
||||
end
|
||||
Screens.push(ctx.game, "NamingScreen", {
|
||||
title = "NICKNAME?", maxLen = 10,
|
||||
title = Strings("NICKNAME?"), maxLen = 10,
|
||||
onDone = function(nick)
|
||||
if nick and #nick > 0 then mon.nickname = nick end
|
||||
ctx.lastCheck = success
|
||||
@@ -598,8 +612,9 @@ function Commands.give_pokemon(ctx, species, level)
|
||||
local TextBox = require("src.render.TextBox")
|
||||
local raw = ctx.game.data.text and ctx.game.data.text._SentToBoxText
|
||||
ctx.game.stack:push(TextBox.new(ctx.game, raw or (
|
||||
"There's no more\nroom for POKéMON!\v" .. name
|
||||
.. " was\vsent to POKéMON\vBOX " .. boxNum .. " on PC!"),
|
||||
Strings(
|
||||
"There's no more\nroom for POKéMON!\v%s was\vsent to POKéMON\vBOX %s on PC!",
|
||||
name, boxNum)),
|
||||
function() end))
|
||||
end
|
||||
end
|
||||
|
||||
@@ -15,6 +15,7 @@
|
||||
local Data = require("src.core.Data")
|
||||
local Logger = require("src.core.Logger")
|
||||
local Runtime = require("src.mods.Runtime")
|
||||
local Strings = require("src.core.Strings")
|
||||
|
||||
local MapScripts = {}
|
||||
|
||||
@@ -268,7 +269,7 @@ function MapScripts.validateContribution(contribution, lookup)
|
||||
end
|
||||
end
|
||||
if type(contribution) ~= "table" then
|
||||
return { "contribution is not a table" }
|
||||
return { Strings("contribution is not a table") }
|
||||
end
|
||||
for textConst, script in pairs(contribution.talk or {}) do
|
||||
if type(script) == "table" then collect("talk." .. textConst, script) end
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
local Commands = require("src.script.Commands")
|
||||
local Logger = require("src.core.Logger")
|
||||
local Runtime = require("src.mods.Runtime")
|
||||
local Strings = require("src.core.Strings")
|
||||
|
||||
local unpack = table.unpack or unpack -- LuaJIT (LÖVE) compatibility
|
||||
|
||||
@@ -195,7 +196,7 @@ function ScriptRunner:resume(...)
|
||||
if not ok then
|
||||
local source = self.ctx and self.ctx.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 "?"))
|
||||
or ""
|
||||
Logger.error("script error%s: %s", where, tostring(err))
|
||||
|
||||
+32
-20
@@ -9,6 +9,7 @@ local TextBox = require("src.render.TextBox")
|
||||
local BagMenu = {}
|
||||
|
||||
local Bag = require("src.inventory.Bag")
|
||||
local Strings = require("src.core.Strings")
|
||||
|
||||
-- acquisition order like wBagItems (Bag.order), not alphabetical
|
||||
local function buildItems(game)
|
||||
@@ -108,13 +109,13 @@ local function useOn(game, battle, id, target, list, moveIndex)
|
||||
if game.save.onBike then
|
||||
game.save.onBike = 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
|
||||
game.save.onBike = 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
|
||||
showMessages(game, { "No cycling\nallowed here." })
|
||||
showMessages(game, { Strings("No cycling\nallowed here.") })
|
||||
end
|
||||
return
|
||||
end
|
||||
@@ -130,13 +131,14 @@ local function useOn(game, battle, id, target, list, moveIndex)
|
||||
return
|
||||
end
|
||||
end
|
||||
showMessages(game, { "No good! It's not\neven near water." })
|
||||
showMessages(game, { Strings("No good! It's not\neven near water.") })
|
||||
return
|
||||
end
|
||||
|
||||
if result == "ball" 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
|
||||
end
|
||||
consume(game, id)
|
||||
@@ -151,7 +153,7 @@ local function useOn(game, battle, id, target, list, moveIndex)
|
||||
local function teach()
|
||||
if #target.moves < 4 then
|
||||
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) })
|
||||
if result == "learn" then consume(game, id) end
|
||||
else
|
||||
@@ -172,7 +174,7 @@ local function useOn(game, battle, id, target, list, moveIndex)
|
||||
require("src.ui.Screens").push(game, "TownMap")
|
||||
end)
|
||||
if not ok then
|
||||
showMessages(game, { "The TOWN MAP is\nunreadable here." })
|
||||
showMessages(game, { Strings("The TOWN MAP is\nunreadable here.") })
|
||||
end
|
||||
return
|
||||
end
|
||||
@@ -184,10 +186,10 @@ local function useOn(game, battle, id, target, list, moveIndex)
|
||||
local t = game.data.text
|
||||
if ow and ow:hasHiddenItemLeft() then
|
||||
showMessages(game, { t._ItemfinderFoundItemText
|
||||
or "Yes! ITEMFINDER\nindicates there's\nan item nearby." })
|
||||
or Strings("Yes! ITEMFINDER\nindicates there's\nan item nearby.") })
|
||||
else
|
||||
showMessages(game, { t._ItemfinderFoundNothingText
|
||||
or "Nope! ITEMFINDER\nisn't responding." })
|
||||
or Strings("Nope! ITEMFINDER\nisn't responding.") })
|
||||
end
|
||||
return
|
||||
end
|
||||
@@ -219,8 +221,9 @@ local function useOn(game, battle, id, target, list, moveIndex)
|
||||
-- departure helper -- the same path Dig/Teleport take from the party menu
|
||||
ow:beginTeleportOut()
|
||||
else
|
||||
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) })
|
||||
end
|
||||
return
|
||||
end
|
||||
@@ -264,7 +267,7 @@ local function useOn(game, battle, id, target, list, moveIndex)
|
||||
if #target.moves < 4 then
|
||||
table.insert(target.moves, { id = moveId, pp = mdef.pp })
|
||||
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)
|
||||
else
|
||||
require("src.ui.Screens").push(game, "MoveLearnMenu",
|
||||
@@ -354,8 +357,8 @@ local function useItem(game, battle, id, list)
|
||||
local moveDef = game.data.moves[def.machine.move]
|
||||
local moveName = moveDef and moveDef.name or def.machine.move
|
||||
local booted = def.machine.kind == "HM"
|
||||
and "Booted up an HM!" or "Booted up a TM!"
|
||||
showMessages(game, { booted, ("It contained\n%s!"):format(moveName) },
|
||||
and "Booted up an HM!" or Strings("Booted up a TM!")
|
||||
showMessages(game, { booted, Strings("It contained\n%s!", moveName) },
|
||||
function() pickTargetAndUse(game, battle, id, list) end)
|
||||
return
|
||||
end
|
||||
@@ -370,7 +373,10 @@ function BagMenu.new(game, opts)
|
||||
local battle = opts.battle
|
||||
local list
|
||||
list = ListMenu.new(game, "ITEMS", buildItems(game), {
|
||||
kind = "bag",
|
||||
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)
|
||||
onSelectKey = function(item, l)
|
||||
if not item then return end
|
||||
@@ -399,17 +405,23 @@ function BagMenu.new(game, opts)
|
||||
useItem(game, battle, id, list)
|
||||
return
|
||||
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")
|
||||
game.stack:push(Menu.new(game, {
|
||||
{ label = "USE", onSelect = function()
|
||||
{ label = Strings("USE"), onSelect = function()
|
||||
useItem(game, battle, id, list)
|
||||
end },
|
||||
{ label = "TOSS", onSelect = function()
|
||||
{ label = Strings("TOSS"), onSelect = function()
|
||||
-- KeyItemFlags + HMs decide tossability (not price:
|
||||
-- MOON STONE is price 0 but tossable)
|
||||
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
|
||||
end
|
||||
local QuantityBox = require("src.ui.QuantityBox")
|
||||
@@ -423,12 +435,12 @@ function BagMenu.new(game, opts)
|
||||
Bag.remove(game.save, id, qty)
|
||||
list.items = buildItems(game)
|
||||
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 },
|
||||
}, { tx = 12, ty = 10, tw = 8, th = 6 }))
|
||||
}, { tx = 13, ty = 10, tw = 7, th = 5 }))
|
||||
end,
|
||||
})
|
||||
return list
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
local Font = require("src.render.Font")
|
||||
local ListMenu = require("src.ui.ListMenu")
|
||||
local Input = require("src.core.Input")
|
||||
local Strings = require("src.core.Strings")
|
||||
|
||||
local BindingsMenu = setmetatable({}, { __index = ListMenu })
|
||||
BindingsMenu.__index = BindingsMenu
|
||||
@@ -55,7 +56,9 @@ function BindingsMenu.new(game)
|
||||
and game.save.options.bindings
|
||||
local items = {}
|
||||
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 }
|
||||
end
|
||||
local self = setmetatable(ListMenu.new(game, "CONTROLS", items, {}),
|
||||
@@ -111,7 +114,7 @@ function BindingsMenu:draw()
|
||||
if self.capture then
|
||||
Font.drawBox(1, 6, 18, 4)
|
||||
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)
|
||||
end
|
||||
end
|
||||
|
||||
+26
-25
@@ -8,12 +8,13 @@ local ListMenu = require("src.ui.ListMenu")
|
||||
local Menu = require("src.ui.Menu")
|
||||
local Party = require("src.pokemon.Party")
|
||||
local TextBox = require("src.render.TextBox")
|
||||
local Strings = require("src.core.Strings")
|
||||
|
||||
local BoxMenu = {}
|
||||
|
||||
local function monLabel(game, mon)
|
||||
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
|
||||
|
||||
local function monName(game, mon)
|
||||
@@ -28,13 +29,13 @@ local function monSubmenu(game, action, mon, onAction)
|
||||
game.stack:push(Menu.new(game, {
|
||||
{ label = action, onSelect = onAction },
|
||||
{
|
||||
label = "STATS",
|
||||
label = Strings("STATS"),
|
||||
keepOpen = true,
|
||||
onSelect = function()
|
||||
require("src.ui.Screens").push(game, "SummaryMenu", mon)
|
||||
end,
|
||||
},
|
||||
{ label = "CANCEL" },
|
||||
{ label = Strings("CANCEL") },
|
||||
}, { tx = 9, ty = 10, tw = 11, th = 8, noSound = true }))
|
||||
end
|
||||
|
||||
@@ -50,12 +51,12 @@ local function withdraw(game)
|
||||
local t = game.data.text
|
||||
if #box == 0 then
|
||||
game.stack:push(TextBox.new(game, t._NoMonText
|
||||
or "What? There are\nno POKéMON here!"))
|
||||
or Strings("What? There are\nno POKéMON here!")))
|
||||
return
|
||||
end
|
||||
if #game.save.party >= Party.MAX then
|
||||
game.stack:push(TextBox.new(game, t._CantTakeMonText
|
||||
or "You can't take\nany more POKéMON.\fDeposit POKéMON\nfirst."))
|
||||
or Strings("You can't take\nany more POKéMON.\fDeposit POKéMON\nfirst.")))
|
||||
return
|
||||
end
|
||||
local items = {}
|
||||
@@ -63,7 +64,7 @@ local function withdraw(game)
|
||||
table.insert(items, { label = monLabel(game, mon), value = i })
|
||||
end
|
||||
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)
|
||||
local mon = box[item.value]
|
||||
if not mon then return end
|
||||
@@ -78,7 +79,7 @@ local function withdraw(game)
|
||||
game.stringBuffer = name
|
||||
require("src.core.Sound").playCry(game.data, mon.species)
|
||||
afterTransfer(game, list, t._MonIsTakenOutText
|
||||
or (name .. " is\ntaken out.\vGot " .. name .. "."))
|
||||
or Strings("%s is\ntaken out.\vGot %s.", name, name))
|
||||
end)
|
||||
end,
|
||||
}))
|
||||
@@ -88,13 +89,13 @@ local function deposit(game)
|
||||
local t = game.data.text
|
||||
if #game.save.party <= 1 then
|
||||
game.stack:push(TextBox.new(game, t._CantDepositLastMonText
|
||||
or "You can't deposit\nthe last POKéMON!"))
|
||||
or Strings("You can't deposit\nthe last POKéMON!")))
|
||||
return
|
||||
end
|
||||
local box = Boxes.active(game.save)
|
||||
if #box >= Boxes.CAPACITY then
|
||||
game.stack:push(TextBox.new(game, t._BoxFullText
|
||||
or "Oops! This Box is\nfull of POKéMON."))
|
||||
or Strings("Oops! This Box is\nfull of POKéMON.")))
|
||||
return
|
||||
end
|
||||
local items = {}
|
||||
@@ -107,12 +108,12 @@ local function deposit(game)
|
||||
if not mon then return end
|
||||
monSubmenu(game, "DEPOSIT", mon, function()
|
||||
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
|
||||
end
|
||||
local active = Boxes.active(game.save)
|
||||
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
|
||||
end
|
||||
table.remove(game.save.party, item.value)
|
||||
@@ -122,7 +123,7 @@ local function deposit(game)
|
||||
game.boxNumString = tostring(game.save.currentBox)
|
||||
require("src.core.Sound").playCry(game.data, mon.species)
|
||||
afterTransfer(game, list, t._MonWasStoredText
|
||||
or (name .. " was\nstored in Box " .. game.boxNumString .. "."))
|
||||
or Strings("%s was\nstored in Box %s.", name, game.boxNumString))
|
||||
end)
|
||||
end,
|
||||
}))
|
||||
@@ -135,7 +136,7 @@ local function release(game)
|
||||
local t = game.data.text
|
||||
if #box == 0 then
|
||||
game.stack:push(TextBox.new(game, t._NoMonText
|
||||
or "What? There are\nno POKéMON here!"))
|
||||
or Strings("What? There are\nno POKéMON here!")))
|
||||
return
|
||||
end
|
||||
local items = {}
|
||||
@@ -143,20 +144,20 @@ local function release(game)
|
||||
table.insert(items, { label = monLabel(game, mon), value = i })
|
||||
end
|
||||
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)
|
||||
local mon = box[list.index]
|
||||
if not mon then return end
|
||||
local name = monName(game, mon)
|
||||
local ChoiceBox = require("src.ui.ChoiceBox")
|
||||
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)
|
||||
if not yes then return end
|
||||
table.remove(box, list.index)
|
||||
require("src.core.Sound").playCry(game.data, mon.species)
|
||||
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()
|
||||
end, { defaultNo = true, noSound = true }))
|
||||
end))
|
||||
@@ -170,7 +171,7 @@ local function changeBox(game)
|
||||
for i = 1, Boxes.COUNT do
|
||||
local mark = i == game.save.currentBox and "*" or " "
|
||||
table.insert(items, {
|
||||
label = ("%sBOX %2d"):format(mark, i),
|
||||
label = Strings("%sBOX %2d", mark, i),
|
||||
right = ("%d/%d"):format(#boxes[i], Boxes.CAPACITY),
|
||||
value = i,
|
||||
})
|
||||
@@ -181,7 +182,7 @@ local function changeBox(game)
|
||||
-- BOX, data will be saved. OK?"); declining aborts the change
|
||||
local ChoiceBox = require("src.ui.ChoiceBox")
|
||||
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)
|
||||
if not yes then return end
|
||||
game.save.currentBox = item.value
|
||||
@@ -197,10 +198,10 @@ end
|
||||
local function drawChrome(game)
|
||||
Font.drawBox(0, 12, 20, 6)
|
||||
love.graphics.setColor(0, 0, 0, 1)
|
||||
Font.draw("What?", 8, 112)
|
||||
Font.draw(Strings("What?"), 8, 112)
|
||||
Font.drawBox(9, 14, 11, 4)
|
||||
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
|
||||
if n >= 10 then
|
||||
Font.draw(tostring(n), 136, 128)
|
||||
@@ -217,15 +218,15 @@ function BoxMenu.new(game)
|
||||
-- full interior (cursor col + label). keepOpen so WITHDRAW/DEPOSIT/
|
||||
-- RELEASE/CHANGE BOX leave this menu underneath (jp BillsPCMenu).
|
||||
local menu = Menu.new(game, {
|
||||
{ label = "WITHDRAW <PK><MN>", keepOpen = true,
|
||||
{ label = Strings("WITHDRAW <PK><MN>"), keepOpen = true,
|
||||
onSelect = function() withdraw(game) end },
|
||||
{ label = "DEPOSIT <PK><MN>", keepOpen = true,
|
||||
{ label = Strings("DEPOSIT <PK><MN>"), keepOpen = true,
|
||||
onSelect = function() deposit(game) end },
|
||||
{ label = "RELEASE <PK><MN>", keepOpen = true,
|
||||
{ label = Strings("RELEASE <PK><MN>"), keepOpen = true,
|
||||
onSelect = function() release(game) end },
|
||||
{ label = "CHANGE BOX", keepOpen = true,
|
||||
{ label = Strings("CHANGE BOX"), keepOpen = true,
|
||||
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,
|
||||
-- engine/menus/pokemon_pc.asm)
|
||||
}, { tx = 0, ty = 0, tw = 14, th = 12, noSound = true })
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
local Font = require("src.render.Font")
|
||||
local Theme = require("src.ui.Theme")
|
||||
local Strings = require("src.core.Strings")
|
||||
|
||||
local ChoiceBox = {}
|
||||
ChoiceBox.__index = ChoiceBox
|
||||
@@ -46,8 +47,8 @@ function ChoiceBox:draw()
|
||||
local tx, ty, tw, th = self.tx, self.ty, self.tw, self.th
|
||||
Font.drawBox(tx, ty, tw, th)
|
||||
love.graphics.setColor(0, 0, 0, 1)
|
||||
Font.draw("YES", (tx + 2) * 8, (ty + 1) * 8)
|
||||
Font.draw("NO", (tx + 2) * 8, (ty + 3) * 8)
|
||||
Font.draw(Strings("YES"), (tx + 2) * 8, (ty + 1) * 8)
|
||||
Font.draw(Strings("NO"), (tx + 2) * 8, (ty + 3) * 8)
|
||||
Font.drawCode(Theme.cursor, (tx + 1) * 8,
|
||||
(ty + (self.index == 1 and 1 or 3)) * 8)
|
||||
love.graphics.setColor(1, 1, 1, 1)
|
||||
|
||||
+9
-7
@@ -31,6 +31,7 @@
|
||||
|
||||
local Font = require("src.render.Font")
|
||||
local Music = require("src.core.Music")
|
||||
local Strings = require("src.core.Strings")
|
||||
|
||||
local Credits = {}
|
||||
Credits.__index = Credits
|
||||
@@ -140,8 +141,9 @@ function Credits:enter()
|
||||
end
|
||||
|
||||
function Credits:monSprite(species)
|
||||
local def = self.game.data.pokemon and self.game.data.pokemon[species]
|
||||
return silhouette(def and def.spriteFront)
|
||||
local path = require("src.pokemon.Sprites").path(
|
||||
self.game.data, species, "front", { kind = "credits" })
|
||||
return silhouette(path)
|
||||
end
|
||||
|
||||
-- advance to the next CreditsOrder screen (Credits .nextCreditsScreen);
|
||||
@@ -260,14 +262,14 @@ function Credits:drawCopyright(xoff)
|
||||
love.graphics.draw(self.gfImg, xoff + 80, 88)
|
||||
else
|
||||
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)
|
||||
end
|
||||
else
|
||||
love.graphics.setColor(0, 0, 0, 1)
|
||||
Font.draw("Nintendo", xoff + 80, 56)
|
||||
Font.draw("Creatures inc.", xoff + 80, 72)
|
||||
Font.draw("GAME FREAK inc.", xoff + 16, 88)
|
||||
Font.draw(Strings("Nintendo"), xoff + 80, 56)
|
||||
Font.draw(Strings("Creatures inc."), xoff + 80, 72)
|
||||
Font.draw(Strings("GAME FREAK inc."), xoff + 16, 88)
|
||||
love.graphics.setColor(1, 1, 1, 1)
|
||||
end
|
||||
end
|
||||
@@ -297,7 +299,7 @@ function Credits:drawTheEnd()
|
||||
end
|
||||
else
|
||||
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)
|
||||
end
|
||||
end
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
-- without permanently marking the mon owned.
|
||||
|
||||
local Font = require("src.render.Font")
|
||||
local Strings = require("src.core.Strings")
|
||||
|
||||
local DexEntryMenu = {}
|
||||
DexEntryMenu.__index = DexEntryMenu
|
||||
@@ -34,7 +35,9 @@ function DexEntryMenu.new(game, speciesOrOpts)
|
||||
local species, forceOwned = resolveArgs(speciesOrOpts)
|
||||
local self = setmetatable({ game = game, forceOwned = forceOwned }, DexEntryMenu)
|
||||
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
|
||||
require("src.core.Sound").playCry(game.data, species)
|
||||
return self
|
||||
@@ -74,8 +77,8 @@ function DexEntryMenu:draw()
|
||||
-- feet/inches use the dex screen's ′/″ glyphs ("HT ?′??″" in
|
||||
-- pokedex.asm; the tiles come from gfx/pokedex/pokedex.png via
|
||||
-- engine/gfx/load_pokedex_tiles.asm)
|
||||
Font.draw(("HT %d′%02d″"):format(e.heightFt, e.heightIn or 0), 72, 44)
|
||||
Font.draw(("WT %.1flb"):format((e.weight or 0) / 10), 72, 54)
|
||||
Font.draw(Strings("HT %d′%02d″", e.heightFt, e.heightIn or 0), 72, 44)
|
||||
Font.draw(Strings("WT %.1flb", (e.weight or 0) / 10), 72, 54)
|
||||
end
|
||||
local text = owned and e.text and self.game.data.text[e.text] or nil
|
||||
local y = 72
|
||||
@@ -86,7 +89,7 @@ function DexEntryMenu:draw()
|
||||
y = y + 10
|
||||
end
|
||||
else
|
||||
Font.draw("Data unknown.", 8, y)
|
||||
Font.draw(Strings("Data unknown."), 8, y)
|
||||
end
|
||||
love.graphics.setColor(1, 1, 1, 1)
|
||||
end
|
||||
|
||||
+26
-19
@@ -1,14 +1,17 @@
|
||||
-- The evolution movie (engine/movie/evolution.asm): the mon's pic
|
||||
-- flashes back and forth with the evolved form, speeding up, then the
|
||||
-- new form appears with its cry and the congratulations text.
|
||||
-- pokered engine/pokemon/evos_moves.asm (EvolveMon) polls hJoyHeld during
|
||||
-- the flash: holding B aborts the evolution -- the mon keeps its species
|
||||
-- and _StoppedEvolvingText ("Huh? MON stopped evolving!") prints. The
|
||||
-- lone exception is trade evolutions (wLinkState == LINK_STATE_TRADING),
|
||||
-- which skip that poll and cannot be cancelled (#213).
|
||||
-- pokered engine/movie/evolution.asm (Evolution_CheckForCancel) polls the
|
||||
-- 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 Music = require("src.core.Music")
|
||||
local Strings = require("src.core.Strings")
|
||||
|
||||
local EvolutionState = {}
|
||||
EvolutionState.__index = EvolutionState
|
||||
@@ -28,10 +31,11 @@ end
|
||||
|
||||
local FLASH_FRAMES = 220
|
||||
|
||||
local function frontSprite(game, species)
|
||||
local def = game.data.pokemon[species]
|
||||
if not (def and def.spriteFront) then return nil end
|
||||
local ok, img = pcall(love.graphics.newImage, def.spriteFront)
|
||||
local function frontSprite(game, species, mon)
|
||||
local path = require("src.pokemon.Sprites").path(game.data, species, "front",
|
||||
{ mon = mon, kind = "evolution" })
|
||||
if not path then return nil end
|
||||
local ok, img = pcall(love.graphics.newImage, path)
|
||||
return ok and img or nil
|
||||
end
|
||||
|
||||
@@ -42,12 +46,15 @@ function EvolutionState.new(game, mon, newSpecies, onDone, via)
|
||||
self.newSpecies = newSpecies
|
||||
self.onDone = onDone
|
||||
self.via = via
|
||||
-- evos_moves.asm: only trade evolutions (LINK_STATE_TRADING) skip the
|
||||
-- B-cancel poll; level-up, stone and rare-candy evos are all cancelable.
|
||||
self.cancelable = (via ~= "TRADE")
|
||||
-- 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.oldSprite = frontSprite(game, mon.species)
|
||||
self.newSprite = frontSprite(game, newSpecies)
|
||||
self.oldSprite = frontSprite(game, mon.species, mon)
|
||||
self.newSprite = frontSprite(game, newSpecies, mon)
|
||||
self.t = 0
|
||||
self.done = false
|
||||
self.canceled = false
|
||||
@@ -68,7 +75,7 @@ function EvolutionState:update(dt)
|
||||
local TextBox = require("src.render.TextBox")
|
||||
-- mirrors data/generated/text.lua _StoppedEvolvingText
|
||||
game.stack:push(TextBox.new(game,
|
||||
("Huh? %s\nstopped evolving!"):format(self.oldName),
|
||||
Strings("Huh? %s\nstopped evolving!", self.oldName),
|
||||
function()
|
||||
Music.restoreMap(game.data)
|
||||
game.stack:pop() -- the evolution screen itself
|
||||
@@ -84,8 +91,8 @@ function EvolutionState:update(dt)
|
||||
local TextBox = require("src.render.TextBox")
|
||||
local newName = game.data.pokemon[self.newSpecies].name
|
||||
game.stack:push(TextBox.new(game,
|
||||
("Congratulations!\nYour %s\nevolved into\n%s!")
|
||||
:format(self.oldName, newName),
|
||||
Strings("Congratulations!\nYour %s\nevolved into\n%s!",
|
||||
self.oldName, newName),
|
||||
function()
|
||||
Music.restoreMap(game.data)
|
||||
game.stack:pop() -- the evolution screen itself
|
||||
@@ -120,9 +127,9 @@ function EvolutionState:draw()
|
||||
|
||||
love.graphics.setColor(0, 0, 0, 1)
|
||||
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("evolving!", 8, 124)
|
||||
Font.draw(Strings("evolving!"), 8, 124)
|
||||
end
|
||||
love.graphics.setColor(1, 1, 1, 1)
|
||||
end
|
||||
|
||||
+19
-13
@@ -7,10 +7,12 @@
|
||||
-- name/time/money boxes plus the dex rating. Plays Music_HallOfFame
|
||||
-- when the audio data has it. Calls onDone() after popping itself.
|
||||
|
||||
local Assets = require("src.render.Assets")
|
||||
local Font = require("src.render.Font")
|
||||
local Music = require("src.core.Music")
|
||||
local Sound = require("src.core.Sound")
|
||||
local TypeChart = require("src.battle.TypeChart")
|
||||
local Strings = require("src.core.Strings")
|
||||
|
||||
local HallOfFame = {}
|
||||
HallOfFame.__index = HallOfFame
|
||||
@@ -47,9 +49,11 @@ local FADE_FRAMES = 20
|
||||
-- HoFPrintTextAndDelay after each dex line
|
||||
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)
|
||||
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
|
||||
end
|
||||
|
||||
@@ -79,7 +83,8 @@ function HallOfFame.new(game, onDone)
|
||||
self.timer = 0
|
||||
self.phase = "mons"
|
||||
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.showHofBanner = false
|
||||
self.fade = 0
|
||||
@@ -118,8 +123,9 @@ end
|
||||
function HallOfFame:spriteFor(species)
|
||||
local cached = self.sprites[species]
|
||||
if cached == nil then
|
||||
local def = self.game.data.pokemon[species]
|
||||
cached = tryImage(def and def.spriteFront) or false
|
||||
local path = require("src.pokemon.Sprites").path(
|
||||
self.game.data, species, "front", { kind = "hof" })
|
||||
cached = tryImage(path) or false
|
||||
self.sprites[species] = cached
|
||||
end
|
||||
return cached or nil
|
||||
@@ -205,13 +211,13 @@ function HallOfFame:drawMonInfo(mon)
|
||||
love.graphics.setColor(0, 0, 0, 1)
|
||||
local name = mon.nickname or (def and def.name) or mon.species
|
||||
Font.draw(name, 1 * 8, 4 * 8)
|
||||
Font.draw("LEVEL/", 2 * 8, 6 * 8)
|
||||
Font.draw("TYPE1/", 2 * 8, 7 * 8)
|
||||
Font.draw(Strings("LEVEL/"), 2 * 8, 6 * 8)
|
||||
Font.draw(Strings("TYPE1/"), 2 * 8, 7 * 8)
|
||||
local t1 = def and def.types and def.types[1]
|
||||
local t2 = def and def.types and def.types[2]
|
||||
local dual = t2 and t2 ~= t1
|
||||
if dual then
|
||||
Font.draw("TYPE2/", 2 * 8, 8 * 8)
|
||||
Font.draw(Strings("TYPE2/"), 2 * 8, 8 * 8)
|
||||
end
|
||||
-- PrintLevelCommon at (8,7): bare level digits (no <LV> tile here)
|
||||
Font.draw(tostring(mon.level), 8 * 8, 7 * 8)
|
||||
@@ -228,7 +234,7 @@ end
|
||||
function HallOfFame:drawHofBanner()
|
||||
Font.drawBox(2, 13, 16, 5)
|
||||
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
|
||||
|
||||
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)
|
||||
Font.drawBox(0, 4, 12, 8)
|
||||
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)
|
||||
Font.draw(("%3d:%02d"):format(math.floor(t / 3600), math.floor(t / 60) % 60),
|
||||
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
|
||||
Font.draw(("¥%d"):format(save.money or 0), 4 * 8, 10 * 8)
|
||||
end
|
||||
@@ -265,16 +271,16 @@ function HallOfFame:drawDexBox(kind)
|
||||
if kind == "seen" then
|
||||
local seen, owned = self:dexSeenOwned()
|
||||
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
|
||||
:gsub("{NUM:wDexRatingNumMonsSeen[^}]*}", tostring(seen))
|
||||
:gsub("{NUM:wDexRatingNumMonsOwned[^}]*}", tostring(owned))
|
||||
drawTextBlock(seenOwned, 1 * 8, 14 * 8, 17 * 8)
|
||||
else
|
||||
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)
|
||||
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)
|
||||
end
|
||||
end
|
||||
|
||||
@@ -27,6 +27,7 @@
|
||||
local Font = require("src.render.Font")
|
||||
local Music = require("src.core.Music")
|
||||
local Sound = require("src.core.Sound")
|
||||
local Strings = require("src.core.Strings")
|
||||
|
||||
local IntroMovie = {}
|
||||
IntroMovie.__index = IntroMovie
|
||||
@@ -287,7 +288,7 @@ function IntroMovie:drawSplash()
|
||||
end
|
||||
-- custom studio name (replaces the GAME FREAK splash text)
|
||||
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)
|
||||
love.graphics.setColor(1, 1, 1, 1)
|
||||
end
|
||||
@@ -348,7 +349,7 @@ function IntroMovie:drawFight()
|
||||
|
||||
if not gengar and not nido then
|
||||
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)
|
||||
end
|
||||
drawBars()
|
||||
@@ -366,7 +367,7 @@ function IntroMovie:draw()
|
||||
-- custom boot card (replaces the Nintendo / GAME FREAK copyright
|
||||
-- card; no (c) glyph in the charmap, keep it ASCII-safe)
|
||||
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(credit, (160 - #credit * 8) / 2, 64)
|
||||
Font.draw("bryanthaboi", (160 - 11 * 8) / 2, 80)
|
||||
|
||||
+102
-9
@@ -3,7 +3,9 @@
|
||||
-- box and the Pokédex.
|
||||
|
||||
local Font = require("src.render.Font")
|
||||
local Runtime = require("src.mods.Runtime")
|
||||
local Theme = require("src.ui.Theme")
|
||||
local Strings = require("src.core.Strings")
|
||||
|
||||
local ListMenu = {}
|
||||
ListMenu.__index = ListMenu
|
||||
@@ -15,9 +17,37 @@ function ListMenu:sgbPalettes(game)
|
||||
end
|
||||
|
||||
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)
|
||||
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)
|
||||
self.game = game
|
||||
self.title = title
|
||||
@@ -28,6 +58,12 @@ function ListMenu.new(game, title, items, opts)
|
||||
self.onCancel = opts.onCancel
|
||||
self.footer = opts.footer
|
||||
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
|
||||
-- scripted mode (the old man tutorial): update() runs the script
|
||||
-- every frame INSTEAD of reading input -- DisplayListMenuID's old-man
|
||||
@@ -46,6 +82,42 @@ function ListMenu.new(game, title, items, opts)
|
||||
return self
|
||||
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)
|
||||
if self.script then
|
||||
self.script(self)
|
||||
@@ -59,14 +131,20 @@ function ListMenu:update(dt)
|
||||
end
|
||||
return
|
||||
end
|
||||
|
||||
local moved = false
|
||||
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
|
||||
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
|
||||
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
|
||||
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
|
||||
self.onSelectKey(self.items[self.index], self)
|
||||
elseif input:wasPressed("b") then
|
||||
@@ -80,10 +158,22 @@ function ListMenu:update(dt)
|
||||
end
|
||||
return
|
||||
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
|
||||
if self.index - self.scroll < 1 then self.scroll = self.index - 1 end
|
||||
|
||||
if not moved then syncScroll(self) end
|
||||
end
|
||||
|
||||
-- remove current item (e.g. consumed); keeps cursor valid
|
||||
@@ -103,7 +193,7 @@ function ListMenu:draw()
|
||||
love.graphics.setColor(0, 0, 0, 1)
|
||||
Font.draw(self.title, 8, 4)
|
||||
if #self.items == 0 then
|
||||
Font.draw("Nothing here.", 16, 64)
|
||||
Font.draw(Strings("Nothing here."), 16, 64)
|
||||
end
|
||||
for row = 1, self.rows do
|
||||
local i = self.scroll + row
|
||||
@@ -112,7 +202,10 @@ function ListMenu:draw()
|
||||
local y = 8 + row * 16
|
||||
Font.draw(item.label, 16, y)
|
||||
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
|
||||
love.graphics.circle("fill", bx, by, 3.5)
|
||||
love.graphics.setColor(1, 1, 1, 1)
|
||||
|
||||
@@ -57,4 +57,32 @@ function ModUI.removeLabel(items, label)
|
||||
return items
|
||||
end
|
||||
|
||||
-- Oak speech step-list helpers (intro.oak_speech.build). Anchored on
|
||||
-- stable step.id values the same way insertBefore anchors on labels.
|
||||
local function stepIndex(steps, id)
|
||||
for i, step in ipairs(steps) do
|
||||
if step.id == id then return i end
|
||||
end
|
||||
return nil
|
||||
end
|
||||
|
||||
function ModUI.insertStepBefore(steps, anchorId, step)
|
||||
local i = stepIndex(steps, anchorId)
|
||||
table.insert(steps, i or (#steps + 1), step)
|
||||
return steps
|
||||
end
|
||||
|
||||
function ModUI.insertStepAfter(steps, anchorId, step)
|
||||
local i = stepIndex(steps, anchorId)
|
||||
table.insert(steps, i and (i + 1) or (#steps + 1), step)
|
||||
return steps
|
||||
end
|
||||
|
||||
function ModUI.removeStep(steps, id)
|
||||
for i = #steps, 1, -1 do
|
||||
if steps[i].id == id then table.remove(steps, i) end
|
||||
end
|
||||
return steps
|
||||
end
|
||||
|
||||
return ModUI
|
||||
|
||||
+12
-11
@@ -4,6 +4,7 @@
|
||||
-- can't be forgotten; B / CANCEL gives up on the new move.
|
||||
|
||||
local Font = require("src.render.Font")
|
||||
local Strings = require("src.core.Strings")
|
||||
|
||||
local MoveLearnMenu = {}
|
||||
MoveLearnMenu.__index = MoveLearnMenu
|
||||
@@ -43,9 +44,9 @@ function MoveLearnMenu:enter()
|
||||
local name = self:monName()
|
||||
self.selecting = false
|
||||
game.stack:push(TextBox.new(game,
|
||||
("%s is\ntrying to learn\v%s!\fBut, %s\ncan't learn more\vthan 4 moves!\f")
|
||||
:format(name, mdef.name, name) ..
|
||||
("Delete an older\nmove to make room\vfor %s?"):format(mdef.name),
|
||||
Strings("%s is\ntrying to learn\v%s!\fBut, %s\ncan't learn more\vthan 4 moves!\f",
|
||||
name, mdef.name, name) ..
|
||||
Strings("Delete an older\nmove to make room\vfor %s?", mdef.name),
|
||||
nil, {
|
||||
choice = function(yes)
|
||||
if yes then
|
||||
@@ -76,7 +77,7 @@ function MoveLearnMenu:update(dt)
|
||||
-- HMCantDeleteText, then back to the forget list
|
||||
local TextBox = require("src.render.TextBox")
|
||||
self.game.stack:push(TextBox.new(self.game,
|
||||
"HM techniques\ncan't be deleted!"))
|
||||
Strings("HM techniques\ncan't be deleted!")))
|
||||
return
|
||||
end
|
||||
local mdef = self.game.data.moves[self.newMoveId]
|
||||
@@ -96,7 +97,7 @@ function MoveLearnMenu:confirmAbandon()
|
||||
local mdef = game.data.moves[self.newMoveId]
|
||||
self.selecting = false
|
||||
game.stack:push(TextBox.new(game,
|
||||
("Abandon learning\n%s?"):format(mdef.name), nil, {
|
||||
Strings("Abandon learning\n%s?", mdef.name), nil, {
|
||||
choice = function(yes)
|
||||
if yes then self:finish(false) else self:enter() end
|
||||
end,
|
||||
@@ -113,11 +114,11 @@ function MoveLearnMenu:finish(learned)
|
||||
local msg
|
||||
if learned then
|
||||
-- OneTwoAndText/PoofText/ForgotAndText
|
||||
msg = ("1, 2 and... Poof!\f%s forgot\n%s!\fAnd...\f%s learned\n%s!")
|
||||
:format(name, self.forgot, name, mdef.name)
|
||||
msg = Strings("1, 2 and... Poof!\f%s forgot\n%s!\fAnd...\f%s learned\n%s!",
|
||||
name, self.forgot, name, mdef.name)
|
||||
else
|
||||
-- DidNotLearnText
|
||||
msg = ("%s\ndid not learn\v%s!"):format(name, mdef.name)
|
||||
msg = Strings("%s\ndid not learn\v%s!", name, mdef.name)
|
||||
end
|
||||
game.stack:push(TextBox.new(game, msg, function()
|
||||
if self.onDone then self.onDone(learned) end
|
||||
@@ -133,12 +134,12 @@ function MoveLearnMenu:draw()
|
||||
for i, mv in ipairs(self.mon.moves) do
|
||||
Font.draw(self.game.data.moves[mv.id].name, 48, (5 + i) * 8)
|
||||
end
|
||||
Font.draw("CANCEL", 48, (6 + #self.mon.moves) * 8)
|
||||
Font.draw(Strings("CANCEL"), 48, (6 + #self.mon.moves) * 8)
|
||||
Font.drawCode(CURSOR, 40, (5 + self.index) * 8)
|
||||
-- WhichMoveToForgetText in the bottom dialogue box
|
||||
Font.drawBox(0, 12, 20, 6)
|
||||
Font.draw("Which move should", 8, 14 * 8)
|
||||
Font.draw("be forgotten?", 8, 16 * 8)
|
||||
Font.draw(Strings("Which move should"), 8, 14 * 8)
|
||||
Font.draw(Strings("be forgotten?"), 8, 16 * 8)
|
||||
love.graphics.setColor(1, 1, 1, 1)
|
||||
end
|
||||
|
||||
|
||||
+43
-11
@@ -5,10 +5,15 @@
|
||||
-- given, a "NEW NAME" + presets menu is shown first
|
||||
-- (engine/menus/main_menu.asm name lists).
|
||||
-- Pops itself from the stack, then calls opts.onDone(name).
|
||||
--
|
||||
-- ui.naming.grid may replace either page; keep an "ED" cell and a
|
||||
-- single-cell case-switch row so confirm / case-flip keep working.
|
||||
|
||||
local Font = require("src.render.Font")
|
||||
local Runtime = require("src.mods.Runtime")
|
||||
local Sound = require("src.core.Sound")
|
||||
local Theme = require("src.ui.Theme")
|
||||
local Strings = require("src.core.Strings")
|
||||
|
||||
local NamingScreen = {}
|
||||
NamingScreen.__index = NamingScreen
|
||||
@@ -37,14 +42,30 @@ local GRID_LOWER = {
|
||||
{ "-", "?", "!", "♂", "♀", "/", ".", ",", "ED" },
|
||||
{ "UPPER CASE" },
|
||||
}
|
||||
local CASE_ROW = 6
|
||||
local ED_ROW, ED_COL = 5, 9
|
||||
|
||||
-- locate the ED confirm cell and the case-switch row on a (possibly
|
||||
-- modded) grid; falls back to vanilla coordinates
|
||||
local function findMeta(grid)
|
||||
local caseRow, edRow, edCol = #grid, 5, 9
|
||||
for r, row in ipairs(grid) do
|
||||
if #row == 1 and (row[1] == "lower case" or row[1] == "UPPER CASE"
|
||||
or row[1] == "lower" or row[1] == "UPPER") then
|
||||
caseRow = r
|
||||
end
|
||||
for c, cell in ipairs(row) do
|
||||
if cell == "ED" then edRow, edCol = r, c end
|
||||
end
|
||||
end
|
||||
return caseRow, edRow, edCol
|
||||
end
|
||||
|
||||
local function sameGrid(grid) return grid end
|
||||
|
||||
function NamingScreen.new(game, opts)
|
||||
opts = opts or {}
|
||||
local self = setmetatable({}, NamingScreen)
|
||||
self.game = game
|
||||
self.title = opts.title or "YOUR NAME?"
|
||||
self.title = opts.title or Strings("YOUR NAME?")
|
||||
self.presets = opts.presets
|
||||
self.maxLen = opts.maxLen or 7
|
||||
self.default = opts.default
|
||||
@@ -58,7 +79,7 @@ end
|
||||
function NamingScreen:enter()
|
||||
if self.presets and #self.presets > 0 then
|
||||
local Menu = require("src.ui.Menu")
|
||||
local items = { { label = "NEW NAME" } }
|
||||
local items = { { label = Strings("NEW NAME") } }
|
||||
for _, preset in ipairs(self.presets) do
|
||||
table.insert(items, {
|
||||
label = preset,
|
||||
@@ -86,16 +107,27 @@ function NamingScreen:confirm()
|
||||
end
|
||||
|
||||
function NamingScreen:grid()
|
||||
return self.lower and GRID_LOWER or GRID_UPPER
|
||||
local base = self.lower and GRID_LOWER or GRID_UPPER
|
||||
if not Runtime.wantsHook("ui.naming.grid") then return base end
|
||||
local hooked = Runtime.call("ui.naming.grid", sameGrid, base, {
|
||||
lower = self.lower and true or false,
|
||||
title = self.title,
|
||||
maxLen = self.maxLen,
|
||||
game = self.game,
|
||||
})
|
||||
if type(hooked) ~= "table" or #hooked == 0 then return base end
|
||||
return hooked
|
||||
end
|
||||
|
||||
-- Gen 1 jumps the cursor to ED once the name is full.
|
||||
function NamingScreen:jumpToEnd()
|
||||
self.row, self.col = ED_ROW, ED_COL
|
||||
local _, edRow, edCol = findMeta(self:grid())
|
||||
self.row, self.col = edRow, edCol
|
||||
end
|
||||
|
||||
function NamingScreen:update(dt)
|
||||
local GRID = self:grid()
|
||||
local caseRow, edRow, edCol = findMeta(GRID)
|
||||
local input = self.game.input
|
||||
if input:wasPressed("start") then
|
||||
self:confirm()
|
||||
@@ -107,28 +139,28 @@ function NamingScreen:update(dt)
|
||||
end
|
||||
if input:wasPressed("up") then
|
||||
-- wrapping up from the top row lands on the case-switch cell
|
||||
self.row = self.row > 1 and self.row - 1 or CASE_ROW
|
||||
self.row = self.row > 1 and self.row - 1 or caseRow
|
||||
self.col = math.min(self.col, #GRID[self.row])
|
||||
elseif input:wasPressed("down") then
|
||||
self.row = self.row < #GRID and self.row + 1 or 1
|
||||
self.col = math.min(self.col, #GRID[self.row])
|
||||
elseif input:wasPressed("left") then
|
||||
-- no horizontal movement on the case-switch row
|
||||
if self.row ~= CASE_ROW then
|
||||
if self.row ~= caseRow then
|
||||
self.col = self.col > 1 and self.col - 1 or #GRID[self.row]
|
||||
end
|
||||
elseif input:wasPressed("right") then
|
||||
if self.row ~= CASE_ROW then
|
||||
if self.row ~= caseRow then
|
||||
self.col = self.col < #GRID[self.row] and self.col + 1 or 1
|
||||
end
|
||||
elseif input:wasPressed("b") then
|
||||
table.remove(self.glyphs)
|
||||
elseif input:wasPressed("a") then
|
||||
if self.row == ED_ROW and self.col == ED_COL then
|
||||
if self.row == edRow and self.col == edCol then
|
||||
self:confirm()
|
||||
return
|
||||
end
|
||||
if self.row == CASE_ROW then
|
||||
if self.row == caseRow then
|
||||
self.lower = not self.lower
|
||||
return
|
||||
end
|
||||
|
||||
+371
-75
@@ -1,16 +1,20 @@
|
||||
-- The intro sequence (engine/movie/oak_speech/oak_speech.asm): Oak's
|
||||
-- welcome, the NIDORINO show-off, player and rival naming, and the
|
||||
-- closing "legend is about to unfold" text followed by the shrink-away:
|
||||
-- the player pic collapses through ShrinkPic1/ShrinkPic2 into the
|
||||
-- overworld walking sprite before the fade to white. Uses the real
|
||||
-- extracted texts (_OakSpeechText1/2A/2B/3, _IntroducePlayerText,
|
||||
-- _IntroduceRivalText) with literal fallbacks.
|
||||
-- closing "legend is about to unfold" text followed by the shrink-away.
|
||||
--
|
||||
-- Steps are a data table so mods can reshape the whole speech through
|
||||
-- hooks:wrap("intro.oak_speech.build"). Vanilla ids stay stable so a
|
||||
-- mod can insertBefore("name_player", ...) without counting indices.
|
||||
-- Calls onDone() after popping itself.
|
||||
|
||||
local Assets = require("src.render.Assets")
|
||||
local Sound = require("src.core.Sound")
|
||||
local Music = require("src.core.Music")
|
||||
local Logger = require("src.core.Logger")
|
||||
local Runtime = require("src.mods.Runtime")
|
||||
local TextBox = require("src.render.TextBox")
|
||||
local Font = require("src.render.Font")
|
||||
local Strings = require("src.core.Strings")
|
||||
|
||||
local OakSpeech = {}
|
||||
OakSpeech.__index = OakSpeech
|
||||
@@ -49,18 +53,164 @@ local function textOr(game, key)
|
||||
return (t and t[key]) or FALLBACKS[key]
|
||||
end
|
||||
|
||||
-- through Assets.resolve so an enabled mod's overrides/ shadows these the
|
||||
-- same way it shadows every other generated asset
|
||||
local function tryImage(path)
|
||||
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
|
||||
end
|
||||
|
||||
-- Resolve a pic descriptor to (image, flip).
|
||||
-- Descriptors:
|
||||
-- "oak" | "rival" | "player" shorthand
|
||||
-- { type = "trainer", id = "OPP_PROF_OAK" }
|
||||
-- { type = "pokemon", id = "PIKACHU", flip = true }
|
||||
-- { type = "player", path = "..." } optional override path
|
||||
-- { type = "image", path = "..." }
|
||||
-- { type = "sprite", id = "SPRITE_RED" }
|
||||
function OakSpeech.resolvePic(game, desc, speech)
|
||||
if desc == nil then return nil, false end
|
||||
if type(desc) == "string" then
|
||||
if desc == "oak" then
|
||||
desc = { type = "trainer", id = "OPP_PROF_OAK" }
|
||||
elseif desc == "rival" then
|
||||
desc = { type = "trainer", id = "OPP_RIVAL1" }
|
||||
elseif desc == "player" then
|
||||
desc = { type = "player" }
|
||||
else
|
||||
-- bare species id
|
||||
desc = { type = "pokemon", id = desc }
|
||||
end
|
||||
end
|
||||
local t = desc.type
|
||||
if t == "trainer" then
|
||||
if speech and desc.id == "OPP_PROF_OAK" and speech.oakPic then
|
||||
return speech.oakPic, false
|
||||
end
|
||||
if speech and desc.id == "OPP_RIVAL1" and speech.rivalPic then
|
||||
return speech.rivalPic, false
|
||||
end
|
||||
local trainers = game.data.trainers or {}
|
||||
local tr = trainers[desc.id]
|
||||
return tryImage(tr and tr.pic), false
|
||||
elseif t == "pokemon" then
|
||||
if speech and desc.id == speech.demoSpecies and speech.demoPic then
|
||||
return speech.demoPic, desc.flip and true or false
|
||||
end
|
||||
local path = require("src.pokemon.Sprites").path(
|
||||
game.data, desc.id, "front", { kind = "oak" })
|
||||
return tryImage(path), desc.flip and true or false
|
||||
elseif t == "player" then
|
||||
if speech and speech.playerPic and not desc.path then
|
||||
return speech.playerPic, false
|
||||
end
|
||||
if desc.path then return tryImage(desc.path), false end
|
||||
return tryImage(require("src.pokemon.Sprites").playerPath(
|
||||
game.data, "front", { kind = "intro" })), false
|
||||
elseif t == "image" then
|
||||
return tryImage(desc.path), desc.flip and true or false
|
||||
elseif t == "sprite" then
|
||||
local sp = game.data.sprites and game.data.sprites[desc.id]
|
||||
return tryImage(sp and sp.image), desc.flip and true or false
|
||||
end
|
||||
return nil, false
|
||||
end
|
||||
|
||||
-- Vanilla step list. Ids are the stable anchors mods insert around.
|
||||
function OakSpeech.defaultSteps(speech)
|
||||
return {
|
||||
{
|
||||
id = "oak_welcome",
|
||||
kind = "say",
|
||||
textKey = "_OakSpeechText1",
|
||||
pic = "oak",
|
||||
reveal = "fade",
|
||||
},
|
||||
{
|
||||
id = "demo_mon",
|
||||
kind = "demo",
|
||||
},
|
||||
{
|
||||
id = "world_spiel",
|
||||
kind = "say",
|
||||
textKey = "_OakSpeechText2B",
|
||||
},
|
||||
{
|
||||
id = "ask_player_name",
|
||||
kind = "say",
|
||||
textKey = "_IntroducePlayerText",
|
||||
pic = "player",
|
||||
},
|
||||
{
|
||||
id = "name_player",
|
||||
kind = "name",
|
||||
who = "player",
|
||||
title = Strings("YOUR NAME?"),
|
||||
presetsWho = "player",
|
||||
presetsFallback = { "RED", "ASH", "JACK" },
|
||||
},
|
||||
{
|
||||
id = "ask_rival_name",
|
||||
kind = "say",
|
||||
textKey = "_IntroduceRivalText",
|
||||
pic = "rival",
|
||||
},
|
||||
{
|
||||
id = "name_rival",
|
||||
kind = "name",
|
||||
who = "rival",
|
||||
title = Strings("HIS NAME?"),
|
||||
presetsWho = "rival",
|
||||
presetsFallback = { "BLUE", "GARY", "JOHN" },
|
||||
},
|
||||
{
|
||||
id = "legend",
|
||||
kind = "say",
|
||||
textKey = "_OakSpeechText3",
|
||||
pic = "player",
|
||||
},
|
||||
{
|
||||
id = "shrink",
|
||||
kind = "shrink",
|
||||
},
|
||||
}
|
||||
end
|
||||
|
||||
-- list helpers for intro.oak_speech.build wrappers (also on ModUI)
|
||||
local function indexOfId(steps, id)
|
||||
for i, step in ipairs(steps) do
|
||||
if step.id == id then return i end
|
||||
end
|
||||
return nil
|
||||
end
|
||||
|
||||
function OakSpeech.insertBefore(steps, anchorId, step)
|
||||
local i = indexOfId(steps, anchorId)
|
||||
table.insert(steps, i or (#steps + 1), step)
|
||||
return steps
|
||||
end
|
||||
|
||||
function OakSpeech.insertAfter(steps, anchorId, step)
|
||||
local i = indexOfId(steps, anchorId)
|
||||
table.insert(steps, i and (i + 1) or (#steps + 1), step)
|
||||
return steps
|
||||
end
|
||||
|
||||
function OakSpeech.removeId(steps, id)
|
||||
for i = #steps, 1, -1 do
|
||||
if steps[i].id == id then table.remove(steps, i) end
|
||||
end
|
||||
return steps
|
||||
end
|
||||
|
||||
function OakSpeech.new(game, onDone)
|
||||
local self = setmetatable({}, OakSpeech)
|
||||
self.game = game
|
||||
self.onDone = onDone
|
||||
self.step = 0
|
||||
self.pic = nil
|
||||
self.answers = {}
|
||||
local trainers = game.data.trainers or {}
|
||||
self.oakPic = tryImage(trainers.OPP_PROF_OAK and trainers.OPP_PROF_OAK.pic)
|
||||
self.rivalPic = tryImage(trainers.OPP_RIVAL1 and trainers.OPP_RIVAL1.pic)
|
||||
@@ -69,13 +219,15 @@ function OakSpeech.new(game, onDone)
|
||||
-- the show-off mon and the name length cap come from data; the vanilla
|
||||
-- literals stay as the fallbacks
|
||||
self.demoSpecies = oakGfx.demoSpecies or "NIDORINO"
|
||||
local demo = game.data.pokemon and game.data.pokemon[self.demoSpecies]
|
||||
self.demoPic = tryImage(demo and demo.spriteFront)
|
||||
local demoPath = require("src.pokemon.Sprites").path(
|
||||
game.data, self.demoSpecies, "front", { kind = "oak" })
|
||||
self.demoPic = tryImage(demoPath)
|
||||
local constants = game.data.constants or {}
|
||||
self.nameLen = constants.playerNameLength or 7
|
||||
-- RedPicFront (gfx/player/red.png, shared with the trainer card) and
|
||||
-- the ShrinkPic1/ShrinkPic2 frames (gfx/player/shrink{1,2}.png)
|
||||
self.playerPic = tryImage("assets/generated/trainer_card/red.png")
|
||||
self.playerPic = tryImage(require("src.pokemon.Sprites").playerPath(
|
||||
game.data, "front", { kind = "intro" }))
|
||||
self.shrinkPic1 = tryImage(oakGfx.shrink1
|
||||
or "assets/generated/intro/shrink1.png")
|
||||
self.shrinkPic2 = tryImage(oakGfx.shrink2
|
||||
@@ -87,9 +239,26 @@ function OakSpeech.new(game, onDone)
|
||||
return self
|
||||
end
|
||||
|
||||
function OakSpeech:buildSteps()
|
||||
local steps = OakSpeech.defaultSteps(self)
|
||||
local hooked = Runtime.call("intro.oak_speech.build",
|
||||
function(s) return s end, steps, self)
|
||||
if type(hooked) ~= "table" then
|
||||
Logger.error("intro.oak_speech.build returned %s; keeping vanilla steps",
|
||||
type(hooked))
|
||||
return steps
|
||||
end
|
||||
return hooked
|
||||
end
|
||||
|
||||
function OakSpeech:enter()
|
||||
-- MUSIC_ROUTES2 plays under the whole speech (oak_speech.asm:43-48)
|
||||
Music.play(self.game.data, self.cfg.music or "Music_Routes2")
|
||||
self.answers = {}
|
||||
self.steps = self:buildSteps()
|
||||
if Runtime.wants("intro.oak_speech.started") then
|
||||
Runtime.emit("intro.oak_speech.started", { speech = self, steps = self.steps })
|
||||
end
|
||||
self:advance()
|
||||
end
|
||||
|
||||
@@ -97,80 +266,187 @@ function OakSpeech:say(key, next)
|
||||
self.game.stack:push(TextBox.new(self.game, textOr(self.game, key), next))
|
||||
end
|
||||
|
||||
local STEPS = {
|
||||
-- 1. Oak's welcome (Oak's pic fades in first, FadeInIntroPic)
|
||||
function(self)
|
||||
self.pic = self.oakPic
|
||||
self:revealPic("fade", function()
|
||||
self:say("_OakSpeechText1", function() self:advance() end)
|
||||
function OakSpeech:sayText(text, next, opts)
|
||||
self.game.stack:push(TextBox.new(self.game, text, next, opts))
|
||||
end
|
||||
|
||||
function OakSpeech:stepText(step)
|
||||
if step.text then return step.text end
|
||||
if step.textKey then return textOr(self.game, step.textKey) end
|
||||
return ""
|
||||
end
|
||||
|
||||
function OakSpeech:applyPic(step)
|
||||
if step.pic == nil then return end
|
||||
local img, flip = OakSpeech.resolvePic(self.game, step.pic, self)
|
||||
if img then
|
||||
self.pic = img
|
||||
self.picFlip = flip or false
|
||||
elseif step.pic == "player" or (type(step.pic) == "table" and step.pic.type == "player") then
|
||||
-- mirror the old fallback: player pic missing → oak
|
||||
self.pic = self.playerPic or self.oakPic
|
||||
self.picFlip = false
|
||||
end
|
||||
end
|
||||
|
||||
function OakSpeech:recordAnswer(step, index, label, value)
|
||||
if value == nil then value = label end
|
||||
if step.saveKey then
|
||||
self.answers[step.saveKey] = value
|
||||
end
|
||||
if Runtime.wants("intro.oak_speech.answered") then
|
||||
Runtime.emit("intro.oak_speech.answered", {
|
||||
speech = self,
|
||||
step = step,
|
||||
index = index,
|
||||
label = label,
|
||||
value = value,
|
||||
saveKey = step.saveKey,
|
||||
})
|
||||
end
|
||||
end
|
||||
|
||||
function OakSpeech:afterReveal(step, fn)
|
||||
if step.reveal then
|
||||
self:revealPic(step.reveal, fn)
|
||||
else
|
||||
fn()
|
||||
end
|
||||
end
|
||||
|
||||
function OakSpeech:runCry(step)
|
||||
local cry = step.cry
|
||||
if not cry then return end
|
||||
if cry == true then
|
||||
if type(step.pic) == "string" and step.pic ~= "oak"
|
||||
and step.pic ~= "rival" and step.pic ~= "player" then
|
||||
cry = step.pic
|
||||
elseif type(step.pic) == "table" and step.pic.type == "pokemon" then
|
||||
cry = step.pic.id
|
||||
else
|
||||
return
|
||||
end
|
||||
end
|
||||
Sound.playCry(self.game.data, cry)
|
||||
end
|
||||
|
||||
function OakSpeech:runStep(step)
|
||||
local kind = step.kind or "say"
|
||||
if kind == "say" then
|
||||
self:applyPic(step)
|
||||
self:afterReveal(step, function()
|
||||
self:runCry(step)
|
||||
self:sayText(self:stepText(step), function() self:advance() end)
|
||||
end)
|
||||
end,
|
||||
-- 2. NIDORINO show-off: the front sprite is mirrored horizontally
|
||||
-- (LoadFlippedFrontSpriteByMonIndex) and wipes in from the right
|
||||
-- (MovePicLeft), then its cry sounds and the text prints
|
||||
function(self)
|
||||
elseif kind == "demo" then
|
||||
-- NIDORINO show-off: mirrored front sprite + wipe + cry + text 2A
|
||||
self.pic = self.demoPic
|
||||
self.picFlip = true
|
||||
self:revealPic("wipe", function()
|
||||
Sound.playCry(self.game.data, self.demoSpecies)
|
||||
self:say("_OakSpeechText2A", function() self:advance() end)
|
||||
self:say(Strings("_OakSpeechText2A"), function() self:advance() end)
|
||||
end)
|
||||
end,
|
||||
-- 3. the rest of the world-of-POKéMON spiel
|
||||
function(self)
|
||||
self:say("_OakSpeechText2B", function() self:advance() end)
|
||||
end,
|
||||
-- 4. "First, what is your name?" over the player's own pic
|
||||
-- (RedPicFront, oak_speech.asm:86-91) then the naming screen
|
||||
function(self)
|
||||
self.pic = self.playerPic or self.oakPic
|
||||
self:say("_IntroducePlayerText", function() self:advance() end)
|
||||
end,
|
||||
function(self)
|
||||
elseif kind == "name" then
|
||||
local who = step.who or "player"
|
||||
local presets = step.presets
|
||||
or namePresets(self.game, step.presetsWho or who,
|
||||
step.presetsFallback or { "RED" })
|
||||
require("src.ui.Screens").push(self.game, "NamingScreen", {
|
||||
title = "YOUR NAME?",
|
||||
presets = namePresets(self.game, "player", { "RED", "ASH", "JACK" }),
|
||||
maxLen = self.nameLen,
|
||||
title = step.title or (who == "rival" and "HIS NAME?" or Strings("YOUR NAME?")),
|
||||
presets = presets,
|
||||
maxLen = step.maxLen or self.nameLen,
|
||||
onDone = function(name)
|
||||
self.game.save.player.name = name
|
||||
if who == "rival" then
|
||||
self.game.save.player.rival = name
|
||||
else
|
||||
self.game.save.player.name = name
|
||||
end
|
||||
self:recordAnswer(step, 1, name, name)
|
||||
self:advance()
|
||||
end,
|
||||
})
|
||||
end,
|
||||
-- 6. the rival introduction and naming
|
||||
function(self)
|
||||
self.pic = self.rivalPic
|
||||
self:say("_IntroduceRivalText", function() self:advance() end)
|
||||
end,
|
||||
function(self)
|
||||
require("src.ui.Screens").push(self.game, "NamingScreen", {
|
||||
title = "HIS NAME?",
|
||||
presets = namePresets(self.game, "rival", { "BLUE", "GARY", "JOHN" }),
|
||||
maxLen = self.nameLen,
|
||||
onDone = function(name)
|
||||
self.game.save.player.rival = name
|
||||
self:advance()
|
||||
end,
|
||||
})
|
||||
end,
|
||||
-- 8. "your very own POKéMON legend is about to unfold!" over the
|
||||
-- player pic again (oak_speech.asm:105-113)
|
||||
function(self)
|
||||
self.pic = self.playerPic or self.oakPic
|
||||
self:say("_OakSpeechText3", function() self:advance() end)
|
||||
end,
|
||||
-- 9. SFX_SHRINK: the pic collapses through the two shrink frames
|
||||
-- into the walking sprite, then fades to white (oak_speech.asm
|
||||
-- .next, lines 115-166). Not skippable, like the DelayFrames
|
||||
-- chain it ports.
|
||||
function(self)
|
||||
elseif kind == "choice" then
|
||||
self:applyPic(step)
|
||||
self:afterReveal(step, function()
|
||||
self:runCry(step)
|
||||
local function openMenu()
|
||||
local Menu = require("src.ui.Menu")
|
||||
local items = {}
|
||||
for i, label in ipairs(step.choices or {}) do
|
||||
items[i] = {
|
||||
label = label,
|
||||
onSelect = function()
|
||||
local value = label
|
||||
if step.values and step.values[i] ~= nil then
|
||||
value = step.values[i]
|
||||
end
|
||||
self:recordAnswer(step, i, label, value)
|
||||
self:advance()
|
||||
end,
|
||||
}
|
||||
end
|
||||
self.game.stack:push(Menu.new(self.game, items, {
|
||||
cancelable = step.cancelable == true,
|
||||
tx = step.tx or 4,
|
||||
ty = step.ty or 0,
|
||||
tw = step.tw or 12,
|
||||
th = step.th,
|
||||
}))
|
||||
end
|
||||
local text = self:stepText(step)
|
||||
if text ~= "" then
|
||||
self:sayText(text, openMenu)
|
||||
else
|
||||
openMenu()
|
||||
end
|
||||
end)
|
||||
elseif kind == "yesno" then
|
||||
self:applyPic(step)
|
||||
self:afterReveal(step, function()
|
||||
self:runCry(step)
|
||||
self:sayText(self:stepText(step), nil, {
|
||||
defaultNo = step.defaultNo,
|
||||
choice = function(yes)
|
||||
local label = yes and "YES" or "NO"
|
||||
local value = yes
|
||||
if step.values then
|
||||
if yes then
|
||||
value = step.values[1]
|
||||
else
|
||||
value = step.values[2]
|
||||
end
|
||||
end
|
||||
self:recordAnswer(step, yes and 1 or 2, label, value)
|
||||
self:advance()
|
||||
end,
|
||||
})
|
||||
end)
|
||||
elseif kind == "pic" then
|
||||
-- show a sprite with an optional reveal, no text
|
||||
self:applyPic(step)
|
||||
self:afterReveal(step, function()
|
||||
self:runCry(step)
|
||||
self:advance()
|
||||
end)
|
||||
elseif kind == "shrink" then
|
||||
Sound.play(self.game.data, "Shrink")
|
||||
-- the OakSpeechText3 box holds its last page on screen through the
|
||||
-- shrink (pokered text boxes persist until overwritten)
|
||||
self.shrinkText = self:lastPageLines("_OakSpeechText3")
|
||||
-- prefer an explicit shrink text key; fall back to the legend beat
|
||||
local key = step.textKey or "_OakSpeechText3"
|
||||
self.shrinkText = self:lastPageLines(key)
|
||||
self.shrink = { frame = 0 }
|
||||
end,
|
||||
}
|
||||
elseif kind == "fn" then
|
||||
-- full escape hatch: step.run(speech, done)
|
||||
if type(step.run) == "function" then
|
||||
step.run(self, function() self:advance() end)
|
||||
else
|
||||
self:advance()
|
||||
end
|
||||
else
|
||||
Logger.warn("oak speech unknown step kind %s (id=%s); skipping",
|
||||
tostring(kind), tostring(step.id))
|
||||
self:advance()
|
||||
end
|
||||
end
|
||||
|
||||
-- the last two visible lines of a text's final page, pre-encoded
|
||||
function OakSpeech:lastPageLines(key)
|
||||
@@ -202,15 +478,31 @@ end
|
||||
function OakSpeech:advance()
|
||||
self.step = self.step + 1
|
||||
self.picFlip = false
|
||||
local fn = STEPS[self.step]
|
||||
if fn then
|
||||
fn(self)
|
||||
local steps = self.steps
|
||||
if not steps then
|
||||
-- enter() builds steps; keep a path for callers that advance early
|
||||
steps = self:buildSteps()
|
||||
self.steps = steps
|
||||
end
|
||||
local step = steps[self.step]
|
||||
if step then
|
||||
if Runtime.wants("intro.oak_speech.step") then
|
||||
Runtime.emit("intro.oak_speech.step", {
|
||||
speech = self, step = step, index = self.step,
|
||||
})
|
||||
end
|
||||
self:runStep(step)
|
||||
else
|
||||
self:finish()
|
||||
end
|
||||
end
|
||||
|
||||
function OakSpeech:finish()
|
||||
if Runtime.wants("intro.oak_speech.finished") then
|
||||
Runtime.emit("intro.oak_speech.finished", {
|
||||
speech = self, answers = self.answers,
|
||||
})
|
||||
end
|
||||
-- the map theme starts with the overworld beneath (the original's
|
||||
-- special warp into Pallet Town)
|
||||
local ow = self.game.overworld
|
||||
@@ -257,6 +549,10 @@ function OakSpeech:update(dt)
|
||||
elseif s.frame >= 79 and s.frame <= 102 then
|
||||
self.fadeLevel = math.floor((s.frame - 79) / 8) + 1
|
||||
elseif s.frame > 102 then
|
||||
-- clear before finish(): a finished-listener that pushes a state gets
|
||||
-- ITS state popped in the speech's place, and a live shrink would call
|
||||
-- finish() again next frame, re-firing the event every frame (#308)
|
||||
self.shrink = nil
|
||||
self:finish()
|
||||
end
|
||||
end
|
||||
@@ -267,8 +563,8 @@ function OakSpeech:draw()
|
||||
if self.pic then
|
||||
-- IntroDisplayPicCenteredOrUpperRight centered: the 7x7-tile pic
|
||||
-- area sits at hlcoord 6,4 = (48,32); smaller mon pics pad inside
|
||||
-- it like the sprite buffer does ((8 - w) >> 1 tiles across,
|
||||
-- bottom-aligned)
|
||||
-- it like the sprite buffer does ((8 - w) >> 1) tiles across,
|
||||
-- bottom-aligned
|
||||
local w, h = self.pic:getDimensions()
|
||||
local x = 48 + math.floor((8 - w / 8) / 2) * 8
|
||||
local y = 32 + (7 - h / 8) * 8
|
||||
|
||||
+25
-21
@@ -22,6 +22,7 @@ local Logger = require("src.core.Logger")
|
||||
local Runtime = require("src.mods.Runtime")
|
||||
local OptionRows = require("src.ui.OptionRows")
|
||||
local Renderer = require("src.render.Renderer")
|
||||
local Strings = require("src.core.Strings")
|
||||
|
||||
local OptionsMenu = {}
|
||||
OptionsMenu.__index = OptionsMenu
|
||||
@@ -116,14 +117,14 @@ local function sameRows(_, rows) return rows end
|
||||
-- ladder's, so the save.options mutations are unchanged
|
||||
local function buildRows(game)
|
||||
local rows = {
|
||||
{ id = "textSpeed", label = "TEXT SPEED",
|
||||
{ id = "textSpeed", label = Strings("TEXT SPEED"),
|
||||
value = function(g) return SPEEDS[speedIndex(g)][2] end,
|
||||
step = function(g)
|
||||
local i = speedIndex(g) % #SPEEDS + 1
|
||||
g.save.options.textSpeed = SPEEDS[i][1]
|
||||
return true
|
||||
end },
|
||||
{ id = "animations", label = "BATTLE ANIMATION",
|
||||
{ id = "animations", label = Strings("BATTLE ANIMATION"),
|
||||
value = function(g)
|
||||
return g.save.options.animations == false and "OFF" or "ON"
|
||||
end,
|
||||
@@ -132,7 +133,7 @@ local function buildRows(game)
|
||||
o.animations = o.animations == false and true or false
|
||||
return true
|
||||
end },
|
||||
{ id = "battleStyle", label = "BATTLE STYLE",
|
||||
{ id = "battleStyle", label = Strings("BATTLE STYLE"),
|
||||
value = function(g)
|
||||
return g.save.options.battleStyle == "set" and "SET" or "SHIFT"
|
||||
end,
|
||||
@@ -141,7 +142,7 @@ local function buildRows(game)
|
||||
o.battleStyle = o.battleStyle == "set" and "shift" or "set"
|
||||
return true
|
||||
end },
|
||||
{ id = "ruleset", label = "RULESET",
|
||||
{ id = "ruleset", label = Strings("RULESET"),
|
||||
value = function(g) return rulesetName(g) end,
|
||||
step = function(g, dir)
|
||||
local ids = rulesetIds(g)
|
||||
@@ -150,7 +151,7 @@ local function buildRows(game)
|
||||
g.save.options.ruleset = ids[wrapIndex(i - 1 + dir, #ids) + 1]
|
||||
return true
|
||||
end },
|
||||
{ id = "musicVol", label = "MUSIC VOL",
|
||||
{ id = "musicVol", label = Strings("MUSIC VOL"),
|
||||
value = function(g) return volLabel(g.save.options.musicVol) end,
|
||||
step = function(g, dir)
|
||||
local o = g.save.options
|
||||
@@ -158,7 +159,7 @@ local function buildRows(game)
|
||||
require("src.core.Music").setVolumeLevel(o.musicVol)
|
||||
return true
|
||||
end },
|
||||
{ id = "sfxVol", label = "SFX VOL",
|
||||
{ id = "sfxVol", label = Strings("SFX VOL"),
|
||||
value = function(g) return volLabel(g.save.options.sfxVol) end,
|
||||
step = function(g, dir)
|
||||
local o = g.save.options
|
||||
@@ -166,7 +167,7 @@ local function buildRows(game)
|
||||
require("src.core.Sound").setVolumeLevel(o.sfxVol)
|
||||
return true
|
||||
end },
|
||||
{ id = "musicFilter", label = "MUSIC FILTER",
|
||||
{ id = "musicFilter", label = Strings("MUSIC FILTER"),
|
||||
value = function(g)
|
||||
return FILTERS[(g.save.options.musicFilter or 0) + 1]
|
||||
end,
|
||||
@@ -176,7 +177,7 @@ local function buildRows(game)
|
||||
require("src.core.Music").setFilterLevel(o.musicFilter)
|
||||
return true
|
||||
end },
|
||||
{ id = "colors", label = "COLORS",
|
||||
{ id = "colors", label = Strings("COLORS"),
|
||||
value = function(g)
|
||||
return PaletteFX.modeLabel(g.save.options.colors or "gbc")
|
||||
end,
|
||||
@@ -188,7 +189,7 @@ local function buildRows(game)
|
||||
PaletteFX.setMode(o.colors)
|
||||
return true
|
||||
end },
|
||||
{ id = "tilt", label = "TILT",
|
||||
{ id = "tilt", label = Strings("TILT"),
|
||||
value = function(g) return Tilt.levelLabel(g.save.options.tilt or 0) end,
|
||||
step = function(g, dir)
|
||||
local o = g.save.options
|
||||
@@ -205,7 +206,7 @@ local function buildRows(game)
|
||||
end
|
||||
return true
|
||||
end },
|
||||
{ id = "gbcfx", label = "GBC FX",
|
||||
{ id = "gbcfx", label = Strings("GBC FX"),
|
||||
value = function(g)
|
||||
return GBCFX.levelLabel(g.save.options.gbcfx or 0)
|
||||
end,
|
||||
@@ -215,7 +216,7 @@ local function buildRows(game)
|
||||
GBCFX.setLevel(o.gbcfx)
|
||||
return true
|
||||
end },
|
||||
{ id = "zoom", label = "ZOOM",
|
||||
{ id = "zoom", label = Strings("ZOOM"),
|
||||
value = function(g)
|
||||
return Zoom.offsetLabel(g.save.options.zoom or 0)
|
||||
end,
|
||||
@@ -230,7 +231,7 @@ local function buildRows(game)
|
||||
Zoom.offset = off
|
||||
return true
|
||||
end },
|
||||
{ id = "voidFill", label = "VOID FILL",
|
||||
{ id = "voidFill", label = Strings("VOID FILL"),
|
||||
value = function(g)
|
||||
return TileRenderer.voidFillLabel(g.save.options.voidFill)
|
||||
end,
|
||||
@@ -246,7 +247,7 @@ local function buildRows(game)
|
||||
TileRenderer.setVoidFill(o.voidFill)
|
||||
return true
|
||||
end },
|
||||
{ id = "videoMode", label = "VIDEO MODE",
|
||||
{ id = "videoMode", label = Strings("VIDEO MODE"),
|
||||
value = function(g)
|
||||
return VideoMode.modeLabel(g.save.options.videoMode)
|
||||
end,
|
||||
@@ -259,7 +260,7 @@ local function buildRows(game)
|
||||
-- hard render cap (issue #88): bounds the present rate so a
|
||||
-- driver-forced vsync-off run cannot spin at thousands of FPS. Logic
|
||||
-- is fixed-step off dt, so this touches presentation only.
|
||||
{ id = "fpsCap", label = "MAX FPS",
|
||||
{ id = "fpsCap", label = Strings("MAX FPS"),
|
||||
value = function(g)
|
||||
return FrameCap.label(g.save.options.fpsCap)
|
||||
end,
|
||||
@@ -271,7 +272,7 @@ local function buildRows(game)
|
||||
end },
|
||||
-- fast-forward the logic clock only; music and sfx keep their tempo
|
||||
-- (src/core/GameSpeed.lua), so this is safe to leave on
|
||||
{ id = "speed", label = "GAME SPEED",
|
||||
{ id = "speed", label = Strings("GAME SPEED"),
|
||||
value = function(g)
|
||||
return GameSpeed.levelLabel(g.save.options.speed)
|
||||
end,
|
||||
@@ -282,17 +283,17 @@ local function buildRows(game)
|
||||
end },
|
||||
-- the manager's discoverable home (18-mod-manager-ux); inert until
|
||||
-- opened, so the row costs a vanilla install nothing
|
||||
{ id = "mods", label = "MODS",
|
||||
{ id = "mods", label = Strings("MODS"),
|
||||
value = function(g)
|
||||
local status = g.modStatus or {}
|
||||
return ("%d INSTALLED"):format(#(status.available or {}))
|
||||
return Strings("%d INSTALLED", #(status.available or {}))
|
||||
end,
|
||||
activate = function(g)
|
||||
require("src.ui.Screens").push(g, "ManagerState")
|
||||
end },
|
||||
-- rebinding UI (gap C2, 12-ui-extensibility 4.4); captured inputs
|
||||
-- live in options.bindings, so the row costs a vanilla install nothing
|
||||
{ id = "controls", label = "CONTROLS",
|
||||
{ id = "controls", label = Strings("CONTROLS"),
|
||||
activate = function(g)
|
||||
require("src.ui.Screens").push(g, "BindingsMenu")
|
||||
end },
|
||||
@@ -328,7 +329,8 @@ local function buildRows(game)
|
||||
return rows
|
||||
end
|
||||
|
||||
function OptionsMenu.new(game)
|
||||
function OptionsMenu.new(game, opts)
|
||||
opts = opts or {}
|
||||
local rows = buildRows(game)
|
||||
local hooked = Runtime.call("ui.options.rows", sameRows, game, rows)
|
||||
if type(hooked) == "table" then
|
||||
@@ -337,8 +339,8 @@ function OptionsMenu.new(game)
|
||||
Logger.error("ui.options.rows returned %s; keeping the vanilla rows",
|
||||
type(hooked))
|
||||
end
|
||||
return setmetatable({ game = game, rows = rows, index = 1, scroll = 0 },
|
||||
OptionsMenu)
|
||||
return setmetatable({ game = game, rows = rows, index = 1, scroll = 0,
|
||||
onCancel = opts.onCancel }, OptionsMenu)
|
||||
end
|
||||
|
||||
function OptionsMenu:update(dt)
|
||||
@@ -361,9 +363,11 @@ function OptionsMenu:update(dt)
|
||||
changed = row.step(self.game, dir) and true or false
|
||||
elseif input:wasPressed("a") then -- CANCEL
|
||||
self.game.stack:pop()
|
||||
if self.onCancel then self.onCancel() end
|
||||
end
|
||||
elseif input:wasPressed("b") or input:wasPressed("start") then
|
||||
self.game.stack:pop()
|
||||
if self.onCancel then self.onCancel() end
|
||||
end
|
||||
if changed and self.game.writeOptions then
|
||||
self.game:writeOptions()
|
||||
|
||||
+40
-33
@@ -17,6 +17,7 @@ local Screens = require("src.ui.Screens")
|
||||
local Theme = require("src.ui.Theme")
|
||||
local FieldDefaults = require("src.world.FieldDefaults")
|
||||
local Map = require("src.world.Map")
|
||||
local Strings = require("src.core.Strings")
|
||||
|
||||
local PartyMenu = {}
|
||||
PartyMenu.__index = PartyMenu
|
||||
@@ -95,6 +96,7 @@ local function drawIcon(game, mon, x, y, selected, counter)
|
||||
name = def and def.dex and icons.byDex and icons.byDex[def.dex]
|
||||
path = name and icons.icons and icons.icons[name]
|
||||
end
|
||||
path = require("src.pokemon.Sprites").iconPath(game.data, mon, path, { name = name })
|
||||
if not path then return end
|
||||
if iconImages[path] == nil then
|
||||
-- resolve through Assets so an overrides/ or transform-derived icon
|
||||
@@ -203,7 +205,7 @@ function PartyMenu:update(dt)
|
||||
self.game.save.flashLit = true
|
||||
self.game.stack:push(TextBox.new(self.game,
|
||||
self.game.data.text._FlashLightsAreaText
|
||||
or "A blinding FLASH\nlights the area!", function()
|
||||
or Strings("A blinding FLASH\nlights the area!"), function()
|
||||
self.game.stack:push(Transition.whiteFlash(self.game))
|
||||
end))
|
||||
return
|
||||
@@ -246,7 +248,7 @@ function PartyMenu:update(dt)
|
||||
current = "_CurrentTooFastText",
|
||||
no_place = "_SurfingNoPlaceToGetOffText" })[reason]
|
||||
or "_NoSurfingHereText"
|
||||
local txt = (self.game.data.text[key] or "No SURFing here!")
|
||||
local txt = (self.game.data.text[key] or Strings("No SURFing here!"))
|
||||
:gsub("{RAM:wNameBuffer}", mon.nickname or def.name)
|
||||
if reason == "no_place" then
|
||||
-- .cannotStopSurfing prints _SurfingNoPlaceToGetOffText but
|
||||
@@ -277,7 +279,7 @@ function PartyMenu:update(dt)
|
||||
local def = self.game.data.pokemon[mon.species]
|
||||
local key = (reason == "no_badge") and "_NewBadgeRequiredText"
|
||||
or "_NothingToCutText"
|
||||
local txt = (self.game.data.text[key] or "Nothing to CUT!")
|
||||
local txt = (self.game.data.text[key] or Strings("Nothing to CUT!"))
|
||||
:gsub("{RAM:wNameBuffer}", mon.nickname or def.name)
|
||||
self.game.stack:push(TextBox.new(self.game, txt))
|
||||
return -- .loop: submenu stays open behind the message
|
||||
@@ -299,9 +301,9 @@ function PartyMenu:update(dt)
|
||||
self.game.stack:pop() -- close the party menu (jp .goBackToMap)
|
||||
ow.strengthActive = true
|
||||
local t1 = (self.game.data.text._UsedStrengthText
|
||||
or "{RAM:wNameBuffer} used\nSTRENGTH."):gsub("{RAM:wNameBuffer}", name)
|
||||
or Strings("{RAM:wNameBuffer} used\nSTRENGTH.")):gsub("{RAM:wNameBuffer}", name)
|
||||
local t2 = (self.game.data.text._CanMoveBouldersText
|
||||
or "{RAM:wNameBuffer} can\nmove boulders."):gsub("{RAM:wNameBuffer}", name)
|
||||
or Strings("{RAM:wNameBuffer} can\nmove boulders.")):gsub("{RAM:wNameBuffer}", name)
|
||||
self.game.stack:push(TextBox.new(self.game, t1, function()
|
||||
self.game.stack:push(TextBox.new(self.game, t2, function()
|
||||
self.game.stack:push(Transition.whiteFlash(self.game))
|
||||
@@ -347,7 +349,7 @@ function PartyMenu:update(dt)
|
||||
or user.hp <= heal then
|
||||
self.softboiledFrom = nil
|
||||
local TextBox = require("src.render.TextBox")
|
||||
self.game.stack:push(TextBox.new(self.game, "It won't have\nany effect."))
|
||||
self.game.stack:push(TextBox.new(self.game, Strings("It won't have\nany effect.")))
|
||||
else
|
||||
user.hp = user.hp - heal
|
||||
mon.hp = math.min(mon.stats.hp, mon.hp + heal)
|
||||
@@ -356,7 +358,7 @@ function PartyMenu:update(dt)
|
||||
local def = self.game.data.pokemon[mon.species]
|
||||
local TextBox = require("src.render.TextBox")
|
||||
self.game.stack:push(TextBox.new(self.game,
|
||||
("%s's HP\nwas restored!"):format(mon.nickname or def.name)))
|
||||
Strings("%s's HP\nwas restored!", mon.nickname or def.name)))
|
||||
end
|
||||
elseif self.swapFrom then
|
||||
if self.swapFrom ~= self.index then
|
||||
@@ -374,14 +376,14 @@ function PartyMenu:update(dt)
|
||||
local ow = self.game.overworld
|
||||
if self.battle and self.onSwitch then
|
||||
-- SwitchStatsCancelText (core.asm PartyMenuOrRockOrRun)
|
||||
items = { { label = "SWITCH", action = "battle_switch" },
|
||||
{ label = "STATS", action = "stats" },
|
||||
{ label = "CANCEL", action = "cancel" } }
|
||||
items = { { label = Strings("SWITCH"), action = "battle_switch" },
|
||||
{ label = Strings("STATS"), action = "stats" },
|
||||
{ label = Strings("CANCEL"), action = "cancel" } }
|
||||
else
|
||||
-- STATS/SWITCH plus this mon's field moves (start_sub_menus.asm
|
||||
-- builds the same dynamic list)
|
||||
items = { { label = "STATS", action = "stats" },
|
||||
{ label = "SWITCH", action = "switch" } }
|
||||
items = { { label = Strings("STATS"), action = "stats" },
|
||||
{ label = Strings("SWITCH"), action = "switch" } }
|
||||
-- Field moves (HMs/TMs) are usable out of battle even when the mon
|
||||
-- is fainted -- Gen 1 does not require HP for Cut/Fly/Surf/etc.
|
||||
-- Battle still excludes this list via `not self.battle`. Softboiled
|
||||
@@ -394,35 +396,35 @@ function PartyMenu:update(dt)
|
||||
for _, mv in ipairs(mon.moves) do
|
||||
if mv.id == "FLY" and outside
|
||||
and self.game.save.inventory.THUNDERBADGE then
|
||||
table.insert(items, { label = "FLY", action = "fly" })
|
||||
table.insert(items, { label = Strings("FLY"), action = "fly" })
|
||||
elseif mv.id == "FLASH" and ow.dark
|
||||
and self.game.save.inventory.BOULDERBADGE then
|
||||
table.insert(items, { label = "FLASH", action = "flash" })
|
||||
table.insert(items, { label = Strings("FLASH"), action = "flash" })
|
||||
elseif mv.id == "CUT" and self.game.save.inventory.CASCADEBADGE then
|
||||
-- CUT/SURF/STRENGTH are party-menu field moves too
|
||||
-- (start_sub_menus.asm .outOfBattleMovePointers); listed here
|
||||
-- with the same list-time badge filter this file already uses
|
||||
-- for FLY/FLASH. The facing-tile/activation check happens on
|
||||
-- selection (useCutFieldMove/useSurfFieldMove).
|
||||
table.insert(items, { label = "CUT", action = "cut" })
|
||||
table.insert(items, { label = Strings("CUT"), action = "cut" })
|
||||
elseif mv.id == "SURF" and self.game.save.inventory.SOULBADGE then
|
||||
table.insert(items, { label = "SURF", action = "surf" })
|
||||
table.insert(items, { label = Strings("SURF"), action = "surf" })
|
||||
elseif mv.id == "STRENGTH" and self.game.save.inventory.RAINBOWBADGE then
|
||||
table.insert(items, { label = "STRENGTH", action = "strength" })
|
||||
table.insert(items, { label = Strings("STRENGTH"), action = "strength" })
|
||||
elseif mv.id == "SOFTBOILED" then
|
||||
table.insert(items, { label = "SOFTBOILED", action = "softboiled" })
|
||||
table.insert(items, { label = Strings("SOFTBOILED"), action = "softboiled" })
|
||||
elseif mv.id == "TELEPORT" and outside then
|
||||
-- TELEPORT works only OUTDOORS (start_sub_menus.asm
|
||||
-- .teleport -> CheckIfInOutsideMap); dark maps don't
|
||||
-- block it
|
||||
table.insert(items, { label = "TELEPORT", action = "escape" })
|
||||
table.insert(items, { label = Strings("TELEPORT"), action = "escape" })
|
||||
elseif mv.id == "DIG" and DIG_TILESETS[ow.map.def.tileset]
|
||||
and ow.map.id ~= "AGATHAS_ROOM" then
|
||||
-- DIG runs ItemUseEscapeRope (.dig sets wCurItem =
|
||||
-- ESCAPE_ROPE): usable in the dungeon tilesets of
|
||||
-- escape_rope_tilesets.asm minus Agatha's room, even in
|
||||
-- the dark (Rock Tunnel)
|
||||
table.insert(items, { label = "DIG", action = "escape" })
|
||||
table.insert(items, { label = Strings("DIG"), action = "escape" })
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -455,13 +457,13 @@ function PartyMenu:bottomMessage()
|
||||
return "Use on which one?"
|
||||
elseif self.tmhm then
|
||||
return self.game.data.text._PartyMenuUseTMText
|
||||
or "Use TM on which\nPOKéMON?"
|
||||
or Strings("Use TM on which\nPOKéMON?")
|
||||
elseif self.battle then
|
||||
return self.game.data.text._PartyMenuBattleText
|
||||
or "Bring out which\nPOKéMON?"
|
||||
or Strings("Bring out which\nPOKéMON?")
|
||||
else
|
||||
return self.game.data.text._PartyMenuNormalText
|
||||
or "Choose a POKéMON."
|
||||
or Strings("Choose a POKéMON.")
|
||||
end
|
||||
end
|
||||
|
||||
@@ -479,7 +481,7 @@ function PartyMenu:draw()
|
||||
love.graphics.setColor(0, 0, 0, 1)
|
||||
local party = self.party or self.game.save.party
|
||||
if #party == 0 then
|
||||
Font.draw("No POKéMON!", 16, 64)
|
||||
Font.draw(Strings("No POKéMON!"), 16, 64)
|
||||
end
|
||||
local HudTiles = require("src.render.HudTiles")
|
||||
for i, mon in ipairs(party) do
|
||||
@@ -511,13 +513,13 @@ function PartyMenu:draw()
|
||||
end
|
||||
-- right-aligned so the shorter "ABLE" shares "NOT ABLE"'s right edge
|
||||
if can then
|
||||
Font.draw("ABLE", 120, y + 8)
|
||||
Font.draw(Strings("ABLE"), 120, y + 8)
|
||||
else
|
||||
Font.draw("NOT ABLE", 88, y + 8)
|
||||
Font.draw(Strings("NOT ABLE"), 88, y + 8)
|
||||
end
|
||||
else
|
||||
if mon.hp <= 0 then
|
||||
Font.draw("FNT", 136, y)
|
||||
Font.draw(Strings("FNT"), 136, y)
|
||||
elseif mon.status then
|
||||
Font.draw(mon.status, 136, y)
|
||||
end
|
||||
@@ -527,17 +529,22 @@ function PartyMenu:draw()
|
||||
love.graphics.setColor(0, 0, 0, 1)
|
||||
Font.draw(("%3d/%3d"):format(mon.hp, mon.stats.hp), 104, y + 8)
|
||||
end
|
||||
-- home/pokemon.asm PartyMenuInit seeds wTopMenuItemY/X with 1/0, so the
|
||||
-- cursor sits on the entry's *second* tile row (the level/HP line),
|
||||
-- level with the middle of the two-row icon -- not on the name row that
|
||||
-- entryY returns. Drawing it at y put it a tile too high (#278).
|
||||
local cursorY = y + 8
|
||||
if i == self.index then
|
||||
Font.drawCode(Theme.cursor, 0, y)
|
||||
Font.drawCode(Theme.cursor, 0, cursorY)
|
||||
end
|
||||
if i == self.swapFrom or i == self.softboiledFrom then
|
||||
Font.drawCode(Theme.cursorHollow, 0, y) -- the unfilled swap arrow
|
||||
Font.drawCode(Theme.cursorHollow, 0, cursorY) -- the unfilled swap arrow
|
||||
end
|
||||
end
|
||||
if self.swapFrom then
|
||||
Font.draw("Move to where?", 8, 136)
|
||||
Font.draw(Strings("Move to where?"), 8, 136)
|
||||
elseif self.softboiledFrom then
|
||||
Font.draw("Use on which one?", 8, 136)
|
||||
Font.draw(Strings("Use on which one?"), 8, 136)
|
||||
elseif self.tmhm then
|
||||
-- "Use TM on which\nPOKeMON?" in the standard bottom text box
|
||||
-- (party_menu.asm keeps the message box for the TM/HM menu); box + line
|
||||
@@ -545,14 +552,14 @@ function PartyMenu:draw()
|
||||
Font.drawBox(0, 12, 20, 6)
|
||||
love.graphics.setColor(0, 0, 0, 1)
|
||||
local prompt = self.game.data.text._PartyMenuUseTMText
|
||||
or "Use TM on which\nPOKéMON?"
|
||||
or Strings("Use TM on which\nPOKéMON?")
|
||||
local ly = 112
|
||||
for line in (prompt .. "\n"):gmatch("([^\n]*)\n") do
|
||||
Font.draw(line, 8, ly)
|
||||
ly = ly + 16
|
||||
end
|
||||
elseif self.pickOnly then
|
||||
Font.draw("Use on which one?", 8, 136)
|
||||
Font.draw(Strings("Use on which one?"), 8, 136)
|
||||
else
|
||||
-- default field party menu (StartMenu) and the battle voluntary-switch
|
||||
-- (BattleState:openParty): Gen1 prints PartyMenuNormalText / PartyMenuBattleText
|
||||
|
||||
+15
-11
@@ -10,6 +10,7 @@ local ChoiceBox = require("src.ui.ChoiceBox")
|
||||
local ListMenu = require("src.ui.ListMenu")
|
||||
local Menu = require("src.ui.Menu")
|
||||
local Sound = require("src.core.Sound")
|
||||
local Strings = require("src.core.Strings")
|
||||
|
||||
local PlayerPC = {}
|
||||
|
||||
@@ -76,14 +77,14 @@ local function withdraw(game)
|
||||
askQuantity(game, list, pc[item.value] or 1, item.value, function(qty)
|
||||
local Bag = require("src.inventory.Bag")
|
||||
if not Bag.add(game.save, item.value, qty) then
|
||||
list.footer = "You can't carry\nany more items."
|
||||
list.footer = Strings("You can't carry\nany more items.")
|
||||
return
|
||||
end
|
||||
pc[item.value] = pc[item.value] - qty
|
||||
if pc[item.value] <= 0 then pc[item.value] = nil end
|
||||
refreshRow(list, pc, item.value)
|
||||
Sound.play(game.data, "Withdraw_Deposit")
|
||||
list.footer = ("Withdrew\n%s."):format(itemName(game, item.value))
|
||||
list.footer = Strings("Withdrew\n%s.", itemName(game, item.value))
|
||||
end)
|
||||
end,
|
||||
}))
|
||||
@@ -112,14 +113,14 @@ local function deposit(game)
|
||||
onChoose = function(item, list)
|
||||
askQuantity(game, list, inv[item.value] or 1, item.value, function(qty)
|
||||
if pcFull(game, pc, item.value) then
|
||||
list.footer = "No room left to\nstore items."
|
||||
list.footer = Strings("No room left to\nstore items.")
|
||||
return
|
||||
end
|
||||
require("src.inventory.Bag").remove(game.save, item.value, qty)
|
||||
pc[item.value] = (pc[item.value] or 0) + qty
|
||||
refreshRow(list, inv, item.value)
|
||||
Sound.play(game.data, "Withdraw_Deposit")
|
||||
list.footer = ("%s was\nstored via PC."):format(itemName(game, item.value))
|
||||
list.footer = Strings("%s was\nstored via PC.", itemName(game, item.value))
|
||||
end)
|
||||
end,
|
||||
}))
|
||||
@@ -132,7 +133,7 @@ local function toss(game)
|
||||
onChoose = function(item, list)
|
||||
local def = game.data.items[item.value]
|
||||
if (def and def.keyItem) or item.value:find("^HM_") then
|
||||
list.footer = "That's too impor-\ntant to toss!"
|
||||
list.footer = Strings("That's too impor-\ntant to toss!")
|
||||
return
|
||||
end
|
||||
local QuantityBox = require("src.ui.QuantityBox")
|
||||
@@ -140,13 +141,13 @@ local function toss(game)
|
||||
max = pc[item.value] or 1,
|
||||
onDone = function(qty)
|
||||
if not qty then return end
|
||||
list.footer = ("Toss %s?"):format(itemName(game, item.value))
|
||||
list.footer = Strings("Toss %s?", itemName(game, item.value))
|
||||
game.stack:push(ChoiceBox.new(game, function(yes)
|
||||
if yes then
|
||||
pc[item.value] = pc[item.value] - qty
|
||||
if pc[item.value] <= 0 then pc[item.value] = nil end
|
||||
refreshRow(list, pc, item.value)
|
||||
list.footer = ("Threw away %s."):format(itemName(game, item.value))
|
||||
list.footer = Strings("Threw away %s.", itemName(game, item.value))
|
||||
else
|
||||
list.footer = nil
|
||||
end
|
||||
@@ -160,10 +161,13 @@ end
|
||||
function PlayerPC.new(game)
|
||||
game.save.pcItems = game.save.pcItems or {}
|
||||
return Menu.new(game, {
|
||||
{ label = "WITHDRAW ITEM", onSelect = function() withdraw(game) end },
|
||||
{ label = "DEPOSIT ITEM", onSelect = function() deposit(game) end },
|
||||
{ label = "TOSS ITEM", onSelect = function() toss(game) end },
|
||||
{ label = "LOG OFF" },
|
||||
-- keepOpen so B in the item lists returns here instead of dropping the
|
||||
-- whole PC session (players_pc.asm re-shows the PC menu); same pattern
|
||||
-- as BoxMenu's rows
|
||||
{ label = Strings("WITHDRAW ITEM"), keepOpen = true, onSelect = function() withdraw(game) end },
|
||||
{ label = Strings("DEPOSIT ITEM"), keepOpen = true, onSelect = function() deposit(game) end },
|
||||
{ label = Strings("TOSS ITEM"), keepOpen = true, onSelect = function() toss(game) end },
|
||||
{ label = Strings("LOG OFF") },
|
||||
-- silent PC session (BIT_NO_MENU_BUTTON_SOUND); players_pc.asm
|
||||
-- PlayersPCMenu TextBoxBorder (0,0) b=8 c=14 → 16x10
|
||||
}, { tx = 0, ty = 0, tw = 16, th = 10, noSound = true })
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
-- Minimal Pokédex: dex-ordered list with seen/owned markers.
|
||||
|
||||
local ListMenu = require("src.ui.ListMenu")
|
||||
local Strings = require("src.core.Strings")
|
||||
|
||||
local PokedexMenu = {}
|
||||
|
||||
@@ -9,7 +10,8 @@ function PokedexMenu:sgbPalettes(game)
|
||||
return require("src.render.PaletteFX").wholeNamed(game.data, "BROWNMON")
|
||||
end
|
||||
|
||||
function PokedexMenu.new(game)
|
||||
function PokedexMenu.new(game, opts)
|
||||
opts = opts or {}
|
||||
local dex = game.save.pokedex or { seen = {}, owned = {} }
|
||||
local byDex = {}
|
||||
for species, def in pairs(game.data.pokemon) do
|
||||
@@ -45,8 +47,9 @@ function PokedexMenu.new(game)
|
||||
end
|
||||
end
|
||||
local list = ListMenu.new(game, "POKéDEX", items, {
|
||||
footer = ("SEEN %d OWNED %d"):format(seen, owned),
|
||||
footer = Strings("SEEN %d OWNED %d", seen, owned),
|
||||
pageJump = true, -- Left/Right page jumps like the original
|
||||
onCancel = opts.onCancel, -- B returns to the start menu when opened from it
|
||||
onChoose = function(item)
|
||||
if not item.value then return end
|
||||
-- the DATA / CRY / AREA / QUIT choice (engine/menus/pokedex.asm
|
||||
@@ -55,16 +58,16 @@ function PokedexMenu.new(game)
|
||||
local Menu = require("src.ui.Menu")
|
||||
local Screens = require("src.ui.Screens")
|
||||
game.stack:push(Menu.new(game, {
|
||||
{ label = "DATA", onSelect = function()
|
||||
{ label = Strings("DATA"), onSelect = function()
|
||||
Screens.push(game, "DexEntryMenu", item.value)
|
||||
end },
|
||||
{ label = "CRY", keepOpen = true, onSelect = function()
|
||||
{ label = Strings("CRY"), keepOpen = true, onSelect = function()
|
||||
require("src.core.Sound").playCry(game.data, item.value)
|
||||
end },
|
||||
{ label = "AREA", onSelect = function()
|
||||
{ label = Strings("AREA"), onSelect = function()
|
||||
Screens.push(game, "TownMap", { nestSpecies = item.value })
|
||||
end },
|
||||
{ label = "QUIT" },
|
||||
{ label = Strings("QUIT") },
|
||||
}, { tx = 12, ty = 8, tw = 8, th = 10 }))
|
||||
end,
|
||||
})
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
|
||||
local Font = require("src.render.Font")
|
||||
local SaveData = require("src.core.SaveData")
|
||||
local Strings = require("src.core.Strings")
|
||||
|
||||
local QuarantineReport = {}
|
||||
QuarantineReport.__index = QuarantineReport
|
||||
@@ -38,12 +39,12 @@ local function buildLines(report, meta)
|
||||
end
|
||||
local rows = {}
|
||||
for _, mon in ipairs(report.lostMons or {}) do
|
||||
rows[#rows + 1] = ("%s (%s)"):format(mon.species or "?", mon.from or "?")
|
||||
rows[#rows + 1] = Strings("%s (%s)", mon.species or "?", mon.from or "?")
|
||||
end
|
||||
section(lines, "Moved to LOST box:", rows)
|
||||
rows = {}
|
||||
for _, item in ipairs(report.lostItems or {}) do
|
||||
rows[#rows + 1] = ("%s x%d"):format(item.id or "?", item.count or 1)
|
||||
rows[#rows + 1] = Strings("%s x%d", item.id or "?", item.count or 1)
|
||||
end
|
||||
section(lines, "Items removed:", rows)
|
||||
rows = {}
|
||||
@@ -51,16 +52,16 @@ local function buildLines(report, meta)
|
||||
if map.to then
|
||||
rows[#rows + 1] = ("%s>%s"):format(map.id or "?", map.to)
|
||||
else
|
||||
rows[#rows + 1] = ("%s (%s)"):format(map.id or "?", map.field or "?")
|
||||
rows[#rows + 1] = Strings("%s (%s)", map.id or "?", map.field or "?")
|
||||
end
|
||||
end
|
||||
section(lines, "Location reset:", rows)
|
||||
rows = {}
|
||||
for _, mon in ipairs(report.restoredMons or {}) do
|
||||
rows[#rows + 1] = ("%s to box %d"):format(mon.species or "?", mon.box or 0)
|
||||
rows[#rows + 1] = Strings("%s to box %d", mon.species or "?", mon.box or 0)
|
||||
end
|
||||
for _, item in ipairs(report.restoredItems or {}) do
|
||||
rows[#rows + 1] = ("%s x%d"):format(item.id or "?", item.count or 1)
|
||||
rows[#rows + 1] = Strings("%s x%d", item.id or "?", item.count or 1)
|
||||
end
|
||||
section(lines, "Restored:", rows)
|
||||
local notice = SaveData.modsDiffNotice(report.modsDiff, meta)
|
||||
@@ -113,7 +114,7 @@ function QuarantineReport:draw()
|
||||
love.graphics.rectangle("fill", 0, 0, 160, 144)
|
||||
Font.drawBox(0, 0, 20, 18)
|
||||
love.graphics.setColor(0, 0, 0, 1)
|
||||
Font.draw("LOAD REPORT", 8, 8)
|
||||
Font.draw(Strings("LOAD REPORT"), 8, 8)
|
||||
for row = 1, VISIBLE do
|
||||
local line = self.lines[self.offset + row]
|
||||
if line then Font.draw(line, 8, 12 + row * 8) end
|
||||
@@ -121,7 +122,7 @@ function QuarantineReport:draw()
|
||||
if self.offset < self:maxOffset() then
|
||||
Font.drawCode(require("src.ui.Theme").moreArrow, 144, 124)
|
||||
end
|
||||
Font.draw("A:CONTINUE", 8, 130)
|
||||
Font.draw(Strings("A:CONTINUE"), 8, 130)
|
||||
love.graphics.setColor(1, 1, 1, 1)
|
||||
end
|
||||
|
||||
|
||||
+10
-9
@@ -12,6 +12,7 @@ local ChoiceBox = require("src.ui.ChoiceBox")
|
||||
local ListMenu = require("src.ui.ListMenu")
|
||||
local Menu = require("src.ui.Menu")
|
||||
local QuantityBox = require("src.ui.QuantityBox")
|
||||
local Strings = require("src.core.Strings")
|
||||
|
||||
local ShopMenu = {}
|
||||
|
||||
@@ -33,7 +34,7 @@ local function buy(game, stock)
|
||||
end
|
||||
local greet = txt(game, "_PokemartBuyingGreetingText", "Take your time.")
|
||||
local notEnough = txt(game, "_PokemartNotEnoughMoneyText",
|
||||
"You don't have\nenough money.")
|
||||
Strings("You don't have\nenough money."))
|
||||
local list
|
||||
list = ListMenu.new(game, "BUY", items, {
|
||||
dialogue = true,
|
||||
@@ -56,7 +57,7 @@ local function buy(game, stock)
|
||||
end
|
||||
local cost = qty * def.price
|
||||
-- _PokemartTellBuyPriceText + yes/no confirm
|
||||
list.footer = ("%s?\nThat will be\n¥%d. OK?"):format(def.name, cost)
|
||||
list.footer = Strings("%s?\nThat will be\n¥%d. OK?", def.name, cost)
|
||||
game.stack:push(ChoiceBox.new(game, function(yes)
|
||||
if not yes then
|
||||
list.footer = greet
|
||||
@@ -68,13 +69,13 @@ local function buy(game, stock)
|
||||
end
|
||||
if not Bag.add(game.save, item.value, qty) then
|
||||
list.footer = txt(game, "_PokemartItemBagFullText",
|
||||
"You can't carry\nany more items.")
|
||||
Strings("You can't carry\nany more items."))
|
||||
return
|
||||
end
|
||||
require("src.core.Sound").play(game.data, "Purchase")
|
||||
game.save.money = game.save.money - cost
|
||||
list.footer = txt(game, "_PokemartBoughtItemText",
|
||||
"Here you are!\nThank you!")
|
||||
Strings("Here you are!\nThank you!"))
|
||||
end))
|
||||
end,
|
||||
}))
|
||||
@@ -112,7 +113,7 @@ local function sell(game)
|
||||
-- ITEM_NONE "0" from Blue's House before that pickup was fixed (#11).
|
||||
if not def or def.keyItem or item.value:find("^HM_") then
|
||||
list.footer = txt(game, "_PokemartUnsellableItemText",
|
||||
"I can't put a\nprice on that.")
|
||||
Strings("I can't put a\nprice on that."))
|
||||
return
|
||||
end
|
||||
local unit = math.floor(def.price / 2)
|
||||
@@ -125,7 +126,7 @@ local function sell(game)
|
||||
return
|
||||
end
|
||||
-- _PokemartTellSellPriceText + yes/no confirm
|
||||
list.footer = ("I can pay you\n¥%d for that."):format(unit * qty)
|
||||
list.footer = Strings("I can pay you\n¥%d for that.", unit * qty)
|
||||
game.stack:push(ChoiceBox.new(game, function(yes)
|
||||
if not yes then
|
||||
list.footer = greet
|
||||
@@ -152,9 +153,9 @@ function ShopMenu.new(game, stock, onQuit)
|
||||
-- keepOpen: the mart menu stays underneath its list so closing the
|
||||
-- list lands back here; only QUIT (or B) leaves and fires onQuit
|
||||
local menu = Menu.new(game, {
|
||||
{ label = "BUY", keepOpen = true, onSelect = function() buy(game, stock) end },
|
||||
{ label = "SELL", keepOpen = true, onSelect = function() sell(game) end },
|
||||
{ label = "QUIT", onSelect = onQuit },
|
||||
{ label = Strings("BUY"), keepOpen = true, onSelect = function() buy(game, stock) end },
|
||||
{ label = Strings("SELL"), keepOpen = true, onSelect = function() sell(game) end },
|
||||
{ label = Strings("QUIT"), onSelect = onQuit },
|
||||
}, { tx = 0, ty = 0, tw = 8, th = 8 })
|
||||
menu.onCancel = onQuit
|
||||
return menu
|
||||
|
||||
@@ -45,6 +45,7 @@
|
||||
|
||||
local Font = require("src.render.Font")
|
||||
local Sound = require("src.core.Sound")
|
||||
local Strings = require("src.core.Strings")
|
||||
|
||||
local SlotMachine = {}
|
||||
SlotMachine.__index = SlotMachine
|
||||
@@ -356,7 +357,7 @@ function SlotMachine:resolveWin(win)
|
||||
-- SlotReward300Func prints "Yeah!" (text_pause) before the flash; the port
|
||||
-- shows it in the box while the screen flashes. LinedUpText follows.
|
||||
self.yeah = (sym == "7")
|
||||
self.message = ("%s lined up!\nScored %d coins!"):format(sym, pay)
|
||||
self.message = Strings("%s lined up!\nScored %d coins!", sym, pay)
|
||||
-- .flashScreenLoop: flip rBGP, wait 5 frames, b times. The coins are not
|
||||
-- credited until the player dismisses the "lined up" text (see startPayout).
|
||||
self.stage = "flash"
|
||||
@@ -394,7 +395,7 @@ end
|
||||
-- delay then CloseTextDisplay), otherwise ask "One more go?".
|
||||
function SlotMachine:afterSpin()
|
||||
if coins(self) == 0 then
|
||||
self.message = "Darn!\nRan out of coins!"
|
||||
self.message = Strings("Darn!\nRan out of coins!")
|
||||
self.stage = "message"
|
||||
self.afterMessage = nil
|
||||
self.exitTimer = 60
|
||||
@@ -515,7 +516,7 @@ function SlotMachine:update(dt)
|
||||
self.bet = 3 - self.betIndex
|
||||
if input:wasPressed("a") then
|
||||
if coins(self) < self.bet then
|
||||
self.message = "Not enough\ncoins!"
|
||||
self.message = Strings("Not enough\ncoins!")
|
||||
self.afterMessage = "bet"
|
||||
self.stage = "message"
|
||||
return
|
||||
@@ -671,8 +672,8 @@ function SlotMachine:drawBottom()
|
||||
local by = self.stage == "intro" and 6 or 11
|
||||
Font.drawBox(13, by, 6, 5)
|
||||
love.graphics.setColor(0, 0, 0, 1)
|
||||
Font.draw("YES", 15 * 8, (by + 1) * 8)
|
||||
Font.draw("NO", 15 * 8, (by + 2) * 8)
|
||||
Font.draw(Strings("YES"), 15 * 8, (by + 1) * 8)
|
||||
Font.draw(Strings("NO"), 15 * 8, (by + 2) * 8)
|
||||
Font.drawCode(0xED, 14 * 8, (by + 1 + (self.yesno == 1 and 0 or 1)) * 8)
|
||||
end
|
||||
love.graphics.setColor(1, 1, 1, 1)
|
||||
@@ -723,8 +724,8 @@ function SlotMachine:drawPlain(art)
|
||||
love.graphics.setColor(1, 1, 1, 1)
|
||||
love.graphics.rectangle("fill", 0, 0, 160, 144)
|
||||
love.graphics.setColor(0, 0, 0, 1)
|
||||
Font.draw("SLOT MACHINE", 32, 4)
|
||||
Font.draw(("COINS %4d"):format(coins(self)), 8, 16)
|
||||
Font.draw(Strings("SLOT MACHINE"), 32, 4)
|
||||
Font.draw(Strings("COINS %4d", coins(self)), 8, 16)
|
||||
if art then self:drawReels(art) end
|
||||
love.graphics.setColor(0, 0, 0, 1)
|
||||
Font.draw(">", 12, 56)
|
||||
|
||||
+27
-21
@@ -10,6 +10,7 @@ local Menu = require("src.ui.Menu")
|
||||
local Renderer = require("src.render.Renderer")
|
||||
local Runtime = require("src.mods.Runtime")
|
||||
local Screens = require("src.ui.Screens")
|
||||
local Strings = require("src.core.Strings")
|
||||
|
||||
local StartMenu = {}
|
||||
|
||||
@@ -19,33 +20,38 @@ function StartMenu.new(game)
|
||||
local flags = game.save.flags or {}
|
||||
local items = {}
|
||||
|
||||
-- vanilla start submenus return here on B (RedisplayStartMenu): the
|
||||
-- generic Menu pops the start menu when a row is selected, so each
|
||||
-- submenu gets an onCancel that re-opens it
|
||||
local function reopen() Screens.push(game, "StartMenu") end
|
||||
|
||||
-- POKéDEX: only after Oak hands it over
|
||||
if flags.EVENT_GOT_POKEDEX then
|
||||
table.insert(items, { label = "POKéDEX", onSelect = function()
|
||||
Screens.push(game, "PokedexMenu")
|
||||
table.insert(items, { label = Strings("POKéDEX"), onSelect = function()
|
||||
Screens.push(game, "PokedexMenu", { onCancel = reopen })
|
||||
end })
|
||||
end
|
||||
|
||||
-- POKéMON is always listed (draw_start_menu.asm prints it even with
|
||||
-- an empty party; selecting it then just no-ops)
|
||||
table.insert(items, { label = "POKéMON", onSelect = function()
|
||||
table.insert(items, { label = Strings("POKéMON"), onSelect = function()
|
||||
if #game.save.party == 0 then return end
|
||||
Screens.push(game, "PartyMenu")
|
||||
Screens.push(game, "PartyMenu", { onCancel = reopen })
|
||||
end })
|
||||
|
||||
table.insert(items, { label = "ITEM", onSelect = function()
|
||||
Screens.push(game, "BagMenu")
|
||||
table.insert(items, { label = Strings("ITEM"), onSelect = function()
|
||||
Screens.push(game, "BagMenu", { onCancel = reopen })
|
||||
end })
|
||||
|
||||
-- the player's name opens the trainer card (StartMenu_TrainerInfo)
|
||||
table.insert(items, { label = game.save.player.name or "RED",
|
||||
onSelect = function()
|
||||
Screens.push(game, "TrainerCard")
|
||||
Screens.push(game, "TrainerCard", { onCancel = reopen })
|
||||
end })
|
||||
|
||||
-- SAVE shows the player/badges/dex/time panel then asks to confirm
|
||||
-- (PrintSaveScreenText)
|
||||
table.insert(items, { label = "SAVE", onSelect = function()
|
||||
table.insert(items, { label = Strings("SAVE"), onSelect = function()
|
||||
local TextBox = require("src.render.TextBox")
|
||||
local ChoiceBox = require("src.ui.ChoiceBox")
|
||||
local badges = require("src.inventory.Badges").count(game.data, game.save)
|
||||
@@ -54,32 +60,32 @@ function StartMenu.new(game)
|
||||
owned = owned + 1
|
||||
end
|
||||
local t = math.floor(game.save.playTime or 0)
|
||||
local panel = ("PLAYER %s\nBADGES %d\nPOKéDEX %3d\nTIME %6d:%02d")
|
||||
:format(game.save.player.name or "RED", badges, owned,
|
||||
math.floor(t / 3600), math.floor(t / 60) % 60)
|
||||
local panel = Strings("PLAYER %s\nBADGES %d\nPOKéDEX %3d\nTIME %6d:%02d",
|
||||
game.save.player.name or "RED", badges, owned,
|
||||
math.floor(t / 3600), math.floor(t / 60) % 60)
|
||||
game.stack:push(TextBox.new(game,
|
||||
panel .. "\fWould you like to\nSAVE the game?", function()
|
||||
panel .. Strings("\fWould you like to\nSAVE the game?"), function()
|
||||
game.stack:push(ChoiceBox.new(game, function(yes)
|
||||
if not yes then return end
|
||||
-- "Now saving..." beat before the write (save.asm
|
||||
-- NowSavingString), then GameSavedText + SFX_SAVE
|
||||
game.stack:push(TextBox.new(game, "Now saving...", function()
|
||||
game.stack:push(TextBox.new(game, Strings("Now saving..."), function()
|
||||
game:writeSave()
|
||||
require("src.core.Sound").play(game.data, "Save")
|
||||
game.stack:push(TextBox.new(game,
|
||||
(game.save.player.name or "RED") .. " saved\nthe game!"))
|
||||
Strings("%s saved\nthe game!", game.save.player.name or "RED")))
|
||||
end))
|
||||
end))
|
||||
end))
|
||||
end })
|
||||
|
||||
table.insert(items, { label = "OPTION", onSelect = function()
|
||||
Screens.push(game, "OptionsMenu")
|
||||
table.insert(items, { label = Strings("OPTION"), onSelect = function()
|
||||
Screens.push(game, "OptionsMenu", { onCancel = reopen })
|
||||
end })
|
||||
|
||||
-- LINK needs a party
|
||||
if #game.save.party > 0 then
|
||||
table.insert(items, { label = "LINK", onSelect = function()
|
||||
table.insert(items, { label = Strings("LINK"), onSelect = function()
|
||||
local LinkState = require("src.link.LinkState")
|
||||
game.stack:push(LinkState.new(game))
|
||||
end })
|
||||
@@ -89,7 +95,7 @@ function StartMenu.new(game)
|
||||
-- one discovered mod so a vanilla install's menu is unchanged
|
||||
local status = game.modStatus
|
||||
if status and #(status.available or {}) > 0 then
|
||||
table.insert(items, { label = "MODS", onSelect = function()
|
||||
table.insert(items, { label = Strings("MODS"), onSelect = function()
|
||||
Screens.push(game, "ManagerState")
|
||||
end })
|
||||
end
|
||||
@@ -97,10 +103,10 @@ function StartMenu.new(game)
|
||||
-- the original's EXIT just closed the menu (CloseStartMenu); with a
|
||||
-- window close button covering that, QUIT instead power-cycles back
|
||||
-- to the title after a confirm (defaultNo guards accidental quits)
|
||||
table.insert(items, { label = "QUIT", onSelect = function()
|
||||
table.insert(items, { label = Strings("QUIT"), onSelect = function()
|
||||
local TextBox = require("src.render.TextBox")
|
||||
local ChoiceBox = require("src.ui.ChoiceBox")
|
||||
game.stack:push(TextBox.new(game, "RETURN TO MAIN\nMENU?", function()
|
||||
game.stack:push(TextBox.new(game, Strings("RETURN TO MAIN\nMENU?"), function()
|
||||
game.stack:push(ChoiceBox.new(game, function(yes)
|
||||
if yes then game:returnToTitle() end
|
||||
end, { defaultNo = true }))
|
||||
@@ -150,7 +156,7 @@ function StartMenu.new(game)
|
||||
love.graphics.setColor(0, 0, 0, 1)
|
||||
Font.draw(("%3d"):format(math.floor(safari.steps or 0)), 8, 8)
|
||||
Font.draw("/500", 32, 8)
|
||||
Font.draw("BALL", 8, 24)
|
||||
Font.draw(Strings("BALL"), 8, 24)
|
||||
Font.draw(("%2d"):format(math.floor(safari.balls or 0)), 48, 24)
|
||||
love.graphics.setColor(1, 1, 1, 1)
|
||||
end
|
||||
|
||||
+14
-11
@@ -12,6 +12,7 @@ local Font = require("src.render.Font")
|
||||
-- TypeChart.displayName maps it back to "PSYCHIC", like HallOfFame and the
|
||||
-- battle move-type box already do (#214).
|
||||
local TypeChart = require("src.battle.TypeChart")
|
||||
local Strings = require("src.core.Strings")
|
||||
|
||||
local SummaryMenu = {}
|
||||
SummaryMenu.__index = SummaryMenu
|
||||
@@ -30,9 +31,11 @@ end
|
||||
|
||||
function SummaryMenu.new(game, mon)
|
||||
local self = setmetatable({ game = game, mon = mon, page = 1 }, SummaryMenu)
|
||||
local def = game.data.pokemon[mon.species]
|
||||
if def and def.spriteFront then
|
||||
local ok, img = pcall(love.graphics.newImage, def.spriteFront)
|
||||
local Sprites = require("src.pokemon.Sprites")
|
||||
local path = Sprites.path(game.data, mon.species, "front",
|
||||
{ mon = mon, kind = "summary" })
|
||||
if path then
|
||||
local ok, img = pcall(love.graphics.newImage, path)
|
||||
self.sprite = ok and img or nil
|
||||
end
|
||||
require("src.core.Sound").playCry(game.data, mon.species)
|
||||
@@ -88,7 +91,7 @@ function SummaryMenu:draw()
|
||||
drawLineBox(19, 1, 6, 10)
|
||||
HudTiles.drawHPBar(data, 11, 3, mon, 1) -- wHPBarType 1
|
||||
Font.draw(("%3d/%3d"):format(mon.hp, mon.stats.hp), 96, 32)
|
||||
Font.draw("STATUS/", 72, 48)
|
||||
Font.draw(Strings("STATUS/"), 72, 48)
|
||||
Font.draw(mon.status or "OK", 128, 48)
|
||||
|
||||
-- stats box (0,8) 10x10: names rows 9/11/13/15, values indented
|
||||
@@ -105,27 +108,27 @@ function SummaryMenu:draw()
|
||||
|
||||
-- TYPE1/TYPE2/IDNo/OT column (10,9) with values indented (11,10)
|
||||
drawLineBox(19, 9, 8, 6)
|
||||
Font.draw("TYPE1/", 80, 72)
|
||||
Font.draw(Strings("TYPE1/"), 80, 72)
|
||||
Font.draw(def.types[1] and TypeChart.displayName(def.types[1]) or "", 88, 80)
|
||||
if def.types[2] then
|
||||
Font.draw("TYPE2/", 80, 88)
|
||||
Font.draw(Strings("TYPE2/"), 80, 88)
|
||||
Font.draw(TypeChart.displayName(def.types[2]), 88, 96)
|
||||
end
|
||||
Font.draw("IDNo/", 80, 104)
|
||||
Font.draw(Strings("IDNo/"), 80, 104)
|
||||
-- the trainer ID is rolled at new game (SaveData.newGame) and
|
||||
-- backfilled on load for old saves
|
||||
Font.draw(("%05d"):format(mon.otId or game.save.player.id or 0), 96, 112)
|
||||
Font.draw("OT/", 80, 120)
|
||||
Font.draw(Strings("OT/"), 80, 120)
|
||||
Font.draw(mon.ot or game.save.player.name or "RED", 96, 128)
|
||||
else
|
||||
-- page 2: EXP + the moves with PP (StatusScreen2)
|
||||
drawLineBox(19, 1, 6, 10)
|
||||
Font.draw("EXP POINTS", 72, 24)
|
||||
Font.draw(Strings("EXP POINTS"), 72, 24)
|
||||
Font.draw(("%d"):format(mon.exp), 96, 32)
|
||||
-- StatusScreen2: "LEVEL UP" at (9,5); next-exp PrintNumber 7 cols
|
||||
-- at (7,6); space at (14,6); PrintLevel at (16,6). The old
|
||||
-- "%d to L%d" string at x=88 overflowed the DrawLineBox edge.
|
||||
Font.draw("LEVEL UP", 72, 40)
|
||||
Font.draw(Strings("LEVEL UP"), 72, 40)
|
||||
local Growth = require("src.pokemon.Growth")
|
||||
local nextExp = mon.level < 100
|
||||
and (Growth.expForLevel(def.growthRate, mon.level + 1) - mon.exp) or 0
|
||||
@@ -139,7 +142,7 @@ function SummaryMenu:draw()
|
||||
if mv then
|
||||
local mdef = data.moves[mv.id]
|
||||
Font.draw(mdef.name, 16, y)
|
||||
Font.draw("PP", 88, y + 8)
|
||||
Font.draw(Strings("PP"), 88, y + 8)
|
||||
Font.draw(("%2d/%2d"):format(mv.pp, mdef.pp), 112, y + 8)
|
||||
else
|
||||
Font.draw("-", 16, y)
|
||||
|
||||
+14
-12
@@ -6,6 +6,7 @@
|
||||
local Font = require("src.render.Font")
|
||||
local Music = require("src.core.Music")
|
||||
local GameVersion = require("src.core.GameVersion")
|
||||
local Strings = require("src.core.Strings")
|
||||
|
||||
local TitleState = {}
|
||||
TitleState.__index = TitleState
|
||||
@@ -117,8 +118,9 @@ function TitleState:currentSprite()
|
||||
local species = self.cycleSpecies[self.cycleIndex]
|
||||
local cached = self.sprites[species]
|
||||
if cached == nil then
|
||||
local def = self.game.data.pokemon[species]
|
||||
cached = tryImage(def and def.spriteFront) or false
|
||||
local path = require("src.pokemon.Sprites").path(
|
||||
self.game.data, species, "front", { kind = "title" })
|
||||
cached = tryImage(path) or false
|
||||
self.sprites[species] = cached
|
||||
end
|
||||
return cached or nil
|
||||
@@ -164,19 +166,19 @@ function ContinueInfo:draw()
|
||||
-- box at (4,7), 8x14 content; labels double-spaced from (5,9)
|
||||
Font.drawBox(4, 7, 16, 10)
|
||||
love.graphics.setColor(0, 0, 0, 1)
|
||||
Font.draw("PLAYER", 40, 72)
|
||||
Font.draw(Strings("PLAYER"), 40, 72)
|
||||
Font.draw((save.player and save.player.name) or "RED", 96, 72)
|
||||
local badges = require("src.inventory.Badges").count(self.game.data, save)
|
||||
Font.draw("BADGES", 40, 88)
|
||||
Font.draw(Strings("BADGES"), 40, 88)
|
||||
Font.draw(("%2d"):format(badges), 128, 88)
|
||||
local owned = 0
|
||||
for _ in pairs(save.pokedex and save.pokedex.owned or {}) do
|
||||
owned = owned + 1
|
||||
end
|
||||
Font.draw("POKéDEX", 40, 104)
|
||||
Font.draw(Strings("POKéDEX"), 40, 104)
|
||||
Font.draw(("%3d"):format(owned), 120, 104)
|
||||
local t = math.floor(save.playTime or 0)
|
||||
Font.draw("TIME", 40, 120)
|
||||
Font.draw(Strings("TIME"), 40, 120)
|
||||
Font.draw(("%3d:%02d"):format(math.floor(t / 3600),
|
||||
math.floor(t / 60) % 60), 104, 120)
|
||||
love.graphics.setColor(1, 1, 1, 1)
|
||||
@@ -187,7 +189,7 @@ function TitleState:openMenu()
|
||||
local game = self.game
|
||||
local items = {}
|
||||
if hasSave() then
|
||||
table.insert(items, { label = "CONTINUE", onSelect = function()
|
||||
table.insert(items, { label = Strings("CONTINUE"), onSelect = function()
|
||||
-- peek at the save for the info window; fall through if the
|
||||
-- file can't be read
|
||||
local ok, loaded = pcall(require("src.core.SaveData").load)
|
||||
@@ -198,13 +200,13 @@ function TitleState:openMenu()
|
||||
end
|
||||
end })
|
||||
end
|
||||
table.insert(items, { label = "NEW GAME", onSelect = function()
|
||||
table.insert(items, { label = Strings("NEW GAME"), onSelect = function()
|
||||
if self.onNewGame then self.onNewGame() end
|
||||
end })
|
||||
table.insert(items, { label = "OPTION", onSelect = function()
|
||||
table.insert(items, { label = Strings("OPTION"), onSelect = function()
|
||||
require("src.ui.Screens").push(game, "OptionsMenu")
|
||||
end })
|
||||
table.insert(items, { label = "EXIT GAME", onSelect = function()
|
||||
table.insert(items, { label = Strings("EXIT GAME"), onSelect = function()
|
||||
if love.event and love.event.quit then
|
||||
love.event.quit()
|
||||
end
|
||||
@@ -253,7 +255,7 @@ function TitleState:draw()
|
||||
love.graphics.draw(self.logo, 16, 8)
|
||||
else
|
||||
love.graphics.setColor(0, 0, 0, 1)
|
||||
Font.draw(self.blue and "POKéMON BLUE" or "POKéMON RED",
|
||||
Font.draw(self.blue and "POKéMON BLUE" or Strings("POKéMON RED"),
|
||||
(160 - 12 * 8) / 2, 24)
|
||||
love.graphics.setColor(1, 1, 1, 1)
|
||||
end
|
||||
@@ -289,7 +291,7 @@ function TitleState:draw()
|
||||
love.graphics.setColor(0, 0, 0, 1)
|
||||
-- the copyright row (tile 2,17); copyrightText because field.title's
|
||||
-- copyright key already names the extracted image strip
|
||||
Font.draw(self.title.copyrightText or "2026 bois club games", 1, 136)
|
||||
Font.draw(self.title.copyrightText or Strings("2026 bois club games"), 1, 136)
|
||||
love.graphics.setColor(1, 1, 1, 1)
|
||||
end
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
local Font = require("src.render.Font")
|
||||
local Sound = require("src.core.Sound")
|
||||
local TextBox = require("src.render.TextBox")
|
||||
local Strings = require("src.core.Strings")
|
||||
|
||||
local TradeAnim = {}
|
||||
TradeAnim.__index = TradeAnim
|
||||
@@ -48,8 +49,9 @@ local function dexOf(game, mon)
|
||||
end
|
||||
|
||||
local function spriteOf(game, mon)
|
||||
local def = game.data.pokemon[mon.species]
|
||||
return tryImage(def and def.spriteFront)
|
||||
local path = require("src.pokemon.Sprites").path(game.data, mon.species, "front",
|
||||
{ mon = mon, kind = "trade" })
|
||||
return tryImage(path)
|
||||
end
|
||||
|
||||
local function expand(game, key, subs)
|
||||
@@ -342,7 +344,7 @@ function TradeAnim:drawMonInfo(mon, ot, otId, boxTy)
|
||||
love.graphics.setColor(0, 0, 0, 1)
|
||||
Font.draw(no, 56, y0)
|
||||
Font.draw(speciesName(self.game, mon), 40, y0 + 16)
|
||||
Font.draw("OT/" .. (ot or "????"), 40, y0 + 32)
|
||||
Font.draw(Strings("OT/%s", ot or "????"), 40, y0 + 32)
|
||||
Font.draw(("IDNo.%05d"):format(otId or 0), 40, y0 + 48)
|
||||
love.graphics.setColor(1, 1, 1, 1)
|
||||
end
|
||||
|
||||
+18
-6
@@ -4,8 +4,10 @@
|
||||
-- are built from the real trainer_info.png frame tiles (the patterned
|
||||
-- band + line style).
|
||||
|
||||
local Assets = require("src.render.Assets")
|
||||
local Badges = require("src.inventory.Badges")
|
||||
local Font = require("src.render.Font")
|
||||
local Strings = require("src.core.Strings")
|
||||
|
||||
local TrainerCard = {}
|
||||
TrainerCard.__index = TrainerCard
|
||||
@@ -16,8 +18,11 @@ function TrainerCard:sgbPalettes(game)
|
||||
return require("src.render.PaletteFX").wholeNamed(game.data, "MEWMON")
|
||||
end
|
||||
|
||||
-- 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 ok, img = pcall(love.graphics.newImage, path)
|
||||
if not path then return nil end
|
||||
local ok, img = pcall(love.graphics.newImage, Assets.resolve(path))
|
||||
return ok and img or nil
|
||||
end
|
||||
|
||||
@@ -30,8 +35,10 @@ local function quads16(img, count, stride, x0, y0)
|
||||
return q
|
||||
end
|
||||
|
||||
function TrainerCard.new(game)
|
||||
local self = setmetatable({ game = game }, TrainerCard)
|
||||
function TrainerCard.new(game, opts)
|
||||
opts = opts or {}
|
||||
local self = setmetatable({ game = game, onCancel = opts.onCancel },
|
||||
TrainerCard)
|
||||
local img = tryImage("assets/generated/trainer_card/badges.png")
|
||||
if img then
|
||||
-- badges.2bpp is 8 stacked [face, badge] pairs (DrawBadges FaceBadgeTiles)
|
||||
@@ -60,14 +67,19 @@ function TrainerCard.new(game)
|
||||
end
|
||||
end
|
||||
self.circle = tryImage("assets/generated/trainer_card/circle_tile.png")
|
||||
self.pic = tryImage("assets/generated/trainer_card/red.png")
|
||||
self.pic = tryImage(require("src.pokemon.Sprites").playerPath(
|
||||
game.data, "front", { kind = "trainer_card" }))
|
||||
return self
|
||||
end
|
||||
|
||||
function TrainerCard:update(dt)
|
||||
local input = self.game.input
|
||||
-- either button dismisses the card back to the start menu
|
||||
-- (StartMenu_TrainerInfo: WaitForTextScrollButtonPress then
|
||||
-- RedisplayStartMenu)
|
||||
if input:wasPressed("a") or input:wasPressed("b") then
|
||||
self.game.stack:pop()
|
||||
if self.onCancel then self.onCancel() end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -108,7 +120,7 @@ function TrainerCard:draw()
|
||||
love.graphics.draw(self.pic, 104, 4)
|
||||
end
|
||||
love.graphics.setColor(0, 0, 0, 1)
|
||||
Font.draw("NAME/" .. (save.player.name or "RED"), 16, 16)
|
||||
Font.draw(Strings("NAME/%s", save.player.name or "RED"), 16, 16)
|
||||
Font.draw(("MONEY/¥%d"):format(save.money or 0), 16, 32)
|
||||
local t = math.floor(save.playTime or 0)
|
||||
Font.draw(("TIME/%3d:%02d"):format(math.floor(t / 3600),
|
||||
@@ -117,7 +129,7 @@ function TrainerCard:draw()
|
||||
-- the circle-dotted BADGES banner (TrainerInfo_BadgesText)
|
||||
self:frameBox(0, 8, 20, 3)
|
||||
love.graphics.setColor(0, 0, 0, 1)
|
||||
Font.draw("BADGES", 56, 72)
|
||||
Font.draw(Strings("BADGES"), 56, 72)
|
||||
if self.circle then
|
||||
love.graphics.setColor(1, 1, 1, 1)
|
||||
love.graphics.draw(self.circle, 48, 72)
|
||||
|
||||
@@ -79,6 +79,19 @@ local PLAYER_SPRITES = {
|
||||
bike = "SPRITE_RED_BIKE", fly = "SPRITE_BIRD",
|
||||
}
|
||||
|
||||
-- The player's own trainer art: RedPicBack (the battle back pic, up until
|
||||
-- "Go!"), OldManPicBack (the catch tutorial fights in his place), and
|
||||
-- RedPicFront (gfx/player/red.png, shared by Oak's intro, the trainer card
|
||||
-- and the Hall of Fame). Asset paths, not sprite ids -- these are pics,
|
||||
-- unrelated to the walking sheet in PLAYER_SPRITES above. Resolution runs
|
||||
-- through Sprites.playerPath, so the player.sprite hook can still vary them
|
||||
-- per save after content freezes.
|
||||
local PLAYER_PICS = {
|
||||
back = "assets/generated/battle/redb.png",
|
||||
demoBack = "assets/generated/battle/oldmanb.png",
|
||||
front = "assets/generated/trainer_card/red.png",
|
||||
}
|
||||
|
||||
-- Route22Gate_Script rewrites wLastMap from the player's Y every frame, so
|
||||
-- the north exit leaves onto Route 23 and the south onto Route 22. Rules
|
||||
-- are ordered, first match wins, the last row is the default.
|
||||
@@ -106,6 +119,7 @@ FieldDefaults.FIELD = {
|
||||
fishing = FISHING,
|
||||
safari = SAFARI,
|
||||
playerSprites = PLAYER_SPRITES,
|
||||
playerPics = PLAYER_PICS,
|
||||
lastMapRewrites = LAST_MAP_REWRITES,
|
||||
-- CheckIfInOutsideMap: what counts as "outside" for the wLastMap memory
|
||||
outsideTilesets = { "OVERWORLD", "PLATEAU" },
|
||||
|
||||
@@ -22,6 +22,7 @@ local TextBox = require("src.render.TextBox")
|
||||
local Transition = require("src.render.Transition")
|
||||
local Warp = require("src.world.Warp")
|
||||
local Zoom = require("src.render.Zoom")
|
||||
local Strings = require("src.core.Strings")
|
||||
|
||||
-- isOverworld marks the live world state for WorldAPI's stack scan
|
||||
local OverworldState = { isOpaque = true, isOverworld = true }
|
||||
@@ -452,6 +453,35 @@ end
|
||||
|
||||
-- name -> name so the map.palette chain has a vanilla link to wrap
|
||||
local function samePalette(name) return name end
|
||||
local function sameTod(tod) return tod end
|
||||
|
||||
-- world.tod default: always DAY. A day/night mod returns "NIGHT",
|
||||
-- "MORNING", etc.; the result is cached on the overworld and handed to
|
||||
-- map.palette as ctx.tod so palette swaps can key off the period.
|
||||
function OverworldState:timeOfDay()
|
||||
local tod = self.tod or "DAY"
|
||||
if not Runtime.wantsHook("world.tod") then return tod end
|
||||
local map = self.map
|
||||
local nextTod = Runtime.call("world.tod", sameTod, tod, {
|
||||
map = map,
|
||||
mapId = map and map.id,
|
||||
x = self.player and self.player.cellX,
|
||||
y = self.player and self.player.cellY,
|
||||
steps = self.todSteps or 0,
|
||||
})
|
||||
if type(nextTod) ~= "string" or nextTod == "" then nextTod = tod end
|
||||
if nextTod ~= tod then
|
||||
self.tod = nextTod
|
||||
if Runtime.wants("world.tod_changed") then
|
||||
Runtime.emit("world.tod_changed", {
|
||||
tod = nextTod, previous = tod, mapId = map and map.id,
|
||||
})
|
||||
end
|
||||
else
|
||||
self.tod = nextTod
|
||||
end
|
||||
return self.tod
|
||||
end
|
||||
|
||||
function OverworldState:paletteNameFor(map)
|
||||
local palettes = FieldDefaults.field(Game.data, "palettes")
|
||||
@@ -470,8 +500,9 @@ function OverworldState:paletteNameFor(map)
|
||||
name = (last and paletteLookup(palettes, last, lastDef and lastDef.tileset))
|
||||
or palettes.default
|
||||
end
|
||||
local tod = self:timeOfDay()
|
||||
if not Runtime.wantsHook("map.palette") then return name end
|
||||
return Runtime.call("map.palette", samePalette, name, map)
|
||||
return Runtime.call("map.palette", samePalette, name, map, { tod = tod })
|
||||
end
|
||||
|
||||
-- UI-pass palette (text boxes and menus tint with the current map). OG RED
|
||||
@@ -1210,7 +1241,7 @@ end
|
||||
-- (constants.hmBadges; distinct from constants.hmMoves, the forget gate).
|
||||
-- Gen 1 allows field use from fainted party members (party menu + name
|
||||
-- lookup for Cut/Surf messages); do not require mon.hp > 0 here.
|
||||
function OverworldState:partyKnows(moveId)
|
||||
local function partyKnowsVanilla(moveId)
|
||||
local gate = (FieldDefaults.constant(Game.data, "hmBadges") or {})[moveId]
|
||||
local badge = gate and gate.badge
|
||||
if badge and not Game.save.inventory[badge] then
|
||||
@@ -1224,6 +1255,17 @@ function OverworldState:partyKnows(moveId)
|
||||
return nil
|
||||
end
|
||||
|
||||
function OverworldState:partyKnows(moveId)
|
||||
-- a mod may unlock a field move another way (an HM in the bag, a rental
|
||||
-- mon); next_ is the whole vanilla check, so calling it first keeps
|
||||
-- vanilla answers winning
|
||||
if Runtime.wantsHook("fieldmove.eligibility") then
|
||||
return Runtime.call("fieldmove.eligibility", partyKnowsVanilla, moveId,
|
||||
{ save = Game.save, data = Game.data })
|
||||
end
|
||||
return partyKnowsVanilla(moveId)
|
||||
end
|
||||
|
||||
-- The rejection loop shared by the Good and Super Rods
|
||||
-- (item_effects.asm ItemUseGoodRod .RandomLoop / ReadSuperRodData): an
|
||||
-- odd random byte is no bite; otherwise a 2-bit pick rerolls until it
|
||||
@@ -1282,10 +1324,10 @@ function OverworldState:goFishing(rod)
|
||||
Game.stack:push(TextBox.new(Game, ". . .", function()
|
||||
self.fishing = nil
|
||||
if not enc then
|
||||
Game.stack:push(TextBox.new(Game, "Not even a nibble!"))
|
||||
Game.stack:push(TextBox.new(Game, Strings("Not even a nibble!")))
|
||||
return
|
||||
end
|
||||
Game.stack:push(TextBox.new(Game, "Oh!\nIt's a bite!", function()
|
||||
Game.stack:push(TextBox.new(Game, Strings("Oh!\nIt's a bite!"), function()
|
||||
local BattleState = require("src.battle.BattleState")
|
||||
local battle = BattleState.newWild(Game, enc.species, enc.level, { hooked = true })
|
||||
if Game.save.safari and Map.inRegion(self.map.def, "SAFARI", "SAFARI_ZONE") then
|
||||
@@ -1452,29 +1494,66 @@ function OverworldState:tryBookshelf(fx, fy)
|
||||
if self.map.def.tileset == "MANSION"
|
||||
and self.map:tileAt(fx * 2, fy * 2) == 0x38 then
|
||||
Game.stack:push(TextBox.new(Game, t._DiglettSculptureText
|
||||
or "It's a sculpture\nof DIGLETT."))
|
||||
or Strings("It's a sculpture\nof DIGLETT.")))
|
||||
return true
|
||||
end
|
||||
Game.stack:push(TextBox.new(Game, t._PokemonBooksText
|
||||
or "Crammed full of\nPOKéMON books!"))
|
||||
or Strings("Crammed full of\nPOKéMON books!")))
|
||||
elseif kind == "stuff" then
|
||||
Game.stack:push(TextBox.new(Game, t._PokemonStuffText
|
||||
or "There's a slew of\nPOKéMON stuff!"))
|
||||
or Strings("There's a slew of\nPOKéMON stuff!")))
|
||||
elseif kind == "elevator" then
|
||||
Game.stack:push(TextBox.new(Game, t._ElevatorText
|
||||
or "An elevator!"))
|
||||
or Strings("An elevator!")))
|
||||
elseif kind == "statues" then
|
||||
-- IndigoPlateauStatues: the plaque, then one of the two lines
|
||||
-- keyed by the statue's column (XCoord bit 0)
|
||||
local line = (self.player.cellX % 2 == 0) and t._IndigoPlateauStatuesText2
|
||||
or t._IndigoPlateauStatuesText3
|
||||
Game.stack:push(TextBox.new(Game,
|
||||
(t._IndigoPlateauStatuesText1 or "INDIGO PLATEAU") .. "\f"
|
||||
.. (line or "POKéMON LEAGUE HQ")))
|
||||
(t._IndigoPlateauStatuesText1 or Strings("INDIGO PLATEAU")) .. "\f"
|
||||
.. (line or Strings("POKéMON LEAGUE HQ"))))
|
||||
end
|
||||
return true
|
||||
end
|
||||
|
||||
-- Bench guys are the one hidden-event family whose extracted label is a
|
||||
-- wrapper rather than the string itself: bench_guys.asm defines e.g.
|
||||
-- PewterCityPokecenterBenchGuyText:: as `text_far _PewterCityPokecenterGuyText`,
|
||||
-- so data/generated/field.lua carries the wrapper name while the text sits
|
||||
-- under the far label. The two only coincide for Mt Moon and the Celadon
|
||||
-- hotel, which is why every other bench guy answered with silence (#248).
|
||||
-- pokered's own names are irregular here (Cerulean/Lavender/Vermilion drop
|
||||
-- "City", Cinnabar drops "Island"), so this is a table, not a transform.
|
||||
local BENCH_GUY_TEXT = {
|
||||
ViridianCityPokecenterBenchGuyText = "_ViridianCityPokecenterGuyText",
|
||||
PewterCityPokecenterBenchGuyText = "_PewterCityPokecenterGuyText",
|
||||
CeruleanCityPokecenterBenchGuyText = "_CeruleanPokecenterGuyText",
|
||||
LavenderCityPokecenterBenchGuyText = "_LavenderPokecenterGuyText",
|
||||
VermilionCityPokecenterBenchGuyText = "_VermilionPokecenterGuyText",
|
||||
CeladonCityPokecenterBenchGuyText = "_CeladonCityPokecenterGuyText",
|
||||
FuchsiaCityPokecenterBenchGuyText = "_FuchsiaCityPokecenterGuyText",
|
||||
CinnabarIslandPokecenterBenchGuyText = "_CinnabarPokecenterGuyText",
|
||||
RockTunnelPokecenterBenchGuyText = "_RockTunnelPokecenterGuyText",
|
||||
}
|
||||
|
||||
-- SaffronCityPokecenterBenchGuyText is text_asm: he complains about ROCKET
|
||||
-- until EVENT_BEAT_SILPH_CO_GIOVANNI, then thanks you for clearing them out.
|
||||
-- Takes data/save rather than reading the Game upvalue so tests can resolve
|
||||
-- every label in the table without standing a whole overworld up.
|
||||
function OverworldState.benchGuyText(data, save, label)
|
||||
if not label then return nil end
|
||||
if label == "SaffronCityPokecenterBenchGuyText" then
|
||||
local key = (save and save.flags and save.flags.EVENT_BEAT_SILPH_CO_GIOVANNI)
|
||||
and "_SaffronCityPokecenterGuyText2"
|
||||
or "_SaffronCityPokecenterGuyText1"
|
||||
return data.text[key]
|
||||
end
|
||||
-- the direct name first, so a cache whose extractor already resolved the
|
||||
-- far label keeps working without consulting the table
|
||||
return data.text["_" .. label] or data.text[BENCH_GUY_TEXT[label] or ""]
|
||||
end
|
||||
|
||||
-- Hidden events at the faced cell (data/events/hidden_events.asm):
|
||||
-- HiddenItems give their item once, HiddenCoins fill the COIN CASE,
|
||||
-- StartSlotMachine seats open the minigame. Taken spots persist in
|
||||
@@ -1489,7 +1568,7 @@ function OverworldState:tryHiddenObject(fx, fy)
|
||||
save.hiddenTaken = save.hiddenTaken or {}
|
||||
if save.hiddenTaken[key] then return false end
|
||||
if not require("src.inventory.Bag").add(save, h.item, 1) then
|
||||
Game.stack:push(TextBox.new(Game, "You can't carry\nany more items!"))
|
||||
Game.stack:push(TextBox.new(Game, Strings("You can't carry\nany more items!")))
|
||||
return true
|
||||
end
|
||||
save.hiddenTaken[key] = true
|
||||
@@ -1497,7 +1576,7 @@ function OverworldState:tryHiddenObject(fx, fy)
|
||||
-- hidden items always play SFX_GET_ITEM_2 (hidden_items.asm)
|
||||
require("src.core.Sound").play(Game.data, "Get_Item2")
|
||||
Game.stack:push(TextBox.new(Game,
|
||||
("%s found\n%s!"):format(save.player.name, name)))
|
||||
Strings("%s found\n%s!", save.player.name, name)))
|
||||
return true
|
||||
end
|
||||
end
|
||||
@@ -1511,7 +1590,7 @@ function OverworldState:tryHiddenObject(fx, fy)
|
||||
save.coins = math.min(9999, (save.coins or 0) + h.coins)
|
||||
require("src.core.Sound").play(Game.data, "Get_Item2")
|
||||
Game.stack:push(TextBox.new(Game,
|
||||
("%s found\n%d coins!"):format(save.player.name, h.coins)))
|
||||
Strings("%s found\n%d coins!", save.player.name, h.coins)))
|
||||
return true
|
||||
end
|
||||
end
|
||||
@@ -1523,20 +1602,20 @@ function OverworldState:tryHiddenObject(fx, fy)
|
||||
if h.x == fx and h.y == fy then
|
||||
if h.state == "out_of_order" then
|
||||
Game.stack:push(TextBox.new(Game, txt._GameCornerOutOfOrderText
|
||||
or "OUT OF ORDER\nThis is broken."))
|
||||
or Strings("OUT OF ORDER\nThis is broken.")))
|
||||
elseif h.state == "out_to_lunch" then
|
||||
Game.stack:push(TextBox.new(Game, txt._GameCornerOutToLunchText
|
||||
or "OUT TO LUNCH\nThis is reserved."))
|
||||
or Strings("OUT TO LUNCH\nThis is reserved.")))
|
||||
elseif h.state == "keys" then
|
||||
Game.stack:push(TextBox.new(Game, txt._GameCornerSomeonesKeysText
|
||||
or "Someone's keys!\nThey'll be back."))
|
||||
or Strings("Someone's keys!\nThey'll be back.")))
|
||||
elseif not save.inventory.COIN_CASE then
|
||||
Game.stack:push(TextBox.new(Game, txt._GameCornerCoinCaseText
|
||||
or "A COIN CASE is\nrequired!"))
|
||||
or Strings("A COIN CASE is\nrequired!")))
|
||||
elseif (save.coins or 0) == 0 then
|
||||
-- AbleToPlaySlotsCheck: a COIN CASE with no coins can't play
|
||||
Game.stack:push(TextBox.new(Game, txt._GameCornerNoCoinsText
|
||||
or "You don't have\nany coins!"))
|
||||
or Strings("You don't have\nany coins!")))
|
||||
else
|
||||
-- one machine per visit is secretly lucky
|
||||
-- (wLuckySlotHiddenEventIndex, engine/slots/game_corner_slots.asm)
|
||||
@@ -1579,7 +1658,7 @@ function OverworldState:tryHiddenObject(fx, fy)
|
||||
-- bench guys (data/events/bench_guys.asm)
|
||||
for _, h in ipairs(extras.benchGuys[self.map.id] or {}) do
|
||||
if h.x == fx and h.y == fy and (not h.facing or h.facing == facing) then
|
||||
local text = h.text and Game.data.text["_" .. h.text]
|
||||
local text = OverworldState.benchGuyText(Game.data, save, h.text)
|
||||
if text then
|
||||
Game.stack:push(TextBox.new(Game, text))
|
||||
return true
|
||||
@@ -1596,7 +1675,7 @@ function OverworldState:tryHiddenObject(fx, fy)
|
||||
if gym then
|
||||
local key = save.inventory[gym.badge] and "_GymStatueText2" or "_GymStatueText1"
|
||||
local text = Game.data.text[key]
|
||||
or "{RAM}\nPOKéMON GYM\nLEADER: {RAM}"
|
||||
or Strings("{RAM}\nPOKéMON GYM\nLEADER: {RAM}")
|
||||
text = text:gsub("{RAM:wGymCityName}", gym.city)
|
||||
:gsub("{RAM:wGymLeaderName}", gym.leader)
|
||||
Game.stack:push(TextBox.new(Game, text))
|
||||
@@ -1609,7 +1688,7 @@ function OverworldState:tryHiddenObject(fx, fy)
|
||||
for _, h in ipairs(extras.printTrash and extras.printTrash[self.map.id] or {}) do
|
||||
if h.x == fx and h.y == fy then
|
||||
Game.stack:push(TextBox.new(Game, txt._VermilionGymTrashText
|
||||
or "Nope, there's\nonly trash here."))
|
||||
or Strings("Nope, there's\nonly trash here.")))
|
||||
return true
|
||||
end
|
||||
end
|
||||
@@ -1651,7 +1730,7 @@ function OverworldState:tryCardKeyDoor(fx, fy)
|
||||
local t = Game.data.text
|
||||
if not Game.save.inventory.CARD_KEY then
|
||||
Game.stack:push(TextBox.new(Game,
|
||||
t._CardKeyFailText or "Darn! It needs a\nCARD KEY!"))
|
||||
t._CardKeyFailText or Strings("Darn! It needs a\nCARD KEY!")))
|
||||
return true
|
||||
end
|
||||
require("src.core.Sound").play(Game.data, "Go_Inside")
|
||||
@@ -1668,8 +1747,8 @@ function OverworldState:tryCardKeyDoor(fx, fy)
|
||||
end
|
||||
end
|
||||
Game.stack:push(TextBox.new(Game,
|
||||
(t._CardKeySuccessText1 or "Bingo!")
|
||||
.. (t._CardKeySuccessText2 or "\nThe CARD KEY\nopened the door!")))
|
||||
(t._CardKeySuccessText1 or Strings("Bingo!"))
|
||||
.. (t._CardKeySuccessText2 or Strings("\nThe CARD KEY\nopened the door!"))))
|
||||
return true
|
||||
end
|
||||
|
||||
@@ -1686,7 +1765,7 @@ function OverworldState:trashCanSwitch(canIndex)
|
||||
local t = Game.data.text
|
||||
local save = Game.save
|
||||
local tc = Game.data.field.hiddenExtras.trashCans
|
||||
local trashText = t._VermilionGymTrashText or "Nope, there's\nonly trash here."
|
||||
local trashText = t._VermilionGymTrashText or Strings("Nope, there's\nonly trash here.")
|
||||
-- "Don't do the trash can puzzle if it's already been done."
|
||||
if save.flags.EVENT_2ND_LOCK_OPENED then
|
||||
Game.stack:push(TextBox.new(Game, trashText))
|
||||
@@ -1734,7 +1813,7 @@ function OverworldState:trashCanSwitch(canIndex)
|
||||
-- open.
|
||||
Game.stack:push(TextBox.new(Game,
|
||||
t._VermilionGymTrashSuccessText1
|
||||
or "Hey! There's a\nswitch under the\ntrash!\fThe 1st electric\nlock opened!",
|
||||
or Strings("Hey! There's a\nswitch under the\ntrash!\fThe 1st electric\nlock opened!"),
|
||||
function() require("src.core.Sound").play(Game.data, "Switch") end))
|
||||
return
|
||||
end
|
||||
@@ -1751,7 +1830,7 @@ function OverworldState:trashCanSwitch(canIndex)
|
||||
-- prints, so the beep fires as the box closes, not as it opens.
|
||||
Game.stack:push(TextBox.new(Game,
|
||||
t._VermilionGymTrashSuccessText3
|
||||
or "The 2nd electric\nlock opened!\fThe motorized door\nopened!",
|
||||
or Strings("The 2nd electric\nlock opened!\fThe motorized door\nopened!"),
|
||||
function() require("src.core.Sound").play(Game.data, "Go_Inside") end))
|
||||
else
|
||||
-- wrong can: ResetEvent EVENT_1ST_LOCK_OPENED and immediately
|
||||
@@ -1763,7 +1842,7 @@ function OverworldState:trashCanSwitch(canIndex)
|
||||
-- text prints, so the beep fires as the box closes, not as it opens.
|
||||
Game.stack:push(TextBox.new(Game,
|
||||
t._VermilionGymTrashFailText
|
||||
or "Nope! There's\nonly trash here.\fHey! The electric\nlocks were reset!",
|
||||
or Strings("Nope! There's\nonly trash here.\fHey! The electric\nlocks were reset!"),
|
||||
function() require("src.core.Sound").play(Game.data, "Denied") end))
|
||||
end
|
||||
end
|
||||
@@ -1785,12 +1864,12 @@ function OverworldState:billsHousePC()
|
||||
if flags.EVENT_USED_CELL_SEPARATOR_ON_BILL
|
||||
or not flags.EVENT_BILL_SAID_USE_CELL_SEPARATOR then
|
||||
Game.stack:push(TextBox.new(Game, t._BillsHouseMonitorText
|
||||
or "TELEPORTER is\ndisplayed on the\nPC monitor."))
|
||||
or Strings("TELEPORTER is\ndisplayed on the\nPC monitor.")))
|
||||
return
|
||||
end
|
||||
require("src.core.Music").stop()
|
||||
Game.stack:push(TextBox.new(Game, t._BillsHouseInitiatedText
|
||||
or "{PLAYER} initiated\nTELEPORTER's Cell\nSeparator!", function()
|
||||
or Strings("{PLAYER} initiated\nTELEPORTER's Cell\nSeparator!"), function()
|
||||
flags.EVENT_USED_CELL_SEPARATOR_ON_BILL = true
|
||||
require("src.core.Sound").play(Game.data, "Switch")
|
||||
self:queueScript({
|
||||
@@ -1827,13 +1906,13 @@ function OverworldState:billsHousePokemonList()
|
||||
end,
|
||||
})
|
||||
end
|
||||
table.insert(items, { label = "CANCEL" })
|
||||
table.insert(items, { label = Strings("CANCEL") })
|
||||
-- TextBoxBorder b=10,c=9 at (0,0) -> total tw=11, th=12
|
||||
Game.stack:push(Menu.new(Game, items,
|
||||
{ tx = 0, ty = 0, tw = 11, th = 12 }))
|
||||
end
|
||||
Game.stack:push(TextBox.new(Game, t._BillsHousePokemonListText1
|
||||
or "BILL's favorite\nPOKéMON list!", openList))
|
||||
or Strings("BILL's favorite\nPOKéMON list!"), openList))
|
||||
end
|
||||
|
||||
-- BillsHouseBillExitsMachineScript: human Bill appears inside the machine
|
||||
@@ -1927,7 +2006,7 @@ function OverworldState:trySurf(fx, fy)
|
||||
local p = self.player
|
||||
p.surfing = true
|
||||
require("src.core.Music").setSurfing(Game.data, true)
|
||||
local text = (Game.data.text._SurfingGotOnText or "{PLAYER} got on\n{RAM:wNameBuffer}!")
|
||||
local text = (Game.data.text._SurfingGotOnText or Strings("{PLAYER} got on\n{RAM:wNameBuffer}!"))
|
||||
:gsub("{RAM:wNameBuffer}", name)
|
||||
Game.stack:push(TextBox.new(Game, text, function()
|
||||
-- start_sub_menus.asm .surf: UseItem returns (mount + text done),
|
||||
@@ -1975,7 +2054,7 @@ function OverworldState:tryCut(fx, fy)
|
||||
-- gen 1 confirms nothing (engine/overworld/cut.asm UsedCut): the
|
||||
-- _UsedCutText message, then the tree vanishes with dust + SFX_CUT
|
||||
local name = mon.nickname or Game.data.pokemon[mon.species].name
|
||||
local text = (Game.data.text._UsedCutText or "{RAM:wNameBuffer} hacked\naway with CUT!")
|
||||
local text = (Game.data.text._UsedCutText or Strings("{RAM:wNameBuffer} hacked\naway with CUT!"))
|
||||
:gsub("{RAM:wNameBuffer}", name)
|
||||
Game.stack:push(TextBox.new(Game, text, function()
|
||||
self.cutBlocks = self.cutBlocks or {}
|
||||
@@ -2111,7 +2190,7 @@ function OverworldState:talkTo(npc)
|
||||
-- the string "0" as truthy, so screen it out and fall through to text.
|
||||
if d.item and d.item ~= "0" and d.item ~= 0 then
|
||||
if not require("src.inventory.Bag").add(Game.save, d.item, 1) then
|
||||
Game.stack:push(TextBox.new(Game, "You can't carry\nany more items!"))
|
||||
Game.stack:push(TextBox.new(Game, Strings("You can't carry\nany more items!")))
|
||||
return
|
||||
end
|
||||
Game.save.itemsTaken = Game.save.itemsTaken or {}
|
||||
@@ -2127,7 +2206,7 @@ function OverworldState:talkTo(npc)
|
||||
require("src.core.Sound").play(Game.data,
|
||||
(ddef and ddef.keyItem) and "Get_Key_Item" or "Get_Item1")
|
||||
Game.stack:push(TextBox.new(Game,
|
||||
("%s found\n%s!"):format(Game.save.player.name, name)))
|
||||
Strings("%s found\n%s!", Game.save.player.name, name)))
|
||||
return
|
||||
end
|
||||
|
||||
@@ -2136,7 +2215,7 @@ function OverworldState:talkTo(npc)
|
||||
if d.pokemon then
|
||||
npc:facePlayer(self.player)
|
||||
local text = select(1, Game.data:resolveText(self.map.def.label, d.text))
|
||||
or "Gyaoo!"
|
||||
or Strings("Gyaoo!")
|
||||
local BattleState = require("src.battle.BattleState")
|
||||
Game.stack:push(TextBox.new(Game, text, function()
|
||||
local battle = BattleState.newWild(Game, d.pokemon, d.level)
|
||||
@@ -2179,7 +2258,7 @@ function OverworldState:talkTo(npc)
|
||||
if entry then
|
||||
if entry.mart then
|
||||
npc:facePlayer(self.player)
|
||||
Game.stack:push(TextBox.new(Game, "Hi there!\nMay I help you?", function()
|
||||
Game.stack:push(TextBox.new(Game, Strings("Hi there!\nMay I help you?"), function()
|
||||
Screens.push(Game, "ShopMenu", entry.mart)
|
||||
unfreeze()
|
||||
end))
|
||||
@@ -2223,7 +2302,7 @@ function OverworldState:openPC(onDone)
|
||||
-- when Bill hands over the SS Ticket)
|
||||
local metBill = flags.EVENT_MET_BILL or flags.EVENT_GOT_SS_TICKET
|
||||
table.insert(items, {
|
||||
label = metBill and "BILL'S PC" or "SOMEONE'S PC",
|
||||
label = metBill and "BILL'S PC" or Strings("SOMEONE'S PC"),
|
||||
onSelect = function()
|
||||
require("src.core.Sound").play(Game.data, "Enter_PC")
|
||||
Screens.push(Game, "BoxMenu")
|
||||
@@ -2243,7 +2322,7 @@ function OverworldState:openPC(onDone)
|
||||
-- Prof. Oak's dex rating only appears once you have the Pokédex
|
||||
if flags.EVENT_GOT_POKEDEX then
|
||||
table.insert(items, {
|
||||
label = "PROF.OAK's PC",
|
||||
label = Strings("PROF.OAK's PC"),
|
||||
onSelect = function()
|
||||
self:dexRating()
|
||||
done()
|
||||
@@ -2263,7 +2342,7 @@ function OverworldState:openPC(onDone)
|
||||
require("src.core.Sound").play(Game.data, "Turn_Off_PC")
|
||||
done()
|
||||
end
|
||||
table.insert(items, { label = "LOG OFF", onSelect = logOff })
|
||||
table.insert(items, { label = Strings("LOG OFF"), onSelect = logOff })
|
||||
-- pokered sets BIT_NO_MENU_BUTTON_SOUND for the whole PC session
|
||||
-- (engine/overworld/pokecenter_pc.asm / player_pc.asm); DisplayPCMainMenu
|
||||
-- calls TextBoxBorder with c=14 (interior width, +2 for the border), so
|
||||
@@ -2288,9 +2367,9 @@ function OverworldState:dexRating()
|
||||
local lo = math.floor(owned / 10) * 10
|
||||
key = ("_DexRatingText_Own%dTo%d"):format(lo, lo + 9)
|
||||
end
|
||||
local rating = Game.data.text[key] or "Keep it up!"
|
||||
local rating = Game.data.text[key] or Strings("Keep it up!")
|
||||
local completion = Game.data.text._DexCompletionText
|
||||
or "POKéDEX comp-\nletion is:\f{NUM:hDexRatingNumMonsSeen} POKéMON seen\n{NUM:hDexRatingNumMonsOwned} POKéMON owned\fPROF.OAK's\nRating:"
|
||||
or Strings("POKéDEX comp-\nletion is:\f{NUM:hDexRatingNumMonsSeen} POKéMON seen\n{NUM:hDexRatingNumMonsOwned} POKéMON owned\fPROF.OAK's\nRating:")
|
||||
completion = completion
|
||||
:gsub("{NUM:hDexRatingNumMonsSeen[^}]*}", tostring(seen))
|
||||
:gsub("{NUM:hDexRatingNumMonsOwned[^}]*}", tostring(owned))
|
||||
@@ -2337,20 +2416,20 @@ end
|
||||
-- POKéMON" and "fighting fit".
|
||||
function OverworldState:nurseHeal(onDone, npc)
|
||||
local t = Game.data.text
|
||||
local bye = t._PokemonCenterFarewellText or "We hope to see\nyou again!"
|
||||
local bye = t._PokemonCenterFarewellText or Strings("We hope to see\nyou again!")
|
||||
local hello = t._PokemonCenterWelcomeText
|
||||
or "Welcome to our\nPOKéMON CENTER!"
|
||||
or Strings("Welcome to our\nPOKéMON CENTER!")
|
||||
if not Game.save.usedPokecenter then
|
||||
Game.save.usedPokecenter = true -- BIT_USED_POKECENTER
|
||||
hello = hello .. "\f"
|
||||
.. (t._ShallWeHealYourPokemonText or "Shall we heal your\nPOKéMON?")
|
||||
.. (t._ShallWeHealYourPokemonText or Strings("Shall we heal your\nPOKéMON?"))
|
||||
end
|
||||
Game.stack:push(TextBox.new(Game, hello, nil, { choice = function(yes)
|
||||
if not yes then
|
||||
Game.stack:push(TextBox.new(Game, bye, onDone))
|
||||
return
|
||||
end
|
||||
local need = t._NeedYourPokemonText or "OK. We'll need\nyour POKéMON."
|
||||
local need = t._NeedYourPokemonText or Strings("OK. We'll need\nyour POKéMON.")
|
||||
Game.stack:push(TextBox.new(Game, need, function()
|
||||
-- the nurse turns to the machine, the map music stops, and the
|
||||
-- party heals before the machine runs (predef HealParty)
|
||||
@@ -2385,7 +2464,7 @@ end
|
||||
|
||||
function OverworldState:finishNurseHeal(bye, onDone)
|
||||
local t = Game.data.text
|
||||
local fit = t._PokemonFightingFitText or "Your POKéMON are\nfighting fit!"
|
||||
local fit = t._PokemonFightingFitText or Strings("Your POKéMON are\nfighting fit!")
|
||||
Game.stack:push(TextBox.new(Game, fit .. "\f" .. bye, onDone))
|
||||
end
|
||||
|
||||
@@ -2397,21 +2476,21 @@ end
|
||||
-- for the original serial handshake; declining prints "Please come again!"
|
||||
function OverworldState:cableClubReceptionist(onDone)
|
||||
local t = Game.data.text
|
||||
local welcome = t._CableClubNPCWelcomeText or "Welcome to the\nCable Club!"
|
||||
local welcome = t._CableClubNPCWelcomeText or Strings("Welcome to the\nCable Club!")
|
||||
if not Game.save.flags.EVENT_GOT_POKEDEX then
|
||||
-- CableClubNPC .didNotConnect path before the pokedex
|
||||
Game.stack:push(TextBox.new(Game, welcome .. "\f"
|
||||
.. (t._CableClubNPCMakingPreparationsText
|
||||
or "We're making\npreparations.\vPlease wait."), onDone))
|
||||
or Strings("We're making\npreparations.\vPlease wait.")), onDone))
|
||||
return
|
||||
end
|
||||
local apply = t._CableClubNPCPleaseApplyHereHaveToSaveText
|
||||
or "Please apply here.\fBefore opening\nthe link, we have\vto save the game."
|
||||
or Strings("Please apply here.\fBefore opening\nthe link, we have\vto save the game.")
|
||||
Game.stack:push(TextBox.new(Game, welcome .. "\f" .. apply, nil,
|
||||
{ choice = function(yes)
|
||||
if not yes then
|
||||
Game.stack:push(TextBox.new(Game,
|
||||
t._CableClubNPCPleaseComeAgainText or "Please come\nagain!", onDone))
|
||||
t._CableClubNPCPleaseComeAgainText or Strings("Please come\nagain!"), onDone))
|
||||
return
|
||||
end
|
||||
Game:writeSave()
|
||||
@@ -2446,7 +2525,7 @@ function OverworldState:engageTrainer(npc, onDone)
|
||||
local battleText = header and header.battle and Game.data.text[header.battle]
|
||||
if not battleText then
|
||||
battleText = select(1, Game.data:resolveText(self.map.def.label, d.text))
|
||||
or "I like shorts!\nThey're comfy and\neasy to wear!"
|
||||
or Strings("I like shorts!\nThey're comfy and\neasy to wear!")
|
||||
end
|
||||
local wonText = header and header.won and Game.data.text[header.won]
|
||||
|
||||
@@ -2525,11 +2604,11 @@ function OverworldState:checkVictoryRewards(trainerClass, partyIndex)
|
||||
if reward.badge then
|
||||
local name = Game.data.items[reward.badge] and Game.data.items[reward.badge].name
|
||||
or reward.badge
|
||||
table.insert(lines, ("%s received\nthe %s!"):format(Game.save.player.name, name))
|
||||
table.insert(lines, Strings("%s received\nthe %s!", Game.save.player.name, name))
|
||||
end
|
||||
if reward.item then
|
||||
local name = Game.stringBuffer or reward.item
|
||||
table.insert(lines, ("%s received\n%s!"):format(Game.save.player.name, name))
|
||||
table.insert(lines, Strings("%s received\n%s!", Game.save.player.name, name))
|
||||
end
|
||||
end
|
||||
if #lines > 0 then
|
||||
@@ -2723,7 +2802,7 @@ function OverworldState:applyFieldPoison()
|
||||
local queue = {}
|
||||
for _, mon in ipairs(fainted) do
|
||||
local name = mon.nickname or Game.data.pokemon[mon.species].name
|
||||
table.insert(queue, ("%s\nfainted!"):format(name))
|
||||
table.insert(queue, Strings("%s\nfainted!", name))
|
||||
end
|
||||
local alive = false
|
||||
for _, mon in ipairs(save.party) do
|
||||
@@ -2737,7 +2816,7 @@ function OverworldState:applyFieldPoison()
|
||||
end
|
||||
if not alive then
|
||||
Game.stack:push(TextBox.new(Game,
|
||||
("%s blacked\nout!"):format(save.player.name), function()
|
||||
Strings("%s blacked\nout!", save.player.name), function()
|
||||
local Pokemon = require("src.pokemon.Pokemon")
|
||||
for _, mon in ipairs(save.party) do Pokemon.heal(mon) end
|
||||
save.money = math.floor(save.money
|
||||
@@ -2781,11 +2860,18 @@ end
|
||||
|
||||
function OverworldState:onStepComplete()
|
||||
local p = self.player
|
||||
self.todSteps = (self.todSteps or 0) + 1
|
||||
-- re-evaluate day/night so a step-based clock can fire world.tod_changed;
|
||||
-- paletteNameFor reads self.tod on the next paint
|
||||
if Runtime.wantsHook("world.tod") then
|
||||
self:timeOfDay()
|
||||
end
|
||||
|
||||
-- hot path: the payload is only built when something is listening
|
||||
if Runtime.wants("world.stepped") then
|
||||
Runtime.emit("world.stepped", { mapId = self.map.id, x = p.cellX, y = p.cellY,
|
||||
tile = self.map:cellTile(p.cellX, p.cellY) })
|
||||
tile = self.map:cellTile(p.cellX, p.cellY),
|
||||
tod = self.tod })
|
||||
end
|
||||
|
||||
-- dismounting a surf: landing on a walkable cell ends it
|
||||
@@ -2866,7 +2952,7 @@ function OverworldState:onStepComplete()
|
||||
if Game.save.repelSteps == 0 then
|
||||
-- no encounter on the exact wear-off step (wild_encounters.asm
|
||||
-- .lastRepelStep returns CantEncounter)
|
||||
Game.stack:push(TextBox.new(Game, "REPEL's effect\nwore off."))
|
||||
Game.stack:push(TextBox.new(Game, Strings("REPEL's effect\nwore off.")))
|
||||
return
|
||||
end
|
||||
end
|
||||
@@ -3000,14 +3086,14 @@ function OverworldState:checkBadgeGate()
|
||||
-- Route22GateGuardGoRightAheadText plays sound_get_item_1
|
||||
require("src.core.Sound").play(Game.data, "Get_Item1")
|
||||
Game.stack:push(TextBox.new(Game,
|
||||
t["_" .. g.passText] or "Go right ahead!"))
|
||||
t["_" .. g.passText] or Strings("Go right ahead!")))
|
||||
end
|
||||
return false
|
||||
end
|
||||
-- Route22GateGuardNoBoulderbadgeText plays SFX_DENIED
|
||||
require("src.core.Sound").play(Game.data, "Denied")
|
||||
Game.stack:push(TextBox.new(Game,
|
||||
(t["_" .. g.failText] or "You don't have the\nBOULDERBADGE yet!")
|
||||
(t["_" .. g.failText] or Strings("You don't have the\nBOULDERBADGE yet!"))
|
||||
.. (t._Route22GateGuardICantLetYouPassText or ""), function()
|
||||
self:scriptMove(p, "down", 1)
|
||||
end))
|
||||
@@ -3028,14 +3114,14 @@ function OverworldState:checkBadgeGate()
|
||||
-- Route23OhThatIsTheBadgeText plays sound_get_item_1
|
||||
require("src.core.Sound").play(Game.data, "Get_Item1")
|
||||
local text = (t["_" .. g.passText] or
|
||||
"Oh! That is the\n{RAM}!"):gsub("{RAM:wNameBuffer}", badgeName)
|
||||
Strings("Oh! That is the\n{RAM}!")):gsub("{RAM:wNameBuffer}", badgeName)
|
||||
Game.stack:push(TextBox.new(Game, text))
|
||||
return false
|
||||
end
|
||||
-- Route23YouDontHaveTheBadgeYetText plays SFX_DENIED
|
||||
require("src.core.Sound").play(Game.data, "Denied")
|
||||
local text = (t["_" .. g.failText] or
|
||||
"You don't have the\n{RAM} yet!"):gsub("{RAM:wNameBuffer}", badgeName)
|
||||
Strings("You don't have the\n{RAM} yet!")):gsub("{RAM:wNameBuffer}", badgeName)
|
||||
Game.stack:push(TextBox.new(Game, text, function()
|
||||
self:scriptMove(p, "down", 1)
|
||||
end))
|
||||
@@ -3073,7 +3159,7 @@ function OverworldState:checkForcedMovement()
|
||||
Game.save.forcedBike = true
|
||||
require("src.core.Music").playMap(Game.data, self.map.id, true)
|
||||
else
|
||||
Game.stack:push(TextBox.new(Game, "You need a\nBICYCLE for the\nCycling Road!",
|
||||
Game.stack:push(TextBox.new(Game, Strings("You need a\nBICYCLE for the\nCycling Road!"),
|
||||
function()
|
||||
local back = ({ up = "down", down = "up",
|
||||
left = "right", right = "left" })[p.facing]
|
||||
@@ -3201,7 +3287,7 @@ function OverworldState:boulderIntoHole(npc)
|
||||
for i = #self.entities, 1, -1 do
|
||||
if self.entities[i] == npc then table.remove(self.entities, i) end
|
||||
end
|
||||
Game.stack:push(TextBox.new(Game, "The boulder fell\nthrough the hole!"))
|
||||
Game.stack:push(TextBox.new(Game, Strings("The boulder fell\nthrough the hole!")))
|
||||
return true
|
||||
end
|
||||
end
|
||||
@@ -3231,7 +3317,7 @@ function OverworldState:safariStep()
|
||||
if not st or not self:inSafariStepZone() then return false end
|
||||
st.steps = st.steps - 1
|
||||
if st.steps > 0 then return false end
|
||||
self:safariGameOver("PA: Ding-dong!\nTime's up!")
|
||||
self:safariGameOver(Strings("PA: Ding-dong!\nTime's up!"))
|
||||
return true
|
||||
end
|
||||
|
||||
@@ -3240,7 +3326,7 @@ function OverworldState:safariGameOver(text)
|
||||
Game.save.safari = nil
|
||||
local t = Game.data.text
|
||||
Game.stack:push(TextBox.new(Game,
|
||||
(text or "") .. "\f" .. (t._GameOverText or "PA: Your SAFARI\nGAME is over!"),
|
||||
(text or "") .. "\f" .. (t._GameOverText or Strings("PA: Your SAFARI\nGAME is over!")),
|
||||
function()
|
||||
local exit_ = FieldDefaults.fieldValue(Game.data, "safari", "exitWarp")
|
||||
self:startWarpTo(exit_.map, exit_.x, exit_.y, exit_.facing or "down")
|
||||
@@ -3283,7 +3369,7 @@ function OverworldState:afterBattle(result, battle)
|
||||
else
|
||||
-- throwing the last SAFARI BALL ends the game
|
||||
if Game.save.safari and Game.save.safari.balls <= 0 then
|
||||
self:safariGameOver("PA: You're out of\nSAFARI BALLs!")
|
||||
self:safariGameOver(Strings("PA: You're out of\nSAFARI BALLs!"))
|
||||
end
|
||||
evolutions()
|
||||
end
|
||||
|
||||
+17
-4
@@ -4,6 +4,7 @@
|
||||
|
||||
local Collision = require("src.world.Collision")
|
||||
local FieldDefaults = require("src.world.FieldDefaults")
|
||||
local Runtime = require("src.mods.Runtime")
|
||||
local SpriteRenderer = require("src.render.SpriteRenderer")
|
||||
|
||||
local Player = {}
|
||||
@@ -82,10 +83,22 @@ function Player:tryMove(dir, map, entities)
|
||||
self.moving = true
|
||||
self.bumpFrames = nil -- a real step supersedes any in-place bonk
|
||||
self.progress = 0
|
||||
-- the bicycle doubles walking speed (8 frames per step)
|
||||
local save = require("src.core.Game").save
|
||||
self.stepFramesCur = (save and save.onBike) and self.bikeStepFrames
|
||||
or self.stepFrames or STEP_FRAMES
|
||||
-- the bicycle doubles walking speed (8 frames per step); movement.speed
|
||||
-- lets a mod multiply or replace that (running shoes, dash, etc.)
|
||||
local Game = require("src.core.Game")
|
||||
local save = Game.save
|
||||
local frames = (save and save.onBike) and self.bikeStepFrames
|
||||
or self.stepFrames or STEP_FRAMES
|
||||
if Runtime.wantsHook("movement.speed") then
|
||||
frames = Runtime.call("movement.speed", function(f) return f end, frames, {
|
||||
onBike = save and save.onBike or false,
|
||||
surfing = self.surfing and true or false,
|
||||
player = self,
|
||||
input = Game.input,
|
||||
save = save,
|
||||
})
|
||||
end
|
||||
self.stepFramesCur = math.max(1, math.floor(tonumber(frames) or STEP_FRAMES))
|
||||
return "moved"
|
||||
end
|
||||
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
-- Driver: the bag's USE/TOSS submenu box (#284). A manual eye check, not a
|
||||
-- pass/fail run.
|
||||
--
|
||||
-- pokered evidence. data/text_boxes.asm pins the whole thing as one entry:
|
||||
--
|
||||
-- text_box_text USE_TOSS_MENU_TEMPLATE, 13, 10, 19, 14, UseTossText, 15, 11
|
||||
-- ; text box ID, upper-left X, upper-left Y, lower-right X, lower-right Y,
|
||||
-- ; text pointer, text X, text Y
|
||||
--
|
||||
-- so the box covers tiles (13,10)-(19,14) and "USE" starts at tile (15,11).
|
||||
-- engine/menus/start_sub_menus.asm then sets wTopMenuItemY/X to 11/14 for
|
||||
-- the cursor, and UseTossText is "USE" + next "TOSS", i.e. two rows.
|
||||
--
|
||||
-- The bug: the port opened the submenu with a (12,10) 8x6 box, one column
|
||||
-- too wide on the left and one row too tall at the bottom. The labels
|
||||
-- themselves were already on the right pixel rows, but the extra bottom row
|
||||
-- left them stranded near the top edge, which is what reads as "too high".
|
||||
-- The fix passes the real geometry (13,10,7,5); src/ui/Menu.lua derives the
|
||||
-- label and cursor columns from the box, so it needed no change and its
|
||||
-- eight other callers are untouched.
|
||||
--
|
||||
-- Do NOT run this under POKEPORT_SPEED: fast-forward scales only the logic
|
||||
-- clock, so a sped-up run can capture a half-drawn frame.
|
||||
--
|
||||
-- POKEPORT_DRIVER=tests/drivers/bag_usetoss_bug284_test.lua POKEPORT_IDENTITY=bug284 love .
|
||||
return function(game)
|
||||
local U = dofile("tests/drivers/util.lua")
|
||||
local Pokemon = require("src.pokemon.Pokemon")
|
||||
local Screens = require("src.ui.Screens")
|
||||
|
||||
local function check(label, ok)
|
||||
U.log(ok and "PASS" or "FAIL", label)
|
||||
return ok
|
||||
end
|
||||
|
||||
-- ---- preconditions the eye cannot separate from the bug ----------------
|
||||
-- An empty bag, or an item whose branch skips the submenu, both end with
|
||||
-- "no USE/TOSS box on screen", which looks identical to a broken box.
|
||||
|
||||
game.save.party = { Pokemon.new(game.data, "CHARIZARD", 50) }
|
||||
game.save.player.name = "bryan"
|
||||
game.save.inventory = game.save.inventory or {}
|
||||
game.save.inventory.POTION = 3
|
||||
check("a usable, tossable item is in the bag",
|
||||
(game.save.inventory.POTION or 0) > 0)
|
||||
|
||||
-- BICYCLE short-circuits straight past the submenu in the original
|
||||
-- (start_sub_menus.asm `cp BICYCLE / jp z, .useOrTossItem`), and the port
|
||||
-- mirrors that, so the first item must not be the bike.
|
||||
check("POTION is not a submenu-skipping item",
|
||||
game.data.items.POTION ~= nil and not game.data.items.POTION.keyItem)
|
||||
|
||||
check("renderer is up", game.renderer ~= nil)
|
||||
|
||||
U.teleport(game, "PALLET_TOWN", 10, 8, "down")
|
||||
U.wait(20)
|
||||
|
||||
Screens.push(game, "BagMenu")
|
||||
U.wait(30)
|
||||
U.shot(game, "bug284_bag_list.png")
|
||||
|
||||
-- A on the first item opens USE/TOSS
|
||||
U.tap(game, "a")
|
||||
U.wait(30)
|
||||
U.shot(game, "bug284_usetoss.png")
|
||||
|
||||
U.log("........................................................")
|
||||
U.log("LOOK NOW: the USE/TOSS box should be open over the bag list.")
|
||||
U.log(" Screenshot: bug284_usetoss.png in the LOVE save dir.")
|
||||
U.log(" RIGHT: a box hugging the bottom-right corner, its border running")
|
||||
U.log(" tiles (13,10) to (19,14). USE and TOSS sit on alternating")
|
||||
U.log(" rows with the cursor one column to their left, and there")
|
||||
U.log(" is exactly one border row below TOSS -- no empty gap.")
|
||||
U.log(" BUG #284 looks like: a roomier box whose labels crowd the top,")
|
||||
U.log(" with a blank row of dead space under TOSS, and the whole")
|
||||
U.log(" box starting one column further left than the original.")
|
||||
U.log(" ALSO WRONG: labels pushed down but the box left oversized (that")
|
||||
U.log(" moves the text off pokered's row 11), or the cursor column")
|
||||
U.log(" no longer lining up one tile left of the labels.")
|
||||
U.log("Compare against the original screenshot in issue #284.")
|
||||
U.log("Input is yours from here: up/down moves between USE and TOSS.")
|
||||
U.log("........................................................")
|
||||
|
||||
while true do
|
||||
coroutine.yield()
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,127 @@
|
||||
-- Driver: the Viridian caterpillar speech waits for the player (#250).
|
||||
-- A manual eye check, not a pass/fail run -- nothing in this repo can judge
|
||||
-- "the text went by too fast to read".
|
||||
--
|
||||
-- pokered evidence. text/ViridianCity.asm spells the answer with three
|
||||
-- different break markers, and they are not interchangeable:
|
||||
--
|
||||
-- ViridianCityYoungster2CaterpieAndWeedleDescriptionText::
|
||||
-- text "CATERPIE has no"
|
||||
-- line "poison, but"
|
||||
-- cont "WEEDLE does."
|
||||
--
|
||||
-- para "Watch out for its"
|
||||
-- line "POISON STING!"
|
||||
-- done
|
||||
--
|
||||
-- src/render/TextBox.lua maps those to \n (second line), \v (scroll one
|
||||
-- line, after the down-arrow and a button press) and \f (page break, clear
|
||||
-- and wait). This text is not in data/generated/text.lua -- pokered
|
||||
-- declares it without the leading underscore the extractor keys on -- so
|
||||
-- the port carries a literal fallback, and that fallback had spelled both
|
||||
-- `cont` and `para` as plain \n. Six lines then landed on one page with
|
||||
-- nothing to wait on, so the whole speech scrolled past untouched.
|
||||
--
|
||||
-- Do NOT run this under POKEPORT_SPEED. Fast-forward scales only the logic
|
||||
-- clock while the typewriter and its SFX run on their own real-time
|
||||
-- accumulator (src/core/Game.lua), which is precisely the pacing being
|
||||
-- judged here.
|
||||
--
|
||||
-- POKEPORT_DRIVER=tests/drivers/caterpillar_text_bug250_test.lua POKEPORT_IDENTITY=bug250 love .
|
||||
return function(game)
|
||||
local U = dofile("tests/drivers/util.lua")
|
||||
|
||||
local function check(label, ok)
|
||||
U.log(ok and "PASS" or "FAIL", label)
|
||||
return ok
|
||||
end
|
||||
|
||||
-- ---- preconditions the eye cannot separate from the bug ----------------
|
||||
-- A missing handler, or an NPC who wandered off, both end in "no text",
|
||||
-- which is not the same failure as "text that does not wait".
|
||||
|
||||
local MAP = "VIRIDIAN_CITY"
|
||||
local NPC = "VIRIDIANCITY_YOUNGSTER2"
|
||||
local TEXT = "TEXT_VIRIDIANCITY_YOUNGSTER2"
|
||||
|
||||
local mapScripts = require("data.scripts.init")
|
||||
check("a hand-ported handler exists for " .. TEXT,
|
||||
mapScripts.talkScript(MAP, TEXT) ~= nil)
|
||||
|
||||
-- the fix itself: the paginator has to find a page break and a scroll in
|
||||
-- the description, because that is what makes it wait for a button
|
||||
local TextBox = require("src.render.TextBox")
|
||||
local desc = "CATERPIE has no\npoison, but\vWEEDLE does.\fWatch out for its\nPOISON STING!"
|
||||
local pages = TextBox.paginate(desc)
|
||||
check("the description spans two pages (para -> \\f)", #pages == 2)
|
||||
check("page 1 is three lines (text + line + cont)",
|
||||
pages[1] ~= nil and #pages[1] == 3)
|
||||
check("page 2 is two lines (para + line)",
|
||||
pages[2] ~= nil and #pages[2] == 2)
|
||||
-- the wait itself: contBefore marks the line the box holds on until the
|
||||
-- player presses a button, which is the whole point of the fix
|
||||
check("line 3 waits for a button before scrolling in",
|
||||
pages.contBefore and pages.contBefore[1] and pages.contBefore[1][3] == true)
|
||||
|
||||
U.teleport(game, MAP, 30, 26, "up")
|
||||
U.wait(30)
|
||||
|
||||
local function target()
|
||||
for _, n in ipairs(game.overworld and game.overworld.npcs or {}) do
|
||||
if n.def and n.def.name == NPC then return n end
|
||||
end
|
||||
return nil
|
||||
end
|
||||
|
||||
local npc = target()
|
||||
check("the youngster is loaded on " .. MAP, npc ~= nil)
|
||||
-- pin the wander: he strolls out from under the A press between reads
|
||||
if npc then npc.wanders = false end
|
||||
|
||||
-- objects live at ../pokered/data/maps/objects/ViridianCity.asm and in
|
||||
-- data/generated/maps.lua; he is at (30,25), so (30,26) faces him. If a
|
||||
-- map edit ever moves him, stand on any free walkable neighbour instead
|
||||
-- of parking the player at a wall.
|
||||
local function facingTarget()
|
||||
local ow = game.overworld
|
||||
if not (ow and npc) then return false end
|
||||
local fx, fy = ow.player:facingCell()
|
||||
return ow:npcAtCell(fx, fy) == npc
|
||||
end
|
||||
|
||||
if npc and not facingTarget() then
|
||||
local ow = game.overworld
|
||||
for _, s in ipairs({ { 0, 1, "up" }, { 1, 0, "left" },
|
||||
{ -1, 0, "right" }, { 0, -1, "down" } }) do
|
||||
local cx, cy = npc.cellX + s[1], npc.cellY + s[2]
|
||||
if ow.map:isWalkableCell(cx, cy) and not ow:npcAtCell(cx, cy) then
|
||||
U.log("approach cell blocked, standing on", cx, cy, "facing", s[3])
|
||||
U.teleport(game, MAP, cx, cy, s[3])
|
||||
U.wait(10)
|
||||
break
|
||||
end
|
||||
end
|
||||
end
|
||||
check("player is standing in front of the youngster", facingTarget())
|
||||
|
||||
U.log("........................................................")
|
||||
U.log("READ NOW: press A to talk, then answer YES to his question.")
|
||||
U.log(" RIGHT: the answer stops and waits for you three times. After")
|
||||
U.log(" 'WEEDLE does.' the box waits with a down-arrow, then the")
|
||||
U.log(" page CLEARS before 'Watch out for its POISON STING!'.")
|
||||
U.log(" BUG #250 looks like: all six lines pouring past in one go, the")
|
||||
U.log(" box scrolling itself, and the conversation ending before")
|
||||
U.log(" you have pressed anything.")
|
||||
U.log(" ALSO WRONG: it waits but never clears the page (that is \\v where")
|
||||
U.log(" pokered has para), or it clears after every single line")
|
||||
U.log(" (that is \\f where pokered has line/cont).")
|
||||
U.log("Control case: answer NO instead and you should get the one-line")
|
||||
U.log(" 'Oh, OK then!', which needs no waits at all.")
|
||||
U.log("Input is yours from here, so you can re-run the talk as often as")
|
||||
U.log("you like.")
|
||||
U.log("........................................................")
|
||||
|
||||
while true do
|
||||
coroutine.yield()
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,150 @@
|
||||
-- Driver: manual audio check for the silent Pewter NIDORAN (#247).
|
||||
--
|
||||
-- pokered scripts/PewterNidoranHouse.asm, PewterNidoranHouseNidoranText:
|
||||
-- text_far _PewterNidoranHouseNidoranText
|
||||
-- text_asm
|
||||
-- ld a, NIDORAN_M
|
||||
-- call PlayCry
|
||||
-- call WaitForSoundToFinish
|
||||
-- jp TextScriptEnd
|
||||
-- so the box types "NIDORAN: Bowbow!" first and the male NIDORAN cry sounds
|
||||
-- second, once the typewriter is done. The box then still waits for a
|
||||
-- button: PewterNidoranHouse_Script is `jp EnableAutoTextBoxDrawing`, and
|
||||
-- AutoTextBoxDrawingCommon (pokered home/window.asm) zeroes
|
||||
-- wDoNotWaitForButtonPressAfterDisplayingText, so DisplayTextID falls through
|
||||
-- to WaitForTextScrollButtonPress like any other NPC line.
|
||||
-- data/scripts/flavor/pewter_nidoran_house.lua ported the dialogue row but
|
||||
-- never the cry row, so the NIDORAN was silent.
|
||||
--
|
||||
-- This is an audio fix, so nothing here can assert it. What this driver does
|
||||
-- is check the halves an ear cannot check (the cry row is wired into the talk
|
||||
-- script in the adjacency Commands.play_cry/show_text require, and the
|
||||
-- NIDORAN_M cry sample resolves the way Sound.playCry looks it up), park the
|
||||
-- player one cell below the NIDORAN already facing it, and then hand input
|
||||
-- straight back so a human hears the cry live.
|
||||
--
|
||||
-- Do NOT add POKEPORT_SPEED to this run: fast-forward scales only the logic
|
||||
-- clock while audio runs on its own real-time 60 Hz accumulator
|
||||
-- (src/core/Game.lua), so the cry would stop lining up with the box it
|
||||
-- belongs to.
|
||||
--
|
||||
-- POKEPORT_DRIVER=tests/drivers/nidoran_cry_bug247_test.lua \
|
||||
-- POKEPORT_IDENTITY=bug247 POKEPORT_TOUCH=0 POKEPORT_VERSION=red love .
|
||||
return function(game)
|
||||
local U = dofile("tests/drivers/util.lua")
|
||||
local mapScripts = require("data.scripts.init")
|
||||
|
||||
local MAP = "PEWTER_NIDORAN_HOUSE"
|
||||
local NPC = "PEWTERNIDORANHOUSE_NIDORAN"
|
||||
local TEXT = "TEXT_PEWTERNIDORANHOUSE_NIDORAN"
|
||||
local SPECIES = "NIDORAN_M"
|
||||
|
||||
local function check(label, ok)
|
||||
U.log(ok and "PASS" or "FAIL", label)
|
||||
return ok
|
||||
end
|
||||
|
||||
-- ---- preconditions the ear cannot check --------------------------------
|
||||
-- Commands.play_cry only stashes ctx.pendingCry; Commands.show_text is what
|
||||
-- turns it into the box's opts.auto. The two rows therefore have to be
|
||||
-- adjacent and in that order, or the cry is handed to the wrong box or
|
||||
-- never consumed at all -- and either way the NIDORAN stays quiet.
|
||||
local rows = mapScripts.talkScript(MAP, TEXT)
|
||||
local cryAt, textAt
|
||||
for i, row in ipairs(rows or {}) do
|
||||
if row[1] == "play_cry" and not cryAt then cryAt = i end
|
||||
if row[1] == "show_text" and not textAt then textAt = i end
|
||||
end
|
||||
local cry = cryAt and rows[cryAt]
|
||||
check("talk script carries a play_cry row", cry ~= nil)
|
||||
check("cry species is " .. SPECIES, cry ~= nil and cry[2] == SPECIES)
|
||||
-- the third arg is play_cry's waitForButton form, which keeps
|
||||
-- DisplayTextID's trailing WaitForTextScrollButtonPress instead of letting
|
||||
-- the box pop itself the instant the cry goes quiet
|
||||
check("cry row keeps the button wait", cry ~= nil and cry[3] == true)
|
||||
check("play_cry sits immediately before show_text",
|
||||
cryAt ~= nil and textAt == cryAt + 1)
|
||||
|
||||
-- Sound.playCry reads data.audio.cries[species]; a missing or misspelled
|
||||
-- key is a silent no-op with no error, which is indistinguishable by ear
|
||||
-- from the bug itself
|
||||
local cries = game.data.audio and game.data.audio.cries
|
||||
check("data.audio.cries." .. SPECIES .. " resolves",
|
||||
cries ~= nil and cries[SPECIES] ~= nil)
|
||||
|
||||
local vol = game.save.options and game.save.options.sfxVol
|
||||
U.log("audio device present:", love.audio ~= nil,
|
||||
" SFX VOL (0-7):", tostring(vol))
|
||||
if not love.audio or vol == 0 then
|
||||
U.log("WARNING: sound output is off, so nothing below will be audible;",
|
||||
"raise SFX VOL in OPTION first")
|
||||
end
|
||||
|
||||
-- ---- park the player in front of the NIDORAN ---------------------------
|
||||
-- pokered data/maps/objects/PewterNidoranHouse.asm puts the NIDORAN on
|
||||
-- (4, 5) facing LEFT at the little boy on (3, 5), so the free approach cell
|
||||
-- is the floor directly below it.
|
||||
U.teleport(game, MAP, 4, 6, "up")
|
||||
U.wait(10)
|
||||
|
||||
local function nidoranIn(ow)
|
||||
for _, n in ipairs(ow.npcs or {}) do
|
||||
if n.def and n.def.name == NPC then return n end
|
||||
end
|
||||
return nil
|
||||
end
|
||||
|
||||
-- re-reads game.overworld every call: the fallback below teleports again,
|
||||
-- which rebuilds the whole state and its npc list
|
||||
local function facingTheMon()
|
||||
local ow = game.overworld
|
||||
local mon = ow and nidoranIn(ow)
|
||||
if not mon then return false end
|
||||
local fx, fy = ow.player:facingCell()
|
||||
return ow:npcAtCell(fx, fy) == mon
|
||||
end
|
||||
|
||||
local ow = game.overworld
|
||||
local mon = ow and nidoranIn(ow)
|
||||
check("NIDORAN object loaded on " .. MAP, mon ~= nil)
|
||||
|
||||
if mon and not facingTheMon() then
|
||||
-- the hard-coded approach cell stopped working (map edit, or a mod moved
|
||||
-- the object): take any free walkable neighbour and turn toward the mon.
|
||||
-- {dx, dy, facing} is the offset from the mon to the stand cell plus the
|
||||
-- direction that looks back at it, so +1 on x means facing left.
|
||||
local sides = {
|
||||
{ 0, 1, "up" }, { 1, 0, "left" }, { -1, 0, "right" }, { 0, -1, "down" },
|
||||
}
|
||||
for _, s in ipairs(sides) do
|
||||
local cx, cy = mon.cellX + s[1], mon.cellY + s[2]
|
||||
if ow.map:isWalkableCell(cx, cy) and not ow:npcAtCell(cx, cy) then
|
||||
U.log("approach cell (4, 6) is blocked, standing on",
|
||||
cx, cy, "facing", s[3])
|
||||
U.teleport(game, MAP, cx, cy, s[3])
|
||||
U.wait(10)
|
||||
break
|
||||
end
|
||||
end
|
||||
end
|
||||
check("player is standing in front of the NIDORAN", facingTheMon())
|
||||
|
||||
-- ---- hand off, then stay out of the way --------------------------------
|
||||
U.log("........................................................")
|
||||
U.log("LISTEN NOW: press A to talk to the NIDORAN in front of you.")
|
||||
U.log(" RIGHT: the box types out \"NIDORAN: Bowbow!\", the male NIDORAN")
|
||||
U.log(" cry sounds ONCE after the last character lands, then the")
|
||||
U.log(" arrow blinks and the box waits for your A or B.")
|
||||
U.log(" BUG #247 sounds like: the line types out and then nothing, dead")
|
||||
U.log(" silence under the house music, arrow blinking.")
|
||||
U.log(" ALSO WRONG: the cry fires while the text is still typing (row")
|
||||
U.log(" order broken), or the box closes itself the moment the cry")
|
||||
U.log(" ends with no button press (waitForButton lost).")
|
||||
U.log("Input is yours from here on -- talk again as often as you like, and")
|
||||
U.log("the little boy on the left is the silent control to compare against.")
|
||||
U.log("........................................................")
|
||||
|
||||
while true do
|
||||
coroutine.yield()
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,96 @@
|
||||
-- Driver: party menu cursor alignment (#278). A manual eye check, not a
|
||||
-- pass/fail run -- no assertion in this repo can judge where a triangle
|
||||
-- sits against a reference screenshot.
|
||||
--
|
||||
-- pokered evidence. home/pokemon.asm PartyMenuInit seeds the shared menu
|
||||
-- cursor coordinates:
|
||||
--
|
||||
-- ld hl, wTopMenuItemY
|
||||
-- inc a ; a = 1
|
||||
-- ld [hli], a ; top menu item Y
|
||||
-- xor a
|
||||
-- ld [hli], a ; top menu item X
|
||||
--
|
||||
-- and home/window.asm PlaceMenuCursor walks that many rows down from
|
||||
-- hlcoord 0, 0. Meanwhile party_menu.asm RedrawPartyMenu_ starts the name
|
||||
-- column at hlcoord 3, 0. So a party entry's name is on tile row 0 while
|
||||
-- its cursor belongs on tile row 1: the level/HP line, level with the
|
||||
-- middle of the two-row icon.
|
||||
--
|
||||
-- The bug: PartyMenu drew the cursor at entryY(i), the name row, putting it
|
||||
-- a full tile (8px) too high on every slot. The fix draws it at y + 8.
|
||||
--
|
||||
-- Do NOT run this under POKEPORT_SPEED. Fast-forward scales only the logic
|
||||
-- clock while rendering and audio run on their own real-time accumulators
|
||||
-- (src/core/Game.lua), so a sped-up run can capture a half-drawn frame.
|
||||
--
|
||||
-- POKEPORT_DRIVER=tests/drivers/party_cursor_bug278_test.lua POKEPORT_IDENTITY=bug278 love .
|
||||
return function(game)
|
||||
local U = dofile("tests/drivers/util.lua")
|
||||
local Pokemon = require("src.pokemon.Pokemon")
|
||||
local Screens = require("src.ui.Screens")
|
||||
local PartyMenu = require("src.ui.PartyMenu")
|
||||
|
||||
local function check(label, ok)
|
||||
U.log(ok and "PASS" or "FAIL", label)
|
||||
return ok
|
||||
end
|
||||
|
||||
-- ---- preconditions a human's eye cannot separate from the bug ----------
|
||||
-- A cursor drawn off-screen, or a party too short to show the stride,
|
||||
-- both look exactly like "the offset is wrong". Check them first.
|
||||
|
||||
-- the geometry contract the fix depends on: 16px stride, name row at the
|
||||
-- top of each entry. If entryY ever changes, the +8 has to be revisited.
|
||||
check("entryY stride is 16px", PartyMenu.entryY(2) - PartyMenu.entryY(1) == 16)
|
||||
check("slot 1 name row is y=0", PartyMenu.entryY(1) == 0)
|
||||
|
||||
game.save.party = {
|
||||
Pokemon.new(game.data, "CHARIZARD", 50),
|
||||
Pokemon.new(game.data, "PIKACHU", 30),
|
||||
Pokemon.new(game.data, "SNORLAX", 77),
|
||||
Pokemon.new(game.data, "BULBASAUR", 12),
|
||||
}
|
||||
game.save.player.name = "bryan"
|
||||
check("party has enough slots to judge the stride", #game.save.party >= 3)
|
||||
|
||||
-- the window actually rendered: a black frame is not an offset bug
|
||||
check("renderer is up", game.renderer ~= nil)
|
||||
|
||||
U.teleport(game, "PALLET_TOWN", 10, 8, "down")
|
||||
U.wait(20)
|
||||
|
||||
Screens.push(game, "PartyMenu")
|
||||
U.wait(30)
|
||||
check("party menu is on top", game.stack:top() ~= nil)
|
||||
|
||||
U.shot(game, "bug278_party_cursor_slot1.png")
|
||||
|
||||
-- move the cursor down so the stride is visible too: a fix that is right
|
||||
-- on slot 1 and wrong further down would otherwise read as a pass
|
||||
U.tap(game, "down")
|
||||
U.wait(15)
|
||||
U.shot(game, "bug278_party_cursor_slot2.png")
|
||||
U.tap(game, "down")
|
||||
U.wait(15)
|
||||
U.shot(game, "bug278_party_cursor_slot3.png")
|
||||
|
||||
U.log("........................................................")
|
||||
U.log("LOOK NOW: the party menu is open. Screenshots were written to the")
|
||||
U.log(" LOVE save dir as bug278_party_cursor_slot1/2/3.png.")
|
||||
U.log(" RIGHT: the black triangle sits on the LOWER of each entry's two")
|
||||
U.log(" rows, level with the LEVEL/HP line and with the middle of")
|
||||
U.log(" the two-row party icon.")
|
||||
U.log(" BUG #278 looks like: the triangle riding up on the NAME row, its")
|
||||
U.log(" tip level with the first letter of the nickname.")
|
||||
U.log(" ALSO WRONG: correct on slot 1 but drifting on slots 2-4 (that is")
|
||||
U.log(" a stride bug, not an offset bug), or the triangle sliding")
|
||||
U.log(" a half tile so it straddles both rows.")
|
||||
U.log("Compare against the reference shot in issue #278. Input is yours")
|
||||
U.log("from here: up/down re-checks every slot, B closes the menu.")
|
||||
U.log("........................................................")
|
||||
|
||||
while true do
|
||||
coroutine.yield()
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,102 @@
|
||||
-- Driver: Pokedex owned-ball alignment for NIDORAN (#285). A manual eye
|
||||
-- check, not a pass/fail run.
|
||||
--
|
||||
-- The list draws each owned entry's pokeball marker one blank glyph after
|
||||
-- the name. ListMenu measured that gap with `#item.label`, the Lua string's
|
||||
-- BYTE length, but the charmap's gender symbols are multi-byte UTF-8: the
|
||||
-- eight glyphs of "NIDORAN<male>" occupy ten bytes, so those two rows put
|
||||
-- their ball 16px further right than every other species.
|
||||
--
|
||||
-- src/render/Font.lua already says this out loud in Font.width's own
|
||||
-- docstring ("callers that right-align with `#text * 8` mis-place them"),
|
||||
-- which is exactly the call this fix switches to.
|
||||
--
|
||||
-- This one has a built-in control case: NIDORINA, NIDORINO and NIDOKING sit
|
||||
-- immediately around the two Nidoran rows in dex order and carry no gender
|
||||
-- symbol, so a correct fix leaves all five balls in one vertical column.
|
||||
--
|
||||
-- Do NOT run this under POKEPORT_SPEED: fast-forward scales only the logic
|
||||
-- clock, so a sped-up run can capture a half-drawn frame.
|
||||
--
|
||||
-- POKEPORT_DRIVER=tests/drivers/pokedex_nidoran_bug285_test.lua POKEPORT_IDENTITY=bug285 love .
|
||||
return function(game)
|
||||
local U = dofile("tests/drivers/util.lua")
|
||||
local Pokemon = require("src.pokemon.Pokemon")
|
||||
local Screens = require("src.ui.Screens")
|
||||
local Font = require("src.render.Font")
|
||||
|
||||
local function check(label, ok)
|
||||
U.log(ok and "PASS" or "FAIL", label)
|
||||
return ok
|
||||
end
|
||||
|
||||
-- ---- preconditions the eye cannot separate from the bug ----------------
|
||||
-- A dex with nothing owned shows no balls at all, which looks the same as
|
||||
-- balls in the wrong place. Seed the neighbourhood and prove the premise.
|
||||
|
||||
local dexRow = { "NIDORAN_F", "NIDORINA", "NIDOQUEEN",
|
||||
"NIDORAN_M", "NIDORINO", "NIDOKING" }
|
||||
|
||||
game.save.pokedex = game.save.pokedex or { seen = {}, owned = {} }
|
||||
for _, id in ipairs(dexRow) do
|
||||
game.save.pokedex.seen[id] = true
|
||||
game.save.pokedex.owned[id] = true
|
||||
end
|
||||
game.save.party = { Pokemon.new(game.data, "NIDORAN_M", 10) }
|
||||
game.save.player.name = "bryan"
|
||||
|
||||
for _, id in ipairs(dexRow) do
|
||||
check("owned: " .. id, game.save.pokedex.owned[id] == true)
|
||||
end
|
||||
|
||||
-- the premise itself: the gender names really are multi-byte, so byte
|
||||
-- length and glyph width really do disagree for exactly these two rows
|
||||
local male = game.data.pokemon.NIDORAN_M.name
|
||||
local plain = game.data.pokemon.NIDORINO.name
|
||||
U.log("NIDORAN male name:", male, "bytes:", #male, "glyph width:", Font.width(male))
|
||||
check("gender name is multi-byte (this is the whole bug)",
|
||||
#male > Font.width(male) / 8)
|
||||
check("a plain neighbour agrees byte-for-glyph",
|
||||
#plain == Font.width(plain) / 8)
|
||||
|
||||
check("renderer is up", game.renderer ~= nil)
|
||||
|
||||
U.teleport(game, "PALLET_TOWN", 10, 8, "down")
|
||||
U.wait(20)
|
||||
|
||||
Screens.push(game, "PokedexMenu")
|
||||
U.wait(30)
|
||||
|
||||
-- Walk down far enough that the whole Nidoran block is on screen at once.
|
||||
-- The list shows seven rows with the cursor on the last, so stopping at
|
||||
-- dex 29 would leave NIDORAN female alone above six empty rows and there
|
||||
-- would be nothing to compare her ball against. Dex 35 puts 029-035 in
|
||||
-- view: both Nidoran rows plus four gender-free neighbours.
|
||||
for _ = 1, 34 do
|
||||
U.tap(game, "down")
|
||||
U.wait(2)
|
||||
end
|
||||
U.wait(20)
|
||||
U.shot(game, "bug285_pokedex_nidoran.png")
|
||||
|
||||
U.log("........................................................")
|
||||
U.log("LOOK NOW: the Pokedex list is parked on the NIDORAN block.")
|
||||
U.log(" Screenshot: bug285_pokedex_nidoran.png in the LOVE save dir.")
|
||||
U.log(" RIGHT: every owned row's pokeball sits one blank space after its")
|
||||
U.log(" name, so NIDORAN<f>, NIDORINA, NIDOQUEEN, NIDORAN<m>,")
|
||||
U.log(" NIDORINO and NIDOKING all show a ragged-but-consistent gap")
|
||||
U.log(" of exactly one space.")
|
||||
U.log(" BUG #285 looks like: the two NIDORAN rows alone kicking their")
|
||||
U.log(" ball two extra characters to the right, out of line with")
|
||||
U.log(" the four neighbours that have no gender symbol.")
|
||||
U.log(" ALSO WRONG: every ball moving together (that would mean the base")
|
||||
U.log(" offset changed, not the measurement), or the ball landing")
|
||||
U.log(" on top of the last letter with no gap at all.")
|
||||
U.log("Compare against the screenshot in issue #285. Input is yours from")
|
||||
U.log("here: up/down scrolls, and any other owned species is a control.")
|
||||
U.log("........................................................")
|
||||
|
||||
while true do
|
||||
coroutine.yield()
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,23 @@
|
||||
-- Driver: drops a fake 3-mon party into the overworld and then gets out
|
||||
-- of the way -- yields forever without ever touching input itself, so
|
||||
-- real keyboard/controller play works normally from here on. This is
|
||||
-- just a quick way to pop up a playable window with Pokemon already in
|
||||
-- the party, not a scripted playthrough.
|
||||
return function(game)
|
||||
local U = dofile("tests/drivers/util.lua")
|
||||
local Pokemon = require("src.pokemon.Pokemon")
|
||||
|
||||
game.save.party = {
|
||||
Pokemon.new(game.data, "CHARIZARD", 50),
|
||||
Pokemon.new(game.data, "PIKACHU", 30),
|
||||
Pokemon.new(game.data, "SNORLAX", 77),
|
||||
}
|
||||
game.save.player.name = "bryan"
|
||||
|
||||
U.teleport(game, "PALLET_TOWN", 10, 8, "down")
|
||||
U.log("popup_fake_save: party ready, handing off to real input")
|
||||
|
||||
while true do
|
||||
coroutine.yield()
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,243 @@
|
||||
-- Driver: NEW GAME through the silly Oak intro (example_silly_oak must be
|
||||
-- installed under mods/). Mashes text, picks every menu option in a fixed
|
||||
-- pattern, screenshots key beats, then asserts mod.save answers.
|
||||
--
|
||||
-- Setup:
|
||||
-- cp -r mods/examples/example_silly_oak mods/
|
||||
-- SHOT_DIR=/tmp/silly_oak POKEPORT_DRIVER=tests/drivers/silly_oak_intro_test.lua love .
|
||||
return function(game)
|
||||
local U = dofile("tests/drivers/util.lua")
|
||||
local DIR = os.getenv("SHOT_DIR") or "/tmp/shots"
|
||||
|
||||
local function top() return game.stack:top() end
|
||||
local function speechState()
|
||||
for _, s in ipairs(game.stack.states or {}) do
|
||||
if s.oakPic ~= nil or (s.steps and s.answers) then return s end
|
||||
end
|
||||
end
|
||||
local function topName()
|
||||
local s = top()
|
||||
if not s then return "?" end
|
||||
return s.screenId or (s.__index and tostring(s.__index)) or tostring(s)
|
||||
end
|
||||
local function isMenu(s)
|
||||
return s and s.items and s.index and s.tx ~= nil
|
||||
end
|
||||
local function isNaming(s)
|
||||
return s and s.glyphs ~= nil and s.title ~= nil
|
||||
end
|
||||
local function isChoiceBox(s)
|
||||
return s and s.onChoose and s.index ~= nil and not s.items
|
||||
end
|
||||
|
||||
-- confirm the mod actually loaded
|
||||
local loaded = false
|
||||
if game.modStatus and game.modStatus.loaded then
|
||||
for _, m in ipairs(game.modStatus.loaded) do
|
||||
if m.id == "example_silly_oak" then loaded = true break end
|
||||
end
|
||||
end
|
||||
if not loaded and game.mods and game.mods.mods then
|
||||
local m = game.mods.mods.example_silly_oak
|
||||
loaded = m and m.enabled and not m.failed
|
||||
end
|
||||
U.log("example_silly_oak loaded:", tostring(loaded))
|
||||
if not loaded then
|
||||
U.log("FAIL: copy the mod up first:")
|
||||
U.log(" cp -r mods/examples/example_silly_oak mods/")
|
||||
return
|
||||
end
|
||||
|
||||
U.wait(5)
|
||||
U.tap(game, "start") -- skip intro movie
|
||||
U.wait(15)
|
||||
U.tap(game, "a") -- title -> menu
|
||||
U.wait(8)
|
||||
-- with an existing save the menu is CONTINUE / NEW GAME / OPTION;
|
||||
-- always land on NEW GAME (row 2 when a save exists, else row 1)
|
||||
local ok, saved = pcall(function()
|
||||
return require("src.core.SaveData").load() ~= nil
|
||||
end)
|
||||
if ok and saved then
|
||||
U.tap(game, "down")
|
||||
U.wait(5)
|
||||
end
|
||||
U.tap(game, "a") -- NEW GAME
|
||||
U.wait(20)
|
||||
|
||||
-- confirm we actually entered OakSpeech, not CONTINUE into the world
|
||||
local entered = false
|
||||
for _ = 1, 60 do
|
||||
if speechState() then entered = true break end
|
||||
if game.overworld and top() == game.overworld then break end
|
||||
U.wait(1)
|
||||
end
|
||||
if not entered then
|
||||
U.log("FAIL: never entered OakSpeech (did we hit CONTINUE?)")
|
||||
U.log("top:", topName())
|
||||
return
|
||||
end
|
||||
|
||||
U.shot(game, DIR .. "/silly_01_start.png")
|
||||
|
||||
-- walk the speech: A advances text; menus get a deliberate pick pattern
|
||||
local picks = {
|
||||
-- silly_toast YES/NO: pick YES (index 1)
|
||||
toast = 1,
|
||||
-- naming player: take preset 1 (RED) via NEW NAME menu -> first preset
|
||||
-- snack: OLD ROD (index 3)
|
||||
snack = 3,
|
||||
-- trusts rival: NO (index 2)
|
||||
trust = 2,
|
||||
-- pineapple YES/NO: pick NO
|
||||
pineapple = 2,
|
||||
}
|
||||
local saw = {
|
||||
toast_kid = false, mew = false, snack = false, trust = false,
|
||||
pineapple = false, shrink = false,
|
||||
}
|
||||
local snackPicked, trustPicked = false, false
|
||||
local toastAnswered, pineappleAnswered = false, false
|
||||
local shot = {}
|
||||
|
||||
for _ = 1, 900 do
|
||||
local speech = speechState()
|
||||
local s = top()
|
||||
|
||||
if speech and speech.shrink then
|
||||
saw.shrink = true
|
||||
if not shot.shrink then
|
||||
shot.shrink = true
|
||||
U.shot(game, DIR .. "/silly_05_shrink.png")
|
||||
end
|
||||
break
|
||||
end
|
||||
|
||||
-- track which injected beats we hit + screenshot once each
|
||||
if speech and speech.steps and speech.step then
|
||||
local cur = speech.steps[speech.step]
|
||||
if cur then
|
||||
if cur.id == "silly_toast_kid" then
|
||||
saw.toast_kid = true
|
||||
if not shot.toast_kid then
|
||||
shot.toast_kid = true
|
||||
U.shot(game, DIR .. "/silly_04_toast_kid.png")
|
||||
end
|
||||
end
|
||||
if cur.id == "silly_mew" then
|
||||
saw.mew = true
|
||||
if not shot.mew then
|
||||
shot.mew = true
|
||||
U.shot(game, DIR .. "/silly_04b_mew.png")
|
||||
end
|
||||
end
|
||||
if cur.id == "silly_snack" then saw.snack = true end
|
||||
if cur.id == "silly_trust" then saw.trust = true end
|
||||
if cur.id == "silly_pineapple" then saw.pineapple = true end
|
||||
end
|
||||
end
|
||||
|
||||
if isChoiceBox(s) then
|
||||
-- YES/NO: move to desired row then A
|
||||
local want
|
||||
local cur = speech and speech.steps and speech.steps[speech.step]
|
||||
if cur and cur.id == "silly_toast" then
|
||||
want = picks.toast
|
||||
toastAnswered = true
|
||||
elseif cur and cur.id == "silly_pineapple" then
|
||||
want = picks.pineapple
|
||||
pineappleAnswered = true
|
||||
else
|
||||
want = 1
|
||||
end
|
||||
if s.index ~= want then
|
||||
U.tap(game, "down")
|
||||
U.wait(2)
|
||||
end
|
||||
U.tap(game, "a")
|
||||
U.wait(4)
|
||||
elseif isMenu(s) and not isNaming(s) then
|
||||
-- multi choice or naming presets
|
||||
local cur = speech and speech.steps and speech.steps[speech.step]
|
||||
local want = 1
|
||||
if cur and cur.id == "silly_snack" then
|
||||
want = picks.snack
|
||||
snackPicked = true
|
||||
if not shot.snack then
|
||||
shot.snack = true
|
||||
U.shot(game, DIR .. "/silly_02_snack.png")
|
||||
end
|
||||
elseif cur and cur.id == "silly_trust" then
|
||||
want = picks.trust
|
||||
trustPicked = true
|
||||
if not shot.trust then
|
||||
shot.trust = true
|
||||
U.shot(game, DIR .. "/silly_03_trust.png")
|
||||
end
|
||||
elseif s.items and s.items[1] and s.items[1].label == "NEW NAME" then
|
||||
-- naming preset menu: pick first preset (row 2)
|
||||
want = 2
|
||||
end
|
||||
while s.index < want do
|
||||
U.tap(game, "down")
|
||||
U.wait(2)
|
||||
s = top()
|
||||
if not isMenu(s) then break end
|
||||
end
|
||||
U.tap(game, "a")
|
||||
U.wait(4)
|
||||
else
|
||||
U.tap(game, "a")
|
||||
U.wait(2)
|
||||
end
|
||||
|
||||
if game.overworld and top() == game.overworld then break end
|
||||
end
|
||||
|
||||
-- finish shrink + land in overworld
|
||||
for _ = 1, 150 do
|
||||
if game.overworld and top() == game.overworld then break end
|
||||
U.wait(1)
|
||||
end
|
||||
U.wait(10)
|
||||
U.shot(game, DIR .. "/silly_06_overworld.png")
|
||||
|
||||
local bucket = (game.save and game.save.modData
|
||||
and game.save.modData.example_silly_oak)
|
||||
or (game.mods and game.mods.modSave
|
||||
and game.mods.modSave.example_silly_oak)
|
||||
or {}
|
||||
|
||||
U.log("saw toast_kid:", tostring(saw.toast_kid),
|
||||
"mew:", tostring(saw.mew),
|
||||
"snack:", tostring(saw.snack),
|
||||
"trust:", tostring(saw.trust),
|
||||
"pineapple:", tostring(saw.pineapple),
|
||||
"shrink:", tostring(saw.shrink))
|
||||
U.log("answers:",
|
||||
"likes_toast=", tostring(bucket.likes_toast),
|
||||
"snack=", tostring(bucket.snack),
|
||||
"trusts_rival=", tostring(bucket.trusts_rival),
|
||||
"pineapple=", tostring(bucket.pineapple_on_pizza),
|
||||
"quiz_done=", tostring(bucket.quiz_done))
|
||||
U.log("player=", game.save and game.save.player and game.save.player.name,
|
||||
"rival=", game.save and game.save.player and game.save.player.rival)
|
||||
U.log("top:", topName(),
|
||||
"map:", game.overworld and game.overworld.map
|
||||
and game.overworld.map.id or "?")
|
||||
|
||||
local function need(cond, msg)
|
||||
if not cond then U.log("FAIL:", msg) else U.log("ok:", msg) end
|
||||
end
|
||||
need(saw.toast_kid, "custom Toast Kid sprite beat ran")
|
||||
need(saw.mew, "MEW sprite beat ran")
|
||||
need(saw.snack or snackPicked, "snack choice ran")
|
||||
need(saw.trust or trustPicked, "rival trust choice ran")
|
||||
need(saw.pineapple or pineappleAnswered, "pineapple yes/no ran")
|
||||
need(bucket.likes_toast == true, "likes_toast saved true")
|
||||
need(bucket.snack == "OLD ROD", "snack saved OLD ROD")
|
||||
need(bucket.trusts_rival == false, "trusts_rival saved false")
|
||||
need(bucket.pineapple_on_pizza == false, "pineapple saved false")
|
||||
need(bucket.quiz_done == true, "quiz_done set")
|
||||
need(game.overworld and top() == game.overworld, "landed in overworld")
|
||||
end
|
||||
@@ -0,0 +1,73 @@
|
||||
-- Driver: issue #251 -- "Silent Speary the Spearow". MANUAL LISTEN, not a
|
||||
-- pass/fail run: the entire symptom is a missing sound, so nothing here can
|
||||
-- assert it. This only parks the player nose to beak with Speary, fires the
|
||||
-- line once at real speed, and then hands the pad back.
|
||||
--
|
||||
-- pokered's ViridianNicknameHouseSpearowText (scripts/ViridianNicknameHouse.asm)
|
||||
-- is a text_asm that PrintTexts "SPEARY: Tetweet!", then runs PlayCry with
|
||||
-- SPEAROW and WaitForSoundToFinish before TextScriptEnd. PrintText returns as
|
||||
-- soon as the string is placed, so the cry starts only once the box has typed
|
||||
-- out; and because ViridianNicknameHouse_Script is `jp EnableAutoTextBoxDrawing`
|
||||
-- (AutoTextBoxDrawingCommon zeroes wDoNotWaitForButtonPressAfterDisplayingText,
|
||||
-- home/window.asm), DisplayTextID still falls through AfterDisplayingTextID into
|
||||
-- WaitForTextScrollButtonPress afterwards -- the box holds for A/B, it does not
|
||||
-- pop itself. The port's talk script carried no cry row at all, so Speary was
|
||||
-- mute; the fix is the play_cry waitForButton form
|
||||
-- (data/scripts/flavor/viridian_nickname_house.lua + TextBox's opts.auto.wait).
|
||||
--
|
||||
-- Do NOT run this under POKEPORT_SPEED. The logic clock is what gets scaled,
|
||||
-- the audio clock is not, so a fast-forwarded run desynchronises the exact
|
||||
-- typing-then-cry ordering this driver exists to let a human judge.
|
||||
return function(game)
|
||||
local U = dofile("tests/drivers/util.lua")
|
||||
|
||||
-- ViridianNicknameHouse_Object object 3 is SPRITE_BIRD at cell (5, 5)
|
||||
-- (pokered/data/maps/objects/ViridianNicknameHouse.asm). (5, 6) is the
|
||||
-- floor cell directly below it, so facing up puts Speary in the cell
|
||||
-- OverworldState:interact reads for an A press (npcAtCell on facingCell).
|
||||
U.teleport(game, "VIRIDIAN_NICKNAME_HOUSE", 5, 6, "up")
|
||||
U.wait(30) -- let the map music settle before anything has to be heard over it
|
||||
|
||||
-- That object_event is WALK / LEFT_RIGHT and NPC:update paces it with no
|
||||
-- range clamp, so left alone Speary strolls out from under the A press
|
||||
-- between listens. Pin the wander rather than setting npc.frozen: talkTo
|
||||
-- freezes and unfreezes around the box, so a frozen flag would be cleared
|
||||
-- the moment the first listen ends.
|
||||
local speary
|
||||
for _, n in ipairs(game.overworld.npcs) do
|
||||
if n.def and n.def.name == "VIRIDIANNICKNAMEHOUSE_SPEAROW" then speary = n end
|
||||
end
|
||||
if speary then
|
||||
speary.wanders = false
|
||||
else
|
||||
U.log("WARNING: Speary is not on this map; check the object list.")
|
||||
end
|
||||
|
||||
U.log("#251 Speary the Spearow -- this one is judged by ear, not by eye.")
|
||||
U.log("About to talk to Speary. The box types out \"SPEARY: Tetweet!\".")
|
||||
U.log("PASS: the moment the last letter lands, a short SPEAROW cry plays,")
|
||||
U.log(" and only after it finishes does the down arrow start blinking.")
|
||||
U.log("FAIL (the bug): dead silence. The arrow blinks the instant the text")
|
||||
U.log(" finishes and no cry is ever heard.")
|
||||
U.log("ALSO FAIL: the box closes itself when the cry ends, with no A press.")
|
||||
U.wait(90) -- a beat to read the above before the sound happens
|
||||
|
||||
U.tap(game, "a")
|
||||
|
||||
-- Call the moment out on the frame the typewriter finishes, which is where
|
||||
-- PrintText returns and PlayCry runs in the ROM.
|
||||
for _ = 1, 600 do
|
||||
local top = game.stack:top()
|
||||
if top and top.pages and top.done then break end
|
||||
U.wait(1)
|
||||
end
|
||||
U.log(">>> NOW: the SPEAROW cry should be sounding. <<<")
|
||||
|
||||
-- popup_fake_save hand-off: never touch input again, so real keyboard and
|
||||
-- controller play works from here on.
|
||||
U.log("Controls are yours. A closes the box; A again replays the line")
|
||||
U.log("(Speary is pinned in front of you, so it stays one button).")
|
||||
while true do
|
||||
coroutine.yield()
|
||||
end
|
||||
end
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user