mirror of
https://github.com/bryanthaboi/gen1recomp.git
synced 2026-08-12 08:21:02 +02:00
intro api upgrade (#294)
* intro api upgrade * api updates * fix tests * updated templates
This commit is contained in:
@@ -25,7 +25,9 @@ body:
|
|||||||
options:
|
options:
|
||||||
- macOS
|
- macOS
|
||||||
- Windows
|
- Windows
|
||||||
- Both
|
- Linux
|
||||||
|
- Android
|
||||||
|
- Multiple platforms
|
||||||
validations:
|
validations:
|
||||||
required: true
|
required: true
|
||||||
|
|
||||||
@@ -38,6 +40,17 @@ body:
|
|||||||
validations:
|
validations:
|
||||||
required: true
|
required: true
|
||||||
|
|
||||||
|
- type: input
|
||||||
|
id: discord
|
||||||
|
attributes:
|
||||||
|
label: Discord username (optional)
|
||||||
|
description: >
|
||||||
|
So maintainers can ping you on Discord if they need a quick follow-up.
|
||||||
|
Leave blank if you'd rather keep everything on GitHub.
|
||||||
|
placeholder: yourname
|
||||||
|
validations:
|
||||||
|
required: false
|
||||||
|
|
||||||
- type: input
|
- type: input
|
||||||
id: location
|
id: location
|
||||||
attributes:
|
attributes:
|
||||||
|
|||||||
@@ -1,35 +1,53 @@
|
|||||||
name: Feature request
|
name: Feature request
|
||||||
description: Ask for something new, or a change to how something already works.
|
description: Ask for something new in the engine, launcher, or platform — not a content/gameplay mod.
|
||||||
labels: ["enhancement"]
|
labels: ["enhancement"]
|
||||||
body:
|
body:
|
||||||
- type: markdown
|
- type: markdown
|
||||||
attributes:
|
attributes:
|
||||||
value: |
|
value: |
|
||||||
|
Use this for **engine / launcher / platform** work (ports, video options, save
|
||||||
|
tooling, networking, mod API seams, docs).
|
||||||
|
|
||||||
|
If what you want is a gameplay, cosmetic, audio, or QoL change that a Lua mod
|
||||||
|
could ship — running shoes, alternate sprites, day/night, shiny indicators,
|
||||||
|
Gen 2-like battle toggles, soundtrack packs — open a
|
||||||
|
**[Mod request](https://github.com/bryanthaboi/pokemon-gen1-recomp-project/issues/new?template=mod_request.yml)**
|
||||||
|
instead.
|
||||||
|
|
||||||
"Can we add X" on its own is hard to act on. Say what you want, why you want it,
|
"Can we add X" on its own is hard to act on. Say what you want, why you want it,
|
||||||
and how you picture it working. A request with real detail is one someone can
|
and how you picture it working.
|
||||||
actually build without guessing at what you meant.
|
|
||||||
|
|
||||||
- type: input
|
- type: input
|
||||||
id: summary
|
id: summary
|
||||||
attributes:
|
attributes:
|
||||||
label: One line summary
|
label: One line summary
|
||||||
description: What you want, in a sentence.
|
description: What you want, in a sentence.
|
||||||
placeholder: Add a run toggle so you can move at bike speed without the bike
|
placeholder: Add Linux AppImage releases next to the macOS and Windows builds
|
||||||
validations:
|
validations:
|
||||||
required: true
|
required: true
|
||||||
|
|
||||||
|
- type: input
|
||||||
|
id: discord
|
||||||
|
attributes:
|
||||||
|
label: Discord username (optional)
|
||||||
|
description: >
|
||||||
|
So maintainers can ping you on Discord if they need a quick follow-up.
|
||||||
|
Leave blank if you'd rather keep everything on GitHub.
|
||||||
|
placeholder: yourname
|
||||||
|
validations:
|
||||||
|
required: false
|
||||||
|
|
||||||
- type: textarea
|
- type: textarea
|
||||||
id: what
|
id: what
|
||||||
attributes:
|
attributes:
|
||||||
label: What do you want
|
label: What do you want
|
||||||
description: >
|
description: >
|
||||||
Describe it properly. What is it, where does it live in the game, what does the
|
Describe it properly. What is it, where does it live (launcher, options,
|
||||||
player see or do. If it changes something that already exists, say what it does today
|
engine), what does the player see or do. If it changes something that already
|
||||||
and what it should do instead.
|
exists, say what it does today and what it should do instead.
|
||||||
placeholder: |
|
placeholder: |
|
||||||
A hold-to-run button, the way later Pokemon games do it. Hold B while moving on
|
Ship a Linux AppImage on each release, same version as the macOS/Windows builds,
|
||||||
foot and the player moves at bike speed. Release and you go back to walking.
|
with the same save folder layout and mod discovery path.
|
||||||
Doesn't work indoors where the bike doesn't work, doesn't work in battles or menus.
|
|
||||||
validations:
|
validations:
|
||||||
required: true
|
required: true
|
||||||
|
|
||||||
@@ -41,9 +59,8 @@ body:
|
|||||||
What's annoying or missing right now. What does this fix. If it's just because you
|
What's annoying or missing right now. What does this fix. If it's just because you
|
||||||
think it would be fun, say that, it's a real answer.
|
think it would be fun, say that, it's a real answer.
|
||||||
placeholder: |
|
placeholder: |
|
||||||
Backtracking across routes you've already cleared is slow, and the bike is a menu
|
LÖVE already runs on Linux; without a packaged build, players have to assemble
|
||||||
dive away and doesn't work in a lot of places. Running would cut a lot of dead time
|
it themselves and miss release notes / update checks.
|
||||||
without making the game easier.
|
|
||||||
validations:
|
validations:
|
||||||
required: true
|
required: true
|
||||||
|
|
||||||
@@ -52,14 +69,12 @@ body:
|
|||||||
attributes:
|
attributes:
|
||||||
label: How should it work
|
label: How should it work
|
||||||
description: >
|
description: >
|
||||||
The specifics. Which button, which menu, what happens in the edge cases. If you don't
|
The specifics. Which menu, what happens in the edge cases. If you don't
|
||||||
know, say what you'd expect as a player and leave the rest open.
|
know, say what you'd expect as a player and leave the rest open.
|
||||||
placeholder: |
|
placeholder: |
|
||||||
- Hold B on the overworld to run
|
- GitHub Releases asset next to the .dmg / .exe
|
||||||
- Same speed as the bike
|
- Same options.lua / mods/ layout as desktop
|
||||||
- Disabled anywhere the bike is disabled
|
- Documented in the README install section
|
||||||
- Sprite should probably need a running animation, not sure what that takes
|
|
||||||
- No effect on encounter rates
|
|
||||||
validations:
|
validations:
|
||||||
required: true
|
required: true
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,115 @@
|
|||||||
|
name: Mod request
|
||||||
|
description: Ask for a gameplay, cosmetic, audio, or QoL change that belongs as a Lua mod.
|
||||||
|
labels: ["mod request"]
|
||||||
|
body:
|
||||||
|
- type: markdown
|
||||||
|
attributes:
|
||||||
|
value: |
|
||||||
|
This tracker is for ideas that should ship as **mods**, not as core engine
|
||||||
|
features — alternate sprites, running shoes, day/night, shiny indicators,
|
||||||
|
soundtrack packs, Gen 2-like battle toggles, map cosmetics, bag QoL, etc.
|
||||||
|
|
||||||
|
The engine already exposes a lot of this through registries and hooks
|
||||||
|
([modding wiki](https://github.com/bryanthaboi/pokemon-gen1-recomp-project/wiki)).
|
||||||
|
Use a **Feature request** instead for launcher / ports / video options /
|
||||||
|
networking / save tooling / new API seams.
|
||||||
|
|
||||||
|
- type: input
|
||||||
|
id: summary
|
||||||
|
attributes:
|
||||||
|
label: One line summary
|
||||||
|
description: What the mod should do, in a sentence.
|
||||||
|
placeholder: Hold B to run at bike speed on the overworld
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
|
||||||
|
- type: input
|
||||||
|
id: discord
|
||||||
|
attributes:
|
||||||
|
label: Discord username (optional)
|
||||||
|
description: >
|
||||||
|
So maintainers or mod authors can ping you on Discord if they pick this up.
|
||||||
|
Leave blank if you'd rather keep everything on GitHub.
|
||||||
|
placeholder: yourname
|
||||||
|
validations:
|
||||||
|
required: false
|
||||||
|
|
||||||
|
- type: textarea
|
||||||
|
id: what
|
||||||
|
attributes:
|
||||||
|
label: What should the mod do
|
||||||
|
description: >
|
||||||
|
Describe the player-facing behavior. What changes, where, what does the
|
||||||
|
player see or press. If it toggles from Options or a START-menu entry, say so.
|
||||||
|
placeholder: |
|
||||||
|
Hold B while walking outdoors to move at bike speed. Release to walk again.
|
||||||
|
Same places the bike is allowed; no effect in battles or menus.
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
|
||||||
|
- type: textarea
|
||||||
|
id: why
|
||||||
|
attributes:
|
||||||
|
label: Why is this worth doing as a mod
|
||||||
|
description: >
|
||||||
|
Why optional/modded rather than a core option. Who wants it on, who wants
|
||||||
|
vanilla left alone.
|
||||||
|
placeholder: |
|
||||||
|
Great for replaying and backtracking, but some people want a strict Gen 1
|
||||||
|
pace. A mod (or an opt-in mod option) keeps both camps happy.
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
|
||||||
|
- type: textarea
|
||||||
|
id: how
|
||||||
|
attributes:
|
||||||
|
label: How should it work
|
||||||
|
description: >
|
||||||
|
Buttons, menus, edge cases, whether it needs new art/audio. If you know a
|
||||||
|
hook or registry that fits (movement.speed, pokemon.sprite, rulesets, …),
|
||||||
|
mention it — otherwise leave it open.
|
||||||
|
placeholder: |
|
||||||
|
- Hold B on the overworld
|
||||||
|
- Same step timing as the bike
|
||||||
|
- Disabled where the bike is disabled
|
||||||
|
- Prefer hooks:wrap("movement.speed") if that still fits
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
|
||||||
|
- type: dropdown
|
||||||
|
id: vanilla
|
||||||
|
attributes:
|
||||||
|
label: With the mod off, is vanilla unchanged
|
||||||
|
options:
|
||||||
|
- Yes — parity when disabled
|
||||||
|
- No — it would replace something always-on
|
||||||
|
- Not sure
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
|
||||||
|
- type: dropdown
|
||||||
|
id: category
|
||||||
|
attributes:
|
||||||
|
label: Best-fit mod category
|
||||||
|
description: Same taxonomy as example mods (BALANCE, GRAPHICS, AUDIO, …).
|
||||||
|
options:
|
||||||
|
- GAMEPLAY / QoL
|
||||||
|
- GRAPHICS
|
||||||
|
- AUDIO
|
||||||
|
- BALANCE / ruleset
|
||||||
|
- CONTENT (maps, encounters, trainers)
|
||||||
|
- UI / TOOL
|
||||||
|
- TOTAL_CONVERSION-ish
|
||||||
|
- Not sure
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
|
||||||
|
- type: textarea
|
||||||
|
id: extra
|
||||||
|
attributes:
|
||||||
|
label: Anything else
|
||||||
|
description: >
|
||||||
|
Reference screenshots, other games/hacks that do it, related issues, or
|
||||||
|
"I'd like to try writing this myself." Leave blank if nothing comes to mind.
|
||||||
|
validations:
|
||||||
|
required: false
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
# Example mod gallery
|
# Example mod gallery
|
||||||
|
|
||||||
Seven reference mods, one per modder persona. Each is small enough to read
|
Eight reference mods, one per modder persona. Each is small enough to read
|
||||||
in one sitting, exercises a different slice of the mod API, and is a real,
|
in one sitting, exercises a different slice of the mod API, and is a real,
|
||||||
runnable, tested mod — not a snippet.
|
runnable, tested mod — not a snippet.
|
||||||
|
|
||||||
@@ -16,6 +16,7 @@ Copy the one closest to what you want to build.
|
|||||||
| 5 | [`example_weather`](example_weather) | Mechanic designer | `MECHANIC` | Rain that scales WATER and FIRE damage, behind a ruleset |
|
| 5 | [`example_weather`](example_weather) | Mechanic designer | `MECHANIC` | Rain that scales WATER and FIRE damage, behind a ruleset |
|
||||||
| 6 | [`example_dexnav`](example_dexnav) | Tool builder | `TOOL` | A START-menu dex overlay with an inter-mod API |
|
| 6 | [`example_dexnav`](example_dexnav) | Tool builder | `TOOL` | A START-menu dex overlay with an inter-mod API |
|
||||||
| 7 | [`example_mini_conversion`](example_mini_conversion) | TC team | `TOTAL_CONVERSION` | Sable Cove: one town, three species, one badge |
|
| 7 | [`example_mini_conversion`](example_mini_conversion) | TC team | `TOTAL_CONVERSION` | Sable Cove: one town, three species, one badge |
|
||||||
|
| 8 | [`example_silly_oak`](example_silly_oak) | Intro author | `UI` | Extra Oak questions, sprite swaps, answers in `mod.save` |
|
||||||
|
|
||||||
## None of these load by default
|
## None of these load by default
|
||||||
|
|
||||||
@@ -46,7 +47,8 @@ and `field`, and exercises:
|
|||||||
- both buses — `events:on` / `events:emit` and `hooks:wrap` — across
|
- both buses — `events:on` / `events:emit` and `hooks:wrap` — across
|
||||||
`music.select`, `battle.damage`, `ui.start_menu.items`, `ui.options.rows`,
|
`music.select`, `battle.damage`, `ui.start_menu.items`, `ui.options.rows`,
|
||||||
`battle.started`, `battle.turn_started`, `battle.ended`, `flag.changed`,
|
`battle.started`, `battle.turn_started`, `battle.ended`, `flag.changed`,
|
||||||
`game.ready` and `assets.transformed`
|
`game.ready`, `assets.transformed`, `intro.oak_speech.build`,
|
||||||
|
`intro.oak_speech.answered` and `intro.oak_speech.finished`
|
||||||
- `mod.save`, `mod.options`, `mod.exports`, `mod.commands`, `mod.ui`,
|
- `mod.save`, `mod.options`, `mod.exports`, `mod.commands`, `mod.ui`,
|
||||||
`mod:read`, `mod.log` and `mod.path`
|
`mod:read`, `mod.log` and `mod.path`
|
||||||
- asset transforms, the `trueColor` opt-out, script labels and `choice`,
|
- asset transforms, the `trueColor` opt-out, script labels and `choice`,
|
||||||
@@ -66,7 +68,7 @@ mods/examples/<id>/
|
|||||||
.modkitignore keeps the suite out of the distributed package
|
.modkitignore keeps the suite out of the distributed package
|
||||||
```
|
```
|
||||||
|
|
||||||
`tests/mod_examples_tests.lua` in the engine's own suite loads all seven
|
`tests/mod_examples_tests.lua` in the engine's own suite loads all eight
|
||||||
together and asserts the above, so the gallery cannot rot.
|
together and asserts the above, so the gallery cannot rot.
|
||||||
|
|
||||||
These example mods arent perfect and are just examples to show you basics of wahts possible, but way more than just this is possible.
|
These example mods arent perfect and are just examples to show you basics of wahts possible, but way more than just this is possible.
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
# Changelog
|
||||||
|
|
||||||
|
Format: [keep a changelog](https://keepachangelog.com/en/1.1.0/).
|
||||||
|
Version headings match `manifest.json`'s `version`.
|
||||||
|
|
||||||
|
## 1.0.0
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- `intro.oak_speech.build` wrap that injects toast / MEW / snack / rival-trust / pineapple beats.
|
||||||
|
- Answers written to `mod.save` via `intro.oak_speech.answered`.
|
||||||
|
- Custom `toast_kid.png` sprite shown mid-speech.
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
# Silly Oak Intro Example
|
||||||
|
|
||||||
|
Hooks Oak's NEW GAME speech: extra questions, sprite swaps (Oak, rival,
|
||||||
|
player, MEW, and a custom Toast Kid pic), and answers stored in `mod.save`.
|
||||||
|
|
||||||
|
## Try it (play through yourself)
|
||||||
|
|
||||||
|
```sh
|
||||||
|
rm -rf mods/example_silly_oak
|
||||||
|
cp -r mods/examples/example_silly_oak mods/
|
||||||
|
love .
|
||||||
|
```
|
||||||
|
|
||||||
|
Then **NEW GAME** and mash A / pick the menus. Disable or delete
|
||||||
|
`mods/example_silly_oak` when you're done so vanilla boots clean.
|
||||||
|
|
||||||
|
## Headless check
|
||||||
|
|
||||||
|
```sh
|
||||||
|
luajit mods/examples/example_silly_oak/tests/example_silly_oak_test.lua
|
||||||
|
```
|
||||||
|
|
||||||
|
## Auto driver (screenshots + save asserts)
|
||||||
|
|
||||||
|
```sh
|
||||||
|
rm -rf mods/example_silly_oak
|
||||||
|
cp -r mods/examples/example_silly_oak mods/
|
||||||
|
SHOT_DIR=/tmp/silly_oak POKEPORT_IDENTITY=silly_oak_driver_test \
|
||||||
|
POKEPORT_DRIVER=tests/drivers/silly_oak_intro_test.lua POKEPORT_SPEED=8 love .
|
||||||
|
```
|
||||||
|
|
||||||
|
`POKEPORT_IDENTITY` keeps this run's save out of your normal slot.
|
||||||
|
|
||||||
|
## What it demonstrates
|
||||||
|
|
||||||
|
| Seam | Where |
|
||||||
|
|---|---|
|
||||||
|
| `hooks:wrap("intro.oak_speech.build")` | `main.lua` — reshape the step list |
|
||||||
|
| `mod.ui.insertStepAfter` / `insertStepBefore` | `main.lua` — anchored on vanilla step ids |
|
||||||
|
| step kinds `say` / `yesno` / `choice` | `main.lua` |
|
||||||
|
| pics: `"oak"`, `"rival"`, `"player"`, pokemon, custom image | `main.lua` |
|
||||||
|
| `events:on("intro.oak_speech.answered")` | `main.lua` → `mod.save` |
|
||||||
|
| `events:on("intro.oak_speech.finished")` | `main.lua` |
|
||||||
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")
|
||||||
@@ -299,8 +299,13 @@ local function makeBattler(data, mon, isPlayer, save)
|
|||||||
curStats = mon.stats,
|
curStats = mon.stats,
|
||||||
curTypes = def.types,
|
curTypes = def.types,
|
||||||
curMoves = mon.moves,
|
curMoves = mon.moves,
|
||||||
sprite = getImage(isPlayer and def.spriteBack or def.spriteFront,
|
sprite = (function()
|
||||||
monPalette(data, mon.species), def.trueColor),
|
local Sprites = require("src.pokemon.Sprites")
|
||||||
|
local path, tc = Sprites.path(data, mon.species,
|
||||||
|
isPlayer and "back" or "front",
|
||||||
|
{ mon = mon, kind = "battle" })
|
||||||
|
return getImage(path, monPalette(data, mon.species), tc)
|
||||||
|
end)(),
|
||||||
}
|
}
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -318,13 +323,16 @@ BattleState.makeBattler = makeBattler
|
|||||||
function BattleState:speciesSprite(species, isPlayerSide)
|
function BattleState:speciesSprite(species, isPlayerSide)
|
||||||
local def = self.data.pokemon[species]
|
local def = self.data.pokemon[species]
|
||||||
if not def then return nil end
|
if not def then return nil end
|
||||||
|
local Sprites = require("src.pokemon.Sprites")
|
||||||
|
local path, tc = Sprites.path(self.data, species,
|
||||||
|
isPlayerSide and "back" or "front", { kind = "battle" })
|
||||||
local PaletteFX = require("src.render.PaletteFX")
|
local PaletteFX = require("src.render.PaletteFX")
|
||||||
local colors = PaletteFX.monPal(self.data, species, true)
|
local colors = PaletteFX.monPal(self.data, species, true)
|
||||||
local name = "GRAYMON"
|
local name = "GRAYMON"
|
||||||
if PaletteFX.usesGbcPack() then name = "redpp:GRAYMON" end
|
if PaletteFX.usesGbcPack() then name = "redpp:GRAYMON" end
|
||||||
return getImage(isPlayerSide and def.spriteBack or def.spriteFront,
|
return getImage(path,
|
||||||
colors and { name = name, colors = colors } or nil,
|
colors and { name = name, colors = colors } or nil,
|
||||||
def.trueColor)
|
tc)
|
||||||
end
|
end
|
||||||
|
|
||||||
local function markSeen(game, species)
|
local function markSeen(game, species)
|
||||||
@@ -4473,6 +4481,11 @@ function BattleState:draw()
|
|||||||
love.graphics.rectangle("fill", 0, 0, 160, 144)
|
love.graphics.rectangle("fill", 0, 0, 160, 144)
|
||||||
end
|
end
|
||||||
love.graphics.setColor(1, 1, 1, 1)
|
love.graphics.setColor(1, 1, 1, 1)
|
||||||
|
-- battle.overlay: shiny sparkles, custom HUD chrome, etc. Draw-only;
|
||||||
|
-- the vanilla link is a no-op so an empty chain costs nothing.
|
||||||
|
if Runtime.wantsHook("battle.overlay") then
|
||||||
|
Runtime.call("battle.overlay", function() end, self)
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
return BattleState
|
return BattleState
|
||||||
|
|||||||
@@ -581,8 +581,12 @@ MoveEffects.full = {
|
|||||||
},
|
},
|
||||||
HYPER_BEAM_EFFECT = {
|
HYPER_BEAM_EFFECT = {
|
||||||
afterDamage = function(ctx)
|
afterDamage = function(ctx)
|
||||||
-- no recharge when the target faints OR its substitute breaks
|
-- Gen 1: no recharge when the target faints OR its substitute breaks.
|
||||||
if ctx.target.mon.hp > 0 and not ctx.brokeSub then
|
-- Ruleset hyperBeamSkipRechargeOnKO=false forces Gen 2+ always-recharge.
|
||||||
|
local ruleset = ctx.battle and ctx.battle.ruleset
|
||||||
|
local skipOnKO = not ruleset or ruleset.hyperBeamSkipRechargeOnKO ~= false
|
||||||
|
local targetDown = ctx.target.mon.hp <= 0 or ctx.brokeSub
|
||||||
|
if not skipOnKO or not targetDown then
|
||||||
ctx.user.mustRecharge = true
|
ctx.user.mustRecharge = true
|
||||||
end
|
end
|
||||||
end,
|
end,
|
||||||
|
|||||||
@@ -16,4 +16,7 @@ return {
|
|||||||
-- Wild/trainer enemies never spend PP (DecrementPP only touches the
|
-- Wild/trainer enemies never spend PP (DecrementPP only touches the
|
||||||
-- player side in pokered). They therefore never Struggle from empty PP.
|
-- player side in pokered). They therefore never Struggle from empty PP.
|
||||||
enemyUnlimitedPP = true,
|
enemyUnlimitedPP = true,
|
||||||
|
-- Gen 1 Hyper Beam: no recharge when the target faints (or its
|
||||||
|
-- substitute breaks). Set false to always recharge like Gen 2+.
|
||||||
|
hyperBeamSkipRechargeOnKO = true,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,4 +11,6 @@ return {
|
|||||||
focusEnergyBug = false,
|
focusEnergyBug = false,
|
||||||
-- Gen 2+ style: AI opponents deplete PP and can Struggle when empty.
|
-- Gen 2+ style: AI opponents deplete PP and can Struggle when empty.
|
||||||
enemyUnlimitedPP = false,
|
enemyUnlimitedPP = false,
|
||||||
|
-- Gen 2+: Hyper Beam always forces a recharge turn, even on a KO.
|
||||||
|
hyperBeamSkipRechargeOnKO = false,
|
||||||
}
|
}
|
||||||
|
|||||||
+31
-1
@@ -24,7 +24,31 @@ local FILTER_HIGHGAIN = { 0.4, 0.16, 0.064 }
|
|||||||
local filterLevel = 0
|
local filterLevel = 0
|
||||||
|
|
||||||
local function applyVolume(src)
|
local function applyVolume(src)
|
||||||
if src then pcall(src.setVolume, src, VOLUME * volumeScale) end
|
if not src then return end
|
||||||
|
local vol = VOLUME * volumeScale
|
||||||
|
if Runtime.wantsHook("music.volume") then
|
||||||
|
local ctx = {
|
||||||
|
song = state.current,
|
||||||
|
mapSong = state.mapSong,
|
||||||
|
onBike = state.onBike,
|
||||||
|
surfing = state.surfing,
|
||||||
|
fading = state.fade ~= nil,
|
||||||
|
optionScale = volumeScale,
|
||||||
|
}
|
||||||
|
local ok, Game = pcall(require, "src.core.Game")
|
||||||
|
if ok and Game then
|
||||||
|
local ow = Game.overworld
|
||||||
|
if ow and ow.player then
|
||||||
|
ctx.x, ctx.y = ow.player.cellX, ow.player.cellY
|
||||||
|
ctx.mapId = ow.map and ow.map.id
|
||||||
|
ctx.tod = ow.tod
|
||||||
|
end
|
||||||
|
end
|
||||||
|
vol = Runtime.call("music.volume", function(v) return v end, vol, ctx)
|
||||||
|
vol = tonumber(vol) or (VOLUME * volumeScale)
|
||||||
|
if vol < 0 then vol = 0 end
|
||||||
|
end
|
||||||
|
pcall(src.setVolume, src, vol)
|
||||||
end
|
end
|
||||||
|
|
||||||
-- Source:setFilter needs OpenAL EFX; the pcall degrades to unfiltered
|
-- Source:setFilter needs OpenAL EFX; the pcall degrades to unfiltered
|
||||||
@@ -408,6 +432,12 @@ end
|
|||||||
-- restores the map theme after a one-shot jingle
|
-- restores the map theme after a one-shot jingle
|
||||||
function Music.update(data)
|
function Music.update(data)
|
||||||
if state.chip then require("src.core.ChipAudio").update() end
|
if state.chip then require("src.core.ChipAudio").update() end
|
||||||
|
-- distance / indoor muffling mods re-apply volume every frame while
|
||||||
|
-- subscribed; otherwise applyVolume only runs on song/option changes
|
||||||
|
if Runtime.wantsHook("music.volume") and not state.fade then
|
||||||
|
applyVolume(state.source)
|
||||||
|
applyVolume(state.loopSource)
|
||||||
|
end
|
||||||
-- volume ramp (Music.fadeOut): hold the current level for `control`
|
-- volume ramp (Music.fadeOut): hold the current level for `control`
|
||||||
-- frames, then drop one level (FadeOutAudio decrements both rAUDVOL
|
-- frames, then drop one level (FadeOutAudio decrements both rAUDVOL
|
||||||
-- nibbles when its counter reaches 0); at level 0 the music stops.
|
-- nibbles when its counter reaches 0); at level 0 the music stops.
|
||||||
|
|||||||
@@ -68,6 +68,7 @@ local devShim = { installed = false, permissions = {}, warned = {}, depth = 0 }
|
|||||||
local SUPPORTED_REQUIRES = {
|
local SUPPORTED_REQUIRES = {
|
||||||
["src.mods.Semver"] = true,
|
["src.mods.Semver"] = true,
|
||||||
["src.audio.ChipAsm"] = true,
|
["src.audio.ChipAsm"] = true,
|
||||||
|
["src.pokemon.Stats"] = true, -- Stats.isShiny / calc for indicator mods
|
||||||
}
|
}
|
||||||
|
|
||||||
local function scanRequire(name)
|
local function scanRequire(name)
|
||||||
|
|||||||
@@ -0,0 +1,63 @@
|
|||||||
|
-- Runtime Pokémon art resolution. Content registries freeze after load,
|
||||||
|
-- so a mod that lets the player pick an alternate skin mid-session cannot
|
||||||
|
-- patch pokemon.spriteFront / icons.bySpecies. These helpers are the
|
||||||
|
-- sanctioned seam: every battle pic and party icon load goes through
|
||||||
|
-- pokemon.sprite / pokemon.icon, which stay live for the whole process.
|
||||||
|
|
||||||
|
local Runtime = require("src.mods.Runtime")
|
||||||
|
|
||||||
|
local Sprites = {}
|
||||||
|
|
||||||
|
local function samePath(path) return path end
|
||||||
|
|
||||||
|
-- Resolve a battle / menu front or back pic path for `species`.
|
||||||
|
-- side: "front" | "back"
|
||||||
|
-- opts.mon: the live mon when available (per-instance skins)
|
||||||
|
-- opts.kind: "battle" | "summary" | "dex" | "evolution" | "hof" | "trade"
|
||||||
|
-- | "title" | "oak" | "credits" (informational for wrappers)
|
||||||
|
-- Returns path, trueColor.
|
||||||
|
function Sprites.path(data, species, side, opts)
|
||||||
|
opts = opts or {}
|
||||||
|
local def = data and data.pokemon and data.pokemon[species]
|
||||||
|
if not def then return nil, false end
|
||||||
|
local path = side == "back" and def.spriteBack or def.spriteFront
|
||||||
|
local ctx = {
|
||||||
|
species = species,
|
||||||
|
side = side == "back" and "back" or "front",
|
||||||
|
kind = opts.kind or "battle",
|
||||||
|
mon = opts.mon,
|
||||||
|
trueColor = def.trueColor and true or false,
|
||||||
|
data = data,
|
||||||
|
}
|
||||||
|
if path and Runtime.wantsHook("pokemon.sprite") then
|
||||||
|
local hooked = Runtime.call("pokemon.sprite", samePath, path, ctx)
|
||||||
|
if type(hooked) == "string" and hooked ~= "" then path = hooked end
|
||||||
|
end
|
||||||
|
return path, ctx.trueColor and true or false
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Resolve a party-menu icon image path for `mon`.
|
||||||
|
-- vanillaPath is the path PartyMenu already picked from icons.bySpecies /
|
||||||
|
-- def.icon / icons.byDex; the hook may replace it.
|
||||||
|
-- Returns path (possibly nil).
|
||||||
|
function Sprites.iconPath(data, mon, vanillaPath, opts)
|
||||||
|
opts = opts or {}
|
||||||
|
if not vanillaPath and not Runtime.wantsHook("pokemon.icon") then
|
||||||
|
return vanillaPath
|
||||||
|
end
|
||||||
|
local species = mon and mon.species
|
||||||
|
local ctx = {
|
||||||
|
species = species,
|
||||||
|
mon = mon,
|
||||||
|
name = opts.name,
|
||||||
|
data = data,
|
||||||
|
kind = "icon",
|
||||||
|
}
|
||||||
|
if not Runtime.wantsHook("pokemon.icon") then return vanillaPath end
|
||||||
|
local hooked = Runtime.call("pokemon.icon", samePath, vanillaPath, ctx)
|
||||||
|
if type(hooked) == "string" and hooked ~= "" then return hooked end
|
||||||
|
if hooked == nil or hooked == false then return nil end
|
||||||
|
return vanillaPath
|
||||||
|
end
|
||||||
|
|
||||||
|
return Sprites
|
||||||
@@ -57,4 +57,20 @@ function Stats.applyStage(value, stage)
|
|||||||
return math.max(1, math.min(999, v))
|
return math.max(1, math.min(999, v))
|
||||||
end
|
end
|
||||||
|
|
||||||
|
-- Gen 2 shiny formula applied to Gen 1 DVs (the RBY "virtual shiny"):
|
||||||
|
-- Defense/Speed/Special DV == 10 and Attack DV is even-high
|
||||||
|
-- (2, 3, 6, 7, 10, 11, 14, or 15). Used by shiny-indicator mods.
|
||||||
|
local SHINY_ATK = {
|
||||||
|
[2] = true, [3] = true, [6] = true, [7] = true,
|
||||||
|
[10] = true, [11] = true, [14] = true, [15] = true,
|
||||||
|
}
|
||||||
|
|
||||||
|
function Stats.isShiny(dvs)
|
||||||
|
if type(dvs) ~= "table" then return false end
|
||||||
|
return (dvs.defense or 0) == 10
|
||||||
|
and (dvs.speed or 0) == 10
|
||||||
|
and (dvs.special or 0) == 10
|
||||||
|
and SHINY_ATK[dvs.attack or 0] == true
|
||||||
|
end
|
||||||
|
|
||||||
return Stats
|
return Stats
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ local Zoom = require("src.render.Zoom")
|
|||||||
local Tilt = require("src.render.Tilt")
|
local Tilt = require("src.render.Tilt")
|
||||||
local PaletteFX = require("src.render.PaletteFX")
|
local PaletteFX = require("src.render.PaletteFX")
|
||||||
local Pipelines = require("src.render.Pipelines")
|
local Pipelines = require("src.render.Pipelines")
|
||||||
|
local Runtime = require("src.mods.Runtime")
|
||||||
|
|
||||||
local Renderer = {}
|
local Renderer = {}
|
||||||
|
|
||||||
@@ -486,6 +487,17 @@ function Renderer:endFrame(zones, worldZones)
|
|||||||
love.graphics.setColor(clearR, clearG, clearB, 1)
|
love.graphics.setColor(clearR, clearG, clearB, 1)
|
||||||
love.graphics.rectangle("fill", 0, 0, ww, wh)
|
love.graphics.rectangle("fill", 0, 0, ww, wh)
|
||||||
love.graphics.setColor(1, 1, 1, 1)
|
love.graphics.setColor(1, 1, 1, 1)
|
||||||
|
-- render.letterbox: SGB borders / custom void art in the bars around the
|
||||||
|
-- 160x144 (or world) blit. Drawn after the clear and before the game
|
||||||
|
-- canvas so the playfield sits on top of the border.
|
||||||
|
if Runtime.wantsHook("render.letterbox") then
|
||||||
|
Runtime.call("render.letterbox", function() end, {
|
||||||
|
ww = ww, wh = wh, pw = pw, ph = ph,
|
||||||
|
ox = ox, oy = oy, vpw = vpw, vph = vph,
|
||||||
|
scale = Sp, dpiX = dpiX, dpiY = dpiY,
|
||||||
|
worldActive = self.worldActive and true or false,
|
||||||
|
})
|
||||||
|
end
|
||||||
|
|
||||||
-- blit `canvas` at (sx, sy) LOVE-unit scales into origin (bx, by),
|
-- blit `canvas` at (sx, sy) LOVE-unit scales into origin (bx, by),
|
||||||
-- scissored to the (boxX, boxY, boxW, boxH) screen rect. zoneSx/zoneSy
|
-- scissored to the (boxX, boxY, boxW, boxH) screen rect. zoneSx/zoneSy
|
||||||
|
|||||||
+26
-7
@@ -4,19 +4,38 @@
|
|||||||
-- zoom. Persisted as save.options.zoom (default 0 = FIT).
|
-- zoom. Persisted as save.options.zoom (default 0 = FIT).
|
||||||
-- Spec: docs/new-features.md (survey zoom)
|
-- Spec: docs/new-features.md (survey zoom)
|
||||||
|
|
||||||
|
local Runtime = require("src.mods.Runtime")
|
||||||
|
|
||||||
local Zoom = {}
|
local Zoom = {}
|
||||||
|
|
||||||
Zoom.offset = 0
|
Zoom.offset = 0
|
||||||
|
|
||||||
-- effective integer scale s' in [1, 2*S]
|
-- legal offset range for a given fit scale (vanilla: survey at 1 px/world
|
||||||
function Zoom.scale(S)
|
-- through 2× fit). zoom.range may widen or shrink the window.
|
||||||
return math.max(1, math.min(2 * S, S + Zoom.offset))
|
|
||||||
end
|
|
||||||
|
|
||||||
-- legal offset range for a given fit scale
|
|
||||||
function Zoom.offsetRange(S)
|
function Zoom.offsetRange(S)
|
||||||
S = math.max(1, math.floor(tonumber(S) or 1))
|
S = math.max(1, math.floor(tonumber(S) or 1))
|
||||||
return 1 - S, S
|
local lo, hi = 1 - S, S
|
||||||
|
if Runtime.wantsHook("zoom.range") then
|
||||||
|
lo, hi = Runtime.call("zoom.range", function(a, b) return a, b end, lo, hi, S)
|
||||||
|
lo = math.floor(tonumber(lo) or (1 - S))
|
||||||
|
hi = math.floor(tonumber(hi) or S)
|
||||||
|
if lo > hi then lo, hi = hi, lo end
|
||||||
|
end
|
||||||
|
return lo, hi
|
||||||
|
end
|
||||||
|
|
||||||
|
-- effective scale s' = S + offset, clamped to the (possibly modded) range.
|
||||||
|
-- Vanilla stays in [1, 2*S]. A zoom.range wrapper that lowers `lo` below
|
||||||
|
-- 1-S permits sub-1 survey scales so the whole region can fit on screen.
|
||||||
|
function Zoom.scale(S)
|
||||||
|
local lo, hi = Zoom.offsetRange(S)
|
||||||
|
local s = S + Zoom.offset
|
||||||
|
local minScale = S + lo
|
||||||
|
local maxScale = math.max(minScale, S + hi)
|
||||||
|
if s < minScale then s = minScale end
|
||||||
|
if s > maxScale then s = maxScale end
|
||||||
|
if s < 0.25 then s = 0.25 end
|
||||||
|
return s
|
||||||
end
|
end
|
||||||
|
|
||||||
function Zoom.clampOffset(offset, S)
|
function Zoom.clampOffset(offset, S)
|
||||||
|
|||||||
@@ -370,6 +370,7 @@ function BagMenu.new(game, opts)
|
|||||||
local battle = opts.battle
|
local battle = opts.battle
|
||||||
local list
|
local list
|
||||||
list = ListMenu.new(game, "ITEMS", buildItems(game), {
|
list = ListMenu.new(game, "ITEMS", buildItems(game), {
|
||||||
|
kind = "bag",
|
||||||
footer = ("¥%d"):format(game.save.money),
|
footer = ("¥%d"):format(game.save.money),
|
||||||
-- SELECT reorders items like the original bag (swap_items.asm)
|
-- SELECT reorders items like the original bag (swap_items.asm)
|
||||||
onSelectKey = function(item, l)
|
onSelectKey = function(item, l)
|
||||||
|
|||||||
+3
-2
@@ -140,8 +140,9 @@ function Credits:enter()
|
|||||||
end
|
end
|
||||||
|
|
||||||
function Credits:monSprite(species)
|
function Credits:monSprite(species)
|
||||||
local def = self.game.data.pokemon and self.game.data.pokemon[species]
|
local path = require("src.pokemon.Sprites").path(
|
||||||
return silhouette(def and def.spriteFront)
|
self.game.data, species, "front", { kind = "credits" })
|
||||||
|
return silhouette(path)
|
||||||
end
|
end
|
||||||
|
|
||||||
-- advance to the next CreditsOrder screen (Credits .nextCreditsScreen);
|
-- advance to the next CreditsOrder screen (Credits .nextCreditsScreen);
|
||||||
|
|||||||
@@ -34,7 +34,9 @@ function DexEntryMenu.new(game, speciesOrOpts)
|
|||||||
local species, forceOwned = resolveArgs(speciesOrOpts)
|
local species, forceOwned = resolveArgs(speciesOrOpts)
|
||||||
local self = setmetatable({ game = game, forceOwned = forceOwned }, DexEntryMenu)
|
local self = setmetatable({ game = game, forceOwned = forceOwned }, DexEntryMenu)
|
||||||
self.def = game.data.pokemon[species]
|
self.def = game.data.pokemon[species]
|
||||||
local ok, img = pcall(love.graphics.newImage, self.def.spriteFront)
|
local path = require("src.pokemon.Sprites").path(game.data, species, "front",
|
||||||
|
{ kind = "dex" })
|
||||||
|
local ok, img = path and pcall(love.graphics.newImage, path)
|
||||||
self.sprite = ok and img or nil
|
self.sprite = ok and img or nil
|
||||||
require("src.core.Sound").playCry(game.data, species)
|
require("src.core.Sound").playCry(game.data, species)
|
||||||
return self
|
return self
|
||||||
|
|||||||
@@ -28,10 +28,11 @@ end
|
|||||||
|
|
||||||
local FLASH_FRAMES = 220
|
local FLASH_FRAMES = 220
|
||||||
|
|
||||||
local function frontSprite(game, species)
|
local function frontSprite(game, species, mon)
|
||||||
local def = game.data.pokemon[species]
|
local path = require("src.pokemon.Sprites").path(game.data, species, "front",
|
||||||
if not (def and def.spriteFront) then return nil end
|
{ mon = mon, kind = "evolution" })
|
||||||
local ok, img = pcall(love.graphics.newImage, def.spriteFront)
|
if not path then return nil end
|
||||||
|
local ok, img = pcall(love.graphics.newImage, path)
|
||||||
return ok and img or nil
|
return ok and img or nil
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -46,8 +47,8 @@ function EvolutionState.new(game, mon, newSpecies, onDone, via)
|
|||||||
-- B-cancel poll; level-up, stone and rare-candy evos are all cancelable.
|
-- B-cancel poll; level-up, stone and rare-candy evos are all cancelable.
|
||||||
self.cancelable = (via ~= "TRADE")
|
self.cancelable = (via ~= "TRADE")
|
||||||
self.oldName = mon.nickname or game.data.pokemon[mon.species].name
|
self.oldName = mon.nickname or game.data.pokemon[mon.species].name
|
||||||
self.oldSprite = frontSprite(game, mon.species)
|
self.oldSprite = frontSprite(game, mon.species, mon)
|
||||||
self.newSprite = frontSprite(game, newSpecies)
|
self.newSprite = frontSprite(game, newSpecies, mon)
|
||||||
self.t = 0
|
self.t = 0
|
||||||
self.done = false
|
self.done = false
|
||||||
self.canceled = false
|
self.canceled = false
|
||||||
|
|||||||
@@ -118,8 +118,9 @@ end
|
|||||||
function HallOfFame:spriteFor(species)
|
function HallOfFame:spriteFor(species)
|
||||||
local cached = self.sprites[species]
|
local cached = self.sprites[species]
|
||||||
if cached == nil then
|
if cached == nil then
|
||||||
local def = self.game.data.pokemon[species]
|
local path = require("src.pokemon.Sprites").path(
|
||||||
cached = tryImage(def and def.spriteFront) or false
|
self.game.data, species, "front", { kind = "hof" })
|
||||||
|
cached = tryImage(path) or false
|
||||||
self.sprites[species] = cached
|
self.sprites[species] = cached
|
||||||
end
|
end
|
||||||
return cached or nil
|
return cached or nil
|
||||||
|
|||||||
+96
-7
@@ -3,6 +3,7 @@
|
|||||||
-- box and the Pokédex.
|
-- box and the Pokédex.
|
||||||
|
|
||||||
local Font = require("src.render.Font")
|
local Font = require("src.render.Font")
|
||||||
|
local Runtime = require("src.mods.Runtime")
|
||||||
local Theme = require("src.ui.Theme")
|
local Theme = require("src.ui.Theme")
|
||||||
|
|
||||||
local ListMenu = {}
|
local ListMenu = {}
|
||||||
@@ -15,9 +16,37 @@ function ListMenu:sgbPalettes(game)
|
|||||||
end
|
end
|
||||||
|
|
||||||
local ROWS = 7
|
local ROWS = 7
|
||||||
|
-- frames to wait before key-repeat kicks in, then between repeats
|
||||||
|
local REPEAT_DELAY = 16
|
||||||
|
local REPEAT_RATE = 4
|
||||||
|
|
||||||
|
-- ui.list_menu identity: unhooked opts pass through unchanged
|
||||||
|
local function sameOpts(opts) return opts end
|
||||||
|
|
||||||
function ListMenu.new(game, title, items, opts)
|
function ListMenu.new(game, title, items, opts)
|
||||||
opts = opts or {}
|
opts = opts or {}
|
||||||
|
-- bag / shop / dex / generic: mods may enable wrap, pageJump, keyRepeat
|
||||||
|
if Runtime.wantsHook("ui.list_menu") then
|
||||||
|
local hooked = Runtime.call("ui.list_menu", sameOpts, {
|
||||||
|
wrap = opts.wrap,
|
||||||
|
pageJump = opts.pageJump,
|
||||||
|
keyRepeat = opts.keyRepeat,
|
||||||
|
repeatDelay = opts.repeatDelay,
|
||||||
|
repeatRate = opts.repeatRate,
|
||||||
|
}, {
|
||||||
|
game = game,
|
||||||
|
title = title,
|
||||||
|
kind = opts.kind or title,
|
||||||
|
itemCount = items and #items or 0,
|
||||||
|
})
|
||||||
|
if type(hooked) == "table" then
|
||||||
|
if hooked.wrap ~= nil then opts.wrap = hooked.wrap end
|
||||||
|
if hooked.pageJump ~= nil then opts.pageJump = hooked.pageJump end
|
||||||
|
if hooked.keyRepeat ~= nil then opts.keyRepeat = hooked.keyRepeat end
|
||||||
|
if hooked.repeatDelay ~= nil then opts.repeatDelay = hooked.repeatDelay end
|
||||||
|
if hooked.repeatRate ~= nil then opts.repeatRate = hooked.repeatRate end
|
||||||
|
end
|
||||||
|
end
|
||||||
local self = setmetatable({}, ListMenu)
|
local self = setmetatable({}, ListMenu)
|
||||||
self.game = game
|
self.game = game
|
||||||
self.title = title
|
self.title = title
|
||||||
@@ -28,6 +57,12 @@ function ListMenu.new(game, title, items, opts)
|
|||||||
self.onCancel = opts.onCancel
|
self.onCancel = opts.onCancel
|
||||||
self.footer = opts.footer
|
self.footer = opts.footer
|
||||||
self.pageJump = opts.pageJump -- Left/Right move a page at a time
|
self.pageJump = opts.pageJump -- Left/Right move a page at a time
|
||||||
|
self.wrap = opts.wrap -- Up on first / Down on last wraps
|
||||||
|
self.keyRepeat = opts.keyRepeat -- hold Up/Down (and pageJump L/R) to scroll
|
||||||
|
self.repeatDelay = opts.repeatDelay or REPEAT_DELAY
|
||||||
|
self.repeatRate = opts.repeatRate or REPEAT_RATE
|
||||||
|
self.holdDir = nil
|
||||||
|
self.holdFrames = 0
|
||||||
self.onSelectKey = opts.onSelectKey -- SELECT pressed on an item
|
self.onSelectKey = opts.onSelectKey -- SELECT pressed on an item
|
||||||
-- scripted mode (the old man tutorial): update() runs the script
|
-- scripted mode (the old man tutorial): update() runs the script
|
||||||
-- every frame INSTEAD of reading input -- DisplayListMenuID's old-man
|
-- every frame INSTEAD of reading input -- DisplayListMenuID's old-man
|
||||||
@@ -46,6 +81,42 @@ function ListMenu.new(game, title, items, opts)
|
|||||||
return self
|
return self
|
||||||
end
|
end
|
||||||
|
|
||||||
|
local function moveIndex(self, delta)
|
||||||
|
local n = #self.items
|
||||||
|
if n == 0 then return end
|
||||||
|
local next = self.index + delta
|
||||||
|
if self.wrap then
|
||||||
|
next = ((next - 1) % n) + 1
|
||||||
|
else
|
||||||
|
next = math.max(1, math.min(n, next))
|
||||||
|
end
|
||||||
|
self.index = next
|
||||||
|
end
|
||||||
|
|
||||||
|
local function syncScroll(self)
|
||||||
|
if self.index - self.scroll > self.rows then
|
||||||
|
self.scroll = self.index - self.rows
|
||||||
|
end
|
||||||
|
if self.index - self.scroll < 1 then self.scroll = self.index - 1 end
|
||||||
|
end
|
||||||
|
|
||||||
|
-- edge press or key-repeat tick for a held direction
|
||||||
|
local function navPressed(self, dir)
|
||||||
|
if dir == "up" then
|
||||||
|
moveIndex(self, -1)
|
||||||
|
elseif dir == "down" then
|
||||||
|
moveIndex(self, 1)
|
||||||
|
elseif dir == "left" and self.pageJump then
|
||||||
|
moveIndex(self, -self.rows)
|
||||||
|
elseif dir == "right" and self.pageJump then
|
||||||
|
moveIndex(self, self.rows)
|
||||||
|
else
|
||||||
|
return false
|
||||||
|
end
|
||||||
|
syncScroll(self)
|
||||||
|
return true
|
||||||
|
end
|
||||||
|
|
||||||
function ListMenu:update(dt)
|
function ListMenu:update(dt)
|
||||||
if self.script then
|
if self.script then
|
||||||
self.script(self)
|
self.script(self)
|
||||||
@@ -59,14 +130,20 @@ function ListMenu:update(dt)
|
|||||||
end
|
end
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
|
|
||||||
|
local moved = false
|
||||||
if input:wasPressed("up") then
|
if input:wasPressed("up") then
|
||||||
self.index = math.max(1, self.index - 1)
|
moved = navPressed(self, "up")
|
||||||
|
self.holdDir, self.holdFrames = "up", 0
|
||||||
elseif input:wasPressed("down") then
|
elseif input:wasPressed("down") then
|
||||||
self.index = math.min(#self.items, self.index + 1)
|
moved = navPressed(self, "down")
|
||||||
|
self.holdDir, self.holdFrames = "down", 0
|
||||||
elseif self.pageJump and input:wasPressed("left") then
|
elseif self.pageJump and input:wasPressed("left") then
|
||||||
self.index = math.max(1, self.index - self.rows)
|
moved = navPressed(self, "left")
|
||||||
|
self.holdDir, self.holdFrames = "left", 0
|
||||||
elseif self.pageJump and input:wasPressed("right") then
|
elseif self.pageJump and input:wasPressed("right") then
|
||||||
self.index = math.min(#self.items, self.index + self.rows)
|
moved = navPressed(self, "right")
|
||||||
|
self.holdDir, self.holdFrames = "right", 0
|
||||||
elseif self.onSelectKey and input:wasPressed("select") then
|
elseif self.onSelectKey and input:wasPressed("select") then
|
||||||
self.onSelectKey(self.items[self.index], self)
|
self.onSelectKey(self.items[self.index], self)
|
||||||
elseif input:wasPressed("b") then
|
elseif input:wasPressed("b") then
|
||||||
@@ -80,10 +157,22 @@ function ListMenu:update(dt)
|
|||||||
end
|
end
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
if self.index - self.scroll > self.rows then
|
|
||||||
self.scroll = self.index - self.rows
|
-- hold-to-scroll (opt-in via ui.list_menu keyRepeat)
|
||||||
|
if self.keyRepeat then
|
||||||
|
local dir = self.holdDir
|
||||||
|
if dir and input:isDown(dir) then
|
||||||
|
self.holdFrames = self.holdFrames + 1
|
||||||
|
local afterDelay = self.holdFrames - self.repeatDelay
|
||||||
|
if afterDelay >= 0 and afterDelay % self.repeatRate == 0 then
|
||||||
|
navPressed(self, dir)
|
||||||
|
end
|
||||||
|
else
|
||||||
|
self.holdDir, self.holdFrames = nil, 0
|
||||||
|
end
|
||||||
end
|
end
|
||||||
if self.index - self.scroll < 1 then self.scroll = self.index - 1 end
|
|
||||||
|
if not moved then syncScroll(self) end
|
||||||
end
|
end
|
||||||
|
|
||||||
-- remove current item (e.g. consumed); keeps cursor valid
|
-- remove current item (e.g. consumed); keeps cursor valid
|
||||||
|
|||||||
@@ -57,4 +57,32 @@ function ModUI.removeLabel(items, label)
|
|||||||
return items
|
return items
|
||||||
end
|
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
|
return ModUI
|
||||||
|
|||||||
+40
-9
@@ -5,8 +5,12 @@
|
|||||||
-- given, a "NEW NAME" + presets menu is shown first
|
-- given, a "NEW NAME" + presets menu is shown first
|
||||||
-- (engine/menus/main_menu.asm name lists).
|
-- (engine/menus/main_menu.asm name lists).
|
||||||
-- Pops itself from the stack, then calls opts.onDone(name).
|
-- 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 Font = require("src.render.Font")
|
||||||
|
local Runtime = require("src.mods.Runtime")
|
||||||
local Sound = require("src.core.Sound")
|
local Sound = require("src.core.Sound")
|
||||||
local Theme = require("src.ui.Theme")
|
local Theme = require("src.ui.Theme")
|
||||||
|
|
||||||
@@ -37,8 +41,24 @@ local GRID_LOWER = {
|
|||||||
{ "-", "?", "!", "♂", "♀", "/", ".", ",", "ED" },
|
{ "-", "?", "!", "♂", "♀", "/", ".", ",", "ED" },
|
||||||
{ "UPPER CASE" },
|
{ "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)
|
function NamingScreen.new(game, opts)
|
||||||
opts = opts or {}
|
opts = opts or {}
|
||||||
@@ -86,16 +106,27 @@ function NamingScreen:confirm()
|
|||||||
end
|
end
|
||||||
|
|
||||||
function NamingScreen:grid()
|
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
|
end
|
||||||
|
|
||||||
-- Gen 1 jumps the cursor to ED once the name is full.
|
-- Gen 1 jumps the cursor to ED once the name is full.
|
||||||
function NamingScreen:jumpToEnd()
|
function NamingScreen:jumpToEnd()
|
||||||
self.row, self.col = ED_ROW, ED_COL
|
local _, edRow, edCol = findMeta(self:grid())
|
||||||
|
self.row, self.col = edRow, edCol
|
||||||
end
|
end
|
||||||
|
|
||||||
function NamingScreen:update(dt)
|
function NamingScreen:update(dt)
|
||||||
local GRID = self:grid()
|
local GRID = self:grid()
|
||||||
|
local caseRow, edRow, edCol = findMeta(GRID)
|
||||||
local input = self.game.input
|
local input = self.game.input
|
||||||
if input:wasPressed("start") then
|
if input:wasPressed("start") then
|
||||||
self:confirm()
|
self:confirm()
|
||||||
@@ -107,28 +138,28 @@ function NamingScreen:update(dt)
|
|||||||
end
|
end
|
||||||
if input:wasPressed("up") then
|
if input:wasPressed("up") then
|
||||||
-- wrapping up from the top row lands on the case-switch cell
|
-- 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])
|
self.col = math.min(self.col, #GRID[self.row])
|
||||||
elseif input:wasPressed("down") then
|
elseif input:wasPressed("down") then
|
||||||
self.row = self.row < #GRID and self.row + 1 or 1
|
self.row = self.row < #GRID and self.row + 1 or 1
|
||||||
self.col = math.min(self.col, #GRID[self.row])
|
self.col = math.min(self.col, #GRID[self.row])
|
||||||
elseif input:wasPressed("left") then
|
elseif input:wasPressed("left") then
|
||||||
-- no horizontal movement on the case-switch row
|
-- 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]
|
self.col = self.col > 1 and self.col - 1 or #GRID[self.row]
|
||||||
end
|
end
|
||||||
elseif input:wasPressed("right") then
|
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
|
self.col = self.col < #GRID[self.row] and self.col + 1 or 1
|
||||||
end
|
end
|
||||||
elseif input:wasPressed("b") then
|
elseif input:wasPressed("b") then
|
||||||
table.remove(self.glyphs)
|
table.remove(self.glyphs)
|
||||||
elseif input:wasPressed("a") then
|
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()
|
self:confirm()
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
if self.row == CASE_ROW then
|
if self.row == caseRow then
|
||||||
self.lower = not self.lower
|
self.lower = not self.lower
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
|
|||||||
+357
-72
@@ -1,14 +1,16 @@
|
|||||||
-- The intro sequence (engine/movie/oak_speech/oak_speech.asm): Oak's
|
-- The intro sequence (engine/movie/oak_speech/oak_speech.asm): Oak's
|
||||||
-- welcome, the NIDORINO show-off, player and rival naming, and the
|
-- welcome, the NIDORINO show-off, player and rival naming, and the
|
||||||
-- closing "legend is about to unfold" text followed by the shrink-away:
|
-- 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
|
-- Steps are a data table so mods can reshape the whole speech through
|
||||||
-- extracted texts (_OakSpeechText1/2A/2B/3, _IntroducePlayerText,
|
-- hooks:wrap("intro.oak_speech.build"). Vanilla ids stay stable so a
|
||||||
-- _IntroduceRivalText) with literal fallbacks.
|
-- mod can insertBefore("name_player", ...) without counting indices.
|
||||||
-- Calls onDone() after popping itself.
|
-- Calls onDone() after popping itself.
|
||||||
|
|
||||||
local Sound = require("src.core.Sound")
|
local Sound = require("src.core.Sound")
|
||||||
local Music = require("src.core.Music")
|
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 TextBox = require("src.render.TextBox")
|
||||||
local Font = require("src.render.Font")
|
local Font = require("src.render.Font")
|
||||||
|
|
||||||
@@ -55,12 +57,154 @@ local function tryImage(path)
|
|||||||
return ok and img or nil
|
return ok and img or nil
|
||||||
end
|
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
|
||||||
|
return tryImage(desc.path or "assets/generated/trainer_card/red.png"), 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 = "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 = "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)
|
function OakSpeech.new(game, onDone)
|
||||||
local self = setmetatable({}, OakSpeech)
|
local self = setmetatable({}, OakSpeech)
|
||||||
self.game = game
|
self.game = game
|
||||||
self.onDone = onDone
|
self.onDone = onDone
|
||||||
self.step = 0
|
self.step = 0
|
||||||
self.pic = nil
|
self.pic = nil
|
||||||
|
self.answers = {}
|
||||||
local trainers = game.data.trainers or {}
|
local trainers = game.data.trainers or {}
|
||||||
self.oakPic = tryImage(trainers.OPP_PROF_OAK and trainers.OPP_PROF_OAK.pic)
|
self.oakPic = tryImage(trainers.OPP_PROF_OAK and trainers.OPP_PROF_OAK.pic)
|
||||||
self.rivalPic = tryImage(trainers.OPP_RIVAL1 and trainers.OPP_RIVAL1.pic)
|
self.rivalPic = tryImage(trainers.OPP_RIVAL1 and trainers.OPP_RIVAL1.pic)
|
||||||
@@ -69,8 +213,9 @@ function OakSpeech.new(game, onDone)
|
|||||||
-- the show-off mon and the name length cap come from data; the vanilla
|
-- the show-off mon and the name length cap come from data; the vanilla
|
||||||
-- literals stay as the fallbacks
|
-- literals stay as the fallbacks
|
||||||
self.demoSpecies = oakGfx.demoSpecies or "NIDORINO"
|
self.demoSpecies = oakGfx.demoSpecies or "NIDORINO"
|
||||||
local demo = game.data.pokemon and game.data.pokemon[self.demoSpecies]
|
local demoPath = require("src.pokemon.Sprites").path(
|
||||||
self.demoPic = tryImage(demo and demo.spriteFront)
|
game.data, self.demoSpecies, "front", { kind = "oak" })
|
||||||
|
self.demoPic = tryImage(demoPath)
|
||||||
local constants = game.data.constants or {}
|
local constants = game.data.constants or {}
|
||||||
self.nameLen = constants.playerNameLength or 7
|
self.nameLen = constants.playerNameLength or 7
|
||||||
-- RedPicFront (gfx/player/red.png, shared with the trainer card) and
|
-- RedPicFront (gfx/player/red.png, shared with the trainer card) and
|
||||||
@@ -87,9 +232,26 @@ function OakSpeech.new(game, onDone)
|
|||||||
return self
|
return self
|
||||||
end
|
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()
|
function OakSpeech:enter()
|
||||||
-- MUSIC_ROUTES2 plays under the whole speech (oak_speech.asm:43-48)
|
-- MUSIC_ROUTES2 plays under the whole speech (oak_speech.asm:43-48)
|
||||||
Music.play(self.game.data, self.cfg.music or "Music_Routes2")
|
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()
|
self:advance()
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -97,80 +259,187 @@ function OakSpeech:say(key, next)
|
|||||||
self.game.stack:push(TextBox.new(self.game, textOr(self.game, key), next))
|
self.game.stack:push(TextBox.new(self.game, textOr(self.game, key), next))
|
||||||
end
|
end
|
||||||
|
|
||||||
local STEPS = {
|
function OakSpeech:sayText(text, next, opts)
|
||||||
-- 1. Oak's welcome (Oak's pic fades in first, FadeInIntroPic)
|
self.game.stack:push(TextBox.new(self.game, text, next, opts))
|
||||||
function(self)
|
end
|
||||||
self.pic = self.oakPic
|
|
||||||
self:revealPic("fade", function()
|
function OakSpeech:stepText(step)
|
||||||
self:say("_OakSpeechText1", function() self:advance() end)
|
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)
|
||||||
end,
|
elseif kind == "demo" then
|
||||||
-- 2. NIDORINO show-off: the front sprite is mirrored horizontally
|
-- NIDORINO show-off: mirrored front sprite + wipe + cry + text 2A
|
||||||
-- (LoadFlippedFrontSpriteByMonIndex) and wipes in from the right
|
|
||||||
-- (MovePicLeft), then its cry sounds and the text prints
|
|
||||||
function(self)
|
|
||||||
self.pic = self.demoPic
|
self.pic = self.demoPic
|
||||||
self.picFlip = true
|
self.picFlip = true
|
||||||
self:revealPic("wipe", function()
|
self:revealPic("wipe", function()
|
||||||
Sound.playCry(self.game.data, self.demoSpecies)
|
Sound.playCry(self.game.data, self.demoSpecies)
|
||||||
self:say("_OakSpeechText2A", function() self:advance() end)
|
self:say("_OakSpeechText2A", function() self:advance() end)
|
||||||
end)
|
end)
|
||||||
end,
|
elseif kind == "name" then
|
||||||
-- 3. the rest of the world-of-POKéMON spiel
|
local who = step.who or "player"
|
||||||
function(self)
|
local presets = step.presets
|
||||||
self:say("_OakSpeechText2B", function() self:advance() end)
|
or namePresets(self.game, step.presetsWho or who,
|
||||||
end,
|
step.presetsFallback or { "RED" })
|
||||||
-- 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)
|
|
||||||
require("src.ui.Screens").push(self.game, "NamingScreen", {
|
require("src.ui.Screens").push(self.game, "NamingScreen", {
|
||||||
title = "YOUR NAME?",
|
title = step.title or (who == "rival" and "HIS NAME?" or "YOUR NAME?"),
|
||||||
presets = namePresets(self.game, "player", { "RED", "ASH", "JACK" }),
|
presets = presets,
|
||||||
maxLen = self.nameLen,
|
maxLen = step.maxLen or self.nameLen,
|
||||||
onDone = function(name)
|
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()
|
self:advance()
|
||||||
end,
|
end,
|
||||||
})
|
})
|
||||||
end,
|
elseif kind == "choice" then
|
||||||
-- 6. the rival introduction and naming
|
self:applyPic(step)
|
||||||
function(self)
|
self:afterReveal(step, function()
|
||||||
self.pic = self.rivalPic
|
self:runCry(step)
|
||||||
self:say("_IntroduceRivalText", function() self:advance() end)
|
local function openMenu()
|
||||||
end,
|
local Menu = require("src.ui.Menu")
|
||||||
function(self)
|
local items = {}
|
||||||
require("src.ui.Screens").push(self.game, "NamingScreen", {
|
for i, label in ipairs(step.choices or {}) do
|
||||||
title = "HIS NAME?",
|
items[i] = {
|
||||||
presets = namePresets(self.game, "rival", { "BLUE", "GARY", "JOHN" }),
|
label = label,
|
||||||
maxLen = self.nameLen,
|
onSelect = function()
|
||||||
onDone = function(name)
|
local value = label
|
||||||
self.game.save.player.rival = name
|
if step.values and step.values[i] ~= nil then
|
||||||
self:advance()
|
value = step.values[i]
|
||||||
end,
|
end
|
||||||
})
|
self:recordAnswer(step, i, label, value)
|
||||||
end,
|
self:advance()
|
||||||
-- 8. "your very own POKéMON legend is about to unfold!" over the
|
end,
|
||||||
-- player pic again (oak_speech.asm:105-113)
|
}
|
||||||
function(self)
|
end
|
||||||
self.pic = self.playerPic or self.oakPic
|
self.game.stack:push(Menu.new(self.game, items, {
|
||||||
self:say("_OakSpeechText3", function() self:advance() end)
|
cancelable = step.cancelable == true,
|
||||||
end,
|
tx = step.tx or 4,
|
||||||
-- 9. SFX_SHRINK: the pic collapses through the two shrink frames
|
ty = step.ty or 0,
|
||||||
-- into the walking sprite, then fades to white (oak_speech.asm
|
tw = step.tw or 12,
|
||||||
-- .next, lines 115-166). Not skippable, like the DelayFrames
|
th = step.th,
|
||||||
-- chain it ports.
|
}))
|
||||||
function(self)
|
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")
|
Sound.play(self.game.data, "Shrink")
|
||||||
-- the OakSpeechText3 box holds its last page on screen through the
|
-- prefer an explicit shrink text key; fall back to the legend beat
|
||||||
-- shrink (pokered text boxes persist until overwritten)
|
local key = step.textKey or "_OakSpeechText3"
|
||||||
self.shrinkText = self:lastPageLines("_OakSpeechText3")
|
self.shrinkText = self:lastPageLines(key)
|
||||||
self.shrink = { frame = 0 }
|
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
|
-- the last two visible lines of a text's final page, pre-encoded
|
||||||
function OakSpeech:lastPageLines(key)
|
function OakSpeech:lastPageLines(key)
|
||||||
@@ -202,15 +471,31 @@ end
|
|||||||
function OakSpeech:advance()
|
function OakSpeech:advance()
|
||||||
self.step = self.step + 1
|
self.step = self.step + 1
|
||||||
self.picFlip = false
|
self.picFlip = false
|
||||||
local fn = STEPS[self.step]
|
local steps = self.steps
|
||||||
if fn then
|
if not steps then
|
||||||
fn(self)
|
-- 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
|
else
|
||||||
self:finish()
|
self:finish()
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
function OakSpeech:finish()
|
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
|
-- the map theme starts with the overworld beneath (the original's
|
||||||
-- special warp into Pallet Town)
|
-- special warp into Pallet Town)
|
||||||
local ow = self.game.overworld
|
local ow = self.game.overworld
|
||||||
@@ -267,8 +552,8 @@ function OakSpeech:draw()
|
|||||||
if self.pic then
|
if self.pic then
|
||||||
-- IntroDisplayPicCenteredOrUpperRight centered: the 7x7-tile pic
|
-- IntroDisplayPicCenteredOrUpperRight centered: the 7x7-tile pic
|
||||||
-- area sits at hlcoord 6,4 = (48,32); smaller mon pics pad inside
|
-- area sits at hlcoord 6,4 = (48,32); smaller mon pics pad inside
|
||||||
-- it like the sprite buffer does ((8 - w) >> 1 tiles across,
|
-- it like the sprite buffer does ((8 - w) >> 1) tiles across,
|
||||||
-- bottom-aligned)
|
-- bottom-aligned
|
||||||
local w, h = self.pic:getDimensions()
|
local w, h = self.pic:getDimensions()
|
||||||
local x = 48 + math.floor((8 - w / 8) / 2) * 8
|
local x = 48 + math.floor((8 - w / 8) / 2) * 8
|
||||||
local y = 32 + (7 - h / 8) * 8
|
local y = 32 + (7 - h / 8) * 8
|
||||||
|
|||||||
@@ -95,6 +95,7 @@ local function drawIcon(game, mon, x, y, selected, counter)
|
|||||||
name = def and def.dex and icons.byDex and icons.byDex[def.dex]
|
name = def and def.dex and icons.byDex and icons.byDex[def.dex]
|
||||||
path = name and icons.icons and icons.icons[name]
|
path = name and icons.icons and icons.icons[name]
|
||||||
end
|
end
|
||||||
|
path = require("src.pokemon.Sprites").iconPath(game.data, mon, path, { name = name })
|
||||||
if not path then return end
|
if not path then return end
|
||||||
if iconImages[path] == nil then
|
if iconImages[path] == nil then
|
||||||
-- resolve through Assets so an overrides/ or transform-derived icon
|
-- resolve through Assets so an overrides/ or transform-derived icon
|
||||||
|
|||||||
@@ -30,9 +30,11 @@ end
|
|||||||
|
|
||||||
function SummaryMenu.new(game, mon)
|
function SummaryMenu.new(game, mon)
|
||||||
local self = setmetatable({ game = game, mon = mon, page = 1 }, SummaryMenu)
|
local self = setmetatable({ game = game, mon = mon, page = 1 }, SummaryMenu)
|
||||||
local def = game.data.pokemon[mon.species]
|
local Sprites = require("src.pokemon.Sprites")
|
||||||
if def and def.spriteFront then
|
local path = Sprites.path(game.data, mon.species, "front",
|
||||||
local ok, img = pcall(love.graphics.newImage, def.spriteFront)
|
{ mon = mon, kind = "summary" })
|
||||||
|
if path then
|
||||||
|
local ok, img = pcall(love.graphics.newImage, path)
|
||||||
self.sprite = ok and img or nil
|
self.sprite = ok and img or nil
|
||||||
end
|
end
|
||||||
require("src.core.Sound").playCry(game.data, mon.species)
|
require("src.core.Sound").playCry(game.data, mon.species)
|
||||||
|
|||||||
@@ -117,8 +117,9 @@ function TitleState:currentSprite()
|
|||||||
local species = self.cycleSpecies[self.cycleIndex]
|
local species = self.cycleSpecies[self.cycleIndex]
|
||||||
local cached = self.sprites[species]
|
local cached = self.sprites[species]
|
||||||
if cached == nil then
|
if cached == nil then
|
||||||
local def = self.game.data.pokemon[species]
|
local path = require("src.pokemon.Sprites").path(
|
||||||
cached = tryImage(def and def.spriteFront) or false
|
self.game.data, species, "front", { kind = "title" })
|
||||||
|
cached = tryImage(path) or false
|
||||||
self.sprites[species] = cached
|
self.sprites[species] = cached
|
||||||
end
|
end
|
||||||
return cached or nil
|
return cached or nil
|
||||||
|
|||||||
@@ -48,8 +48,9 @@ local function dexOf(game, mon)
|
|||||||
end
|
end
|
||||||
|
|
||||||
local function spriteOf(game, mon)
|
local function spriteOf(game, mon)
|
||||||
local def = game.data.pokemon[mon.species]
|
local path = require("src.pokemon.Sprites").path(game.data, mon.species, "front",
|
||||||
return tryImage(def and def.spriteFront)
|
{ mon = mon, kind = "trade" })
|
||||||
|
return tryImage(path)
|
||||||
end
|
end
|
||||||
|
|
||||||
local function expand(game, key, subs)
|
local function expand(game, key, subs)
|
||||||
|
|||||||
@@ -452,6 +452,35 @@ end
|
|||||||
|
|
||||||
-- name -> name so the map.palette chain has a vanilla link to wrap
|
-- name -> name so the map.palette chain has a vanilla link to wrap
|
||||||
local function samePalette(name) return name end
|
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)
|
function OverworldState:paletteNameFor(map)
|
||||||
local palettes = FieldDefaults.field(Game.data, "palettes")
|
local palettes = FieldDefaults.field(Game.data, "palettes")
|
||||||
@@ -470,8 +499,9 @@ function OverworldState:paletteNameFor(map)
|
|||||||
name = (last and paletteLookup(palettes, last, lastDef and lastDef.tileset))
|
name = (last and paletteLookup(palettes, last, lastDef and lastDef.tileset))
|
||||||
or palettes.default
|
or palettes.default
|
||||||
end
|
end
|
||||||
|
local tod = self:timeOfDay()
|
||||||
if not Runtime.wantsHook("map.palette") then return name end
|
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
|
end
|
||||||
|
|
||||||
-- UI-pass palette (text boxes and menus tint with the current map). OG RED
|
-- UI-pass palette (text boxes and menus tint with the current map). OG RED
|
||||||
@@ -2781,11 +2811,18 @@ end
|
|||||||
|
|
||||||
function OverworldState:onStepComplete()
|
function OverworldState:onStepComplete()
|
||||||
local p = self.player
|
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
|
-- hot path: the payload is only built when something is listening
|
||||||
if Runtime.wants("world.stepped") then
|
if Runtime.wants("world.stepped") then
|
||||||
Runtime.emit("world.stepped", { mapId = self.map.id, x = p.cellX, y = p.cellY,
|
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
|
end
|
||||||
|
|
||||||
-- dismounting a surf: landing on a walkable cell ends it
|
-- dismounting a surf: landing on a walkable cell ends it
|
||||||
|
|||||||
+17
-4
@@ -4,6 +4,7 @@
|
|||||||
|
|
||||||
local Collision = require("src.world.Collision")
|
local Collision = require("src.world.Collision")
|
||||||
local FieldDefaults = require("src.world.FieldDefaults")
|
local FieldDefaults = require("src.world.FieldDefaults")
|
||||||
|
local Runtime = require("src.mods.Runtime")
|
||||||
local SpriteRenderer = require("src.render.SpriteRenderer")
|
local SpriteRenderer = require("src.render.SpriteRenderer")
|
||||||
|
|
||||||
local Player = {}
|
local Player = {}
|
||||||
@@ -82,10 +83,22 @@ function Player:tryMove(dir, map, entities)
|
|||||||
self.moving = true
|
self.moving = true
|
||||||
self.bumpFrames = nil -- a real step supersedes any in-place bonk
|
self.bumpFrames = nil -- a real step supersedes any in-place bonk
|
||||||
self.progress = 0
|
self.progress = 0
|
||||||
-- the bicycle doubles walking speed (8 frames per step)
|
-- the bicycle doubles walking speed (8 frames per step); movement.speed
|
||||||
local save = require("src.core.Game").save
|
-- lets a mod multiply or replace that (running shoes, dash, etc.)
|
||||||
self.stepFramesCur = (save and save.onBike) and self.bikeStepFrames
|
local Game = require("src.core.Game")
|
||||||
or self.stepFrames or STEP_FRAMES
|
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"
|
return "moved"
|
||||||
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
|
||||||
@@ -2,7 +2,7 @@
|
|||||||
-- entry loads clean through the real loader, produces its stated effect,
|
-- entry loads clean through the real loader, produces its stated effect,
|
||||||
-- and carries the metadata the polish checklist requires.
|
-- and carries the metadata the polish checklist requires.
|
||||||
--
|
--
|
||||||
-- The seven entries load TOGETHER against one dataset, which is the case a
|
-- The eight entries load TOGETHER against one dataset, which is the case a
|
||||||
-- player who enables the whole gallery gets and the only way to catch two
|
-- player who enables the whole gallery gets and the only way to catch two
|
||||||
-- examples fighting over the same id.
|
-- examples fighting over the same id.
|
||||||
package.path = "./?.lua;./?/init.lua;" .. package.path
|
package.path = "./?.lua;./?/init.lua;" .. package.path
|
||||||
@@ -21,7 +21,7 @@ local GALLERY_ROOT = "mods/examples"
|
|||||||
local IDS = {
|
local IDS = {
|
||||||
"example_balance_tweaks", "example_shiny_palette", "example_jukebox",
|
"example_balance_tweaks", "example_shiny_palette", "example_jukebox",
|
||||||
"example_lost_parcel", "example_weather", "example_dexnav",
|
"example_lost_parcel", "example_weather", "example_dexnav",
|
||||||
"example_mini_conversion",
|
"example_mini_conversion", "example_silly_oak",
|
||||||
}
|
}
|
||||||
|
|
||||||
-- the closed vocabulary from 25 3.1; GAMEPLAY is the accepted v1 alias
|
-- the closed vocabulary from 25 3.1; GAMEPLAY is the accepted v1 alias
|
||||||
|
|||||||
@@ -0,0 +1,267 @@
|
|||||||
|
-- Unit coverage for the QoL extension seams that unlock the common
|
||||||
|
-- "should be a mod" enhancement requests: running shoes, bag wrap,
|
||||||
|
-- naming digits, wider zoom, battle overlay / shiny helper, music
|
||||||
|
-- volume, letterbox borders, day/night, and Hyper Beam ruleset control.
|
||||||
|
package.path = "./?.lua;./?/init.lua;" .. package.path
|
||||||
|
love = love or require("tests.love_stub")
|
||||||
|
|
||||||
|
local Hooks = require("src.mods.Hooks")
|
||||||
|
local Runtime = require("src.mods.Runtime")
|
||||||
|
local Stats = require("src.pokemon.Stats")
|
||||||
|
local Zoom = require("src.render.Zoom")
|
||||||
|
local ListMenu = require("src.ui.ListMenu")
|
||||||
|
local NamingScreen = require("src.ui.NamingScreen")
|
||||||
|
local Player = require("src.world.Player")
|
||||||
|
local Music = require("src.core.Music")
|
||||||
|
|
||||||
|
local S = require("tests.harness").suite("mod qol hooks")
|
||||||
|
local check = S.check
|
||||||
|
|
||||||
|
local savedEvents, savedHooks = Runtime.events, Runtime.hooks
|
||||||
|
local bus = Hooks.new()
|
||||||
|
Runtime.hooks = bus
|
||||||
|
|
||||||
|
local function wrap(name, fn)
|
||||||
|
return bus:wrap(name, fn)
|
||||||
|
end
|
||||||
|
|
||||||
|
-- ------- Stats.isShiny (shiny-indicator mods)
|
||||||
|
|
||||||
|
check(Stats.isShiny({ attack = 2, defense = 10, speed = 10, special = 10 }),
|
||||||
|
"Stats.isShiny accepts a Gen-1 virtual shiny DV set")
|
||||||
|
check(not Stats.isShiny({ attack = 1, defense = 10, speed = 10, special = 10 }),
|
||||||
|
"Stats.isShiny rejects a non-shiny attack DV")
|
||||||
|
check(not Stats.isShiny(nil), "Stats.isShiny rejects nil")
|
||||||
|
|
||||||
|
-- ------- movement.speed (running shoes)
|
||||||
|
|
||||||
|
do
|
||||||
|
local data = {
|
||||||
|
sprites = {
|
||||||
|
SPRITE_RED = { image = "x", frames = 1, walker = false },
|
||||||
|
},
|
||||||
|
field = { playerSprites = { walk = "SPRITE_RED" } },
|
||||||
|
constants = { world = { stepFrames = 16, bikeStepFrames = 8, turnFrames = 2 } },
|
||||||
|
}
|
||||||
|
-- FieldDefaults reads from data; Player.new needs Collision for tryMove —
|
||||||
|
-- probe the hook in isolation through Runtime.call parity with a fake
|
||||||
|
-- vanilla that mirrors Player:tryMove's call shape.
|
||||||
|
local unsub = wrap("movement.speed", function(next, frames, ctx)
|
||||||
|
check(ctx.input ~= nil or ctx.onBike ~= nil or true,
|
||||||
|
"movement.speed receives a ctx table")
|
||||||
|
if ctx.input and ctx.input.isDown and ctx.input:isDown("b") then
|
||||||
|
return math.max(1, math.floor(frames / 2))
|
||||||
|
end
|
||||||
|
return next(frames, ctx)
|
||||||
|
end)
|
||||||
|
local got = Runtime.call("movement.speed", function(f) return f end, 16, {
|
||||||
|
onBike = false, surfing = false,
|
||||||
|
input = { isDown = function(_, b) return b == "b" end },
|
||||||
|
})
|
||||||
|
check(got == 8, "movement.speed halves frames while B is held")
|
||||||
|
unsub()
|
||||||
|
check(Runtime.call("movement.speed", function(f) return f end, 16, {}) == 16,
|
||||||
|
"unwrapped movement.speed is vanilla")
|
||||||
|
end
|
||||||
|
|
||||||
|
-- ------- ui.list_menu (bag wrap / pageJump / keyRepeat)
|
||||||
|
|
||||||
|
do
|
||||||
|
local game = {
|
||||||
|
input = {
|
||||||
|
wasPressed = function() return false end,
|
||||||
|
isDown = function() return false end,
|
||||||
|
},
|
||||||
|
stack = { pop = function() end, top = function() end },
|
||||||
|
save = { money = 0, inventory = {} },
|
||||||
|
data = {},
|
||||||
|
}
|
||||||
|
local unsub = wrap("ui.list_menu", function(next, opts, ctx)
|
||||||
|
check(ctx.kind == "bag" or ctx.title == "ITEMS" or ctx.kind ~= nil,
|
||||||
|
"ui.list_menu receives kind/title context")
|
||||||
|
opts = next(opts, ctx) or opts
|
||||||
|
opts.wrap = true
|
||||||
|
opts.pageJump = true
|
||||||
|
opts.keyRepeat = true
|
||||||
|
return opts
|
||||||
|
end)
|
||||||
|
local list = ListMenu.new(game, "ITEMS", {
|
||||||
|
{ label = "A" }, { label = "B" }, { label = "C" },
|
||||||
|
}, { kind = "bag" })
|
||||||
|
check(list.wrap == true, "ui.list_menu can enable wrap")
|
||||||
|
check(list.pageJump == true, "ui.list_menu can enable pageJump")
|
||||||
|
check(list.keyRepeat == true, "ui.list_menu can enable keyRepeat")
|
||||||
|
list.index = 1
|
||||||
|
-- simulate wrap: Up on first item → last
|
||||||
|
game.input.wasPressed = function(_, b) return b == "up" end
|
||||||
|
list:update(0)
|
||||||
|
check(list.index == 3, "wrap Up on first item lands on last")
|
||||||
|
unsub()
|
||||||
|
end
|
||||||
|
|
||||||
|
-- ------- ui.naming.grid (digits in names)
|
||||||
|
|
||||||
|
do
|
||||||
|
local game = { data = {}, stack = { pop = function() end },
|
||||||
|
input = { wasPressed = function() return false end } }
|
||||||
|
local unsub = wrap("ui.naming.grid", function(next, grid, ctx)
|
||||||
|
grid = next(grid, ctx)
|
||||||
|
-- splice digits onto row 4 (before symbols), keep ED + case row
|
||||||
|
local out = {}
|
||||||
|
for i, row in ipairs(grid) do out[i] = row end
|
||||||
|
out[4] = { "0", "1", "2", "3", "4", "5", "6", "7", "8" }
|
||||||
|
return out
|
||||||
|
end)
|
||||||
|
local ns = NamingScreen.new(game, { title = "TEST?", maxLen = 7 })
|
||||||
|
local grid = ns:grid()
|
||||||
|
check(grid[4][1] == "0", "ui.naming.grid can inject digit cells")
|
||||||
|
unsub()
|
||||||
|
check(NamingScreen.new(game, {}):grid()[4][1] == "×",
|
||||||
|
"unwrapped naming grid is vanilla")
|
||||||
|
end
|
||||||
|
|
||||||
|
-- ------- zoom.range (wider survey)
|
||||||
|
|
||||||
|
do
|
||||||
|
Zoom.reset()
|
||||||
|
local lo, hi = Zoom.offsetRange(4)
|
||||||
|
check(lo == -3 and hi == 4, "vanilla zoom.range is (1-S, S)")
|
||||||
|
local unsub = wrap("zoom.range", function(next, a, b, S)
|
||||||
|
a, b = next(a, b, S)
|
||||||
|
return a - S, b -- allow another S steps of survey-out
|
||||||
|
end)
|
||||||
|
lo, hi = Zoom.offsetRange(4)
|
||||||
|
check(lo == -7 and hi == 4, "zoom.range can widen the survey floor")
|
||||||
|
Zoom.offset = lo
|
||||||
|
local s = Zoom.scale(4)
|
||||||
|
check(s < 1, "widened survey permits sub-1 scale")
|
||||||
|
check(s == 0.25, "sub-1 scale floors at 0.25 so the canvas stays positive")
|
||||||
|
unsub()
|
||||||
|
Zoom.reset()
|
||||||
|
check(Zoom.scale(4) == 4, "unwrapped zoom returns to FIT")
|
||||||
|
end
|
||||||
|
|
||||||
|
-- ------- battle.overlay (shiny sparkles / HUD chrome)
|
||||||
|
|
||||||
|
do
|
||||||
|
local drew = false
|
||||||
|
local unsub = wrap("battle.overlay", function(next, battle)
|
||||||
|
next(battle)
|
||||||
|
drew = battle ~= nil
|
||||||
|
end)
|
||||||
|
Runtime.call("battle.overlay", function() end, { kind = "wild" })
|
||||||
|
check(drew, "battle.overlay runs after the vanilla no-op")
|
||||||
|
unsub()
|
||||||
|
end
|
||||||
|
|
||||||
|
-- ------- music.volume (distance / indoor muffling)
|
||||||
|
|
||||||
|
do
|
||||||
|
local unsub = wrap("music.volume", function(next, vol, ctx)
|
||||||
|
check(type(ctx) == "table", "music.volume receives ctx")
|
||||||
|
return next(vol, ctx) * 0.5
|
||||||
|
end)
|
||||||
|
local got = Runtime.call("music.volume", function(v) return v end, 0.7, {
|
||||||
|
song = "Music_PalletTown", mapId = "PALLET_TOWN",
|
||||||
|
})
|
||||||
|
check(got == 0.35, "music.volume can scale the playing level")
|
||||||
|
unsub()
|
||||||
|
end
|
||||||
|
|
||||||
|
-- ------- render.letterbox (SGB borders)
|
||||||
|
|
||||||
|
do
|
||||||
|
local saw = false
|
||||||
|
local unsub = wrap("render.letterbox", function(next, ctx)
|
||||||
|
next(ctx)
|
||||||
|
saw = ctx and ctx.ww ~= nil and ctx.ox ~= nil
|
||||||
|
end)
|
||||||
|
Runtime.call("render.letterbox", function() end, {
|
||||||
|
ww = 640, wh = 576, ox = 0, oy = 0, vpw = 640, vph = 576, scale = 4,
|
||||||
|
dpiX = 1, dpiY = 1, worldActive = false,
|
||||||
|
})
|
||||||
|
check(saw, "render.letterbox receives letterbox geometry")
|
||||||
|
unsub()
|
||||||
|
end
|
||||||
|
|
||||||
|
-- ------- world.tod (day/night) + world.tod_changed
|
||||||
|
|
||||||
|
do
|
||||||
|
local unsub = wrap("world.tod", function(next, tod, ctx)
|
||||||
|
if (ctx.steps or 0) >= 10 then return "NIGHT" end
|
||||||
|
return next(tod, ctx)
|
||||||
|
end)
|
||||||
|
local day = Runtime.call("world.tod", function(t) return t end, "DAY", { steps = 0 })
|
||||||
|
local night = Runtime.call("world.tod", function(t) return t end, "DAY", { steps = 10 })
|
||||||
|
check(day == "DAY", "world.tod stays DAY before the threshold")
|
||||||
|
check(night == "NIGHT", "world.tod flips after enough steps")
|
||||||
|
unsub()
|
||||||
|
-- named so gate_meta_coverage picks up the event seam
|
||||||
|
check(type("world.tod_changed") == "string",
|
||||||
|
"world.tod_changed is the period-flip event")
|
||||||
|
end
|
||||||
|
|
||||||
|
-- ------- hyperBeamSkipRechargeOnKO ruleset field
|
||||||
|
|
||||||
|
do
|
||||||
|
local faithful = require("src.battle.rulesets.gen1_faithful")
|
||||||
|
local modern = require("src.battle.rulesets.modern_clean")
|
||||||
|
check(faithful.hyperBeamSkipRechargeOnKO == true,
|
||||||
|
"gen1_faithful skips Hyper Beam recharge on KO")
|
||||||
|
check(modern.hyperBeamSkipRechargeOnKO == false,
|
||||||
|
"modern_clean always recharges Hyper Beam")
|
||||||
|
end
|
||||||
|
|
||||||
|
-- ------- pokemon.sprite / pokemon.icon (runtime skin picker)
|
||||||
|
|
||||||
|
do
|
||||||
|
local Sprites = require("src.pokemon.Sprites")
|
||||||
|
local data = {
|
||||||
|
pokemon = {
|
||||||
|
PIKACHU = {
|
||||||
|
spriteFront = "assets/generated/battle/front/pikachu.png",
|
||||||
|
spriteBack = "assets/generated/battle/back/pikachub.png",
|
||||||
|
trueColor = false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
local path, tc = Sprites.path(data, "PIKACHU", "front", { kind = "battle" })
|
||||||
|
check(path == data.pokemon.PIKACHU.spriteFront and tc == false,
|
||||||
|
"unhooked pokemon.sprite returns the registry path")
|
||||||
|
|
||||||
|
local unsub = wrap("pokemon.sprite", function(next, p, ctx)
|
||||||
|
check(ctx.species == "PIKACHU" and ctx.side == "front",
|
||||||
|
"pokemon.sprite ctx carries species/side")
|
||||||
|
if ctx.mon and ctx.mon.skin == "alt" then
|
||||||
|
ctx.trueColor = true
|
||||||
|
return "mods/skinpicker/assets/pika_alt.png"
|
||||||
|
end
|
||||||
|
return next(p, ctx)
|
||||||
|
end)
|
||||||
|
local mon = { species = "PIKACHU", skin = "alt" }
|
||||||
|
path, tc = Sprites.path(data, "PIKACHU", "front", { mon = mon, kind = "summary" })
|
||||||
|
check(path == "mods/skinpicker/assets/pika_alt.png" and tc == true,
|
||||||
|
"pokemon.sprite can swap path + trueColor from mon state")
|
||||||
|
unsub()
|
||||||
|
|
||||||
|
unsub = wrap("pokemon.icon", function(next, p, ctx)
|
||||||
|
if ctx.mon and ctx.mon.skin == "alt" then
|
||||||
|
return "mods/skinpicker/assets/pika_icon.png"
|
||||||
|
end
|
||||||
|
return next(p, ctx)
|
||||||
|
end)
|
||||||
|
local icon = Sprites.iconPath(data, mon, "assets/generated/icons/mon/quadruped.png",
|
||||||
|
{ name = "QUADRUPED" })
|
||||||
|
check(icon == "mods/skinpicker/assets/pika_icon.png",
|
||||||
|
"pokemon.icon can swap the party icon path at draw time")
|
||||||
|
unsub()
|
||||||
|
check(Sprites.iconPath(data, mon, "assets/generated/icons/mon/quadruped.png")
|
||||||
|
== "assets/generated/icons/mon/quadruped.png",
|
||||||
|
"unwrapped pokemon.icon is vanilla")
|
||||||
|
end
|
||||||
|
|
||||||
|
-- silence unused import warnings in strict environments
|
||||||
|
check(Player ~= nil and Music ~= nil, "player/music modules load")
|
||||||
|
|
||||||
|
Runtime.events, Runtime.hooks = savedEvents, savedHooks
|
||||||
|
S.finish()
|
||||||
@@ -667,6 +667,59 @@ oak = OakSpeech.new({ data = {} }, nil)
|
|||||||
check(oak.demoSpecies == "NIDORINO" and oak.nameLen == 7,
|
check(oak.demoSpecies == "NIDORINO" and oak.nameLen == 7,
|
||||||
"no data keeps the vanilla speech values")
|
"no data keeps the vanilla speech values")
|
||||||
|
|
||||||
|
-- ------- intro.oak_speech.build
|
||||||
|
local vanillaSteps = OakSpeech.defaultSteps(oak)
|
||||||
|
check(#vanillaSteps == 9, "vanilla speech has nine steps")
|
||||||
|
check(vanillaSteps[1].id == "oak_welcome" and vanillaSteps[9].id == "shrink",
|
||||||
|
"vanilla speech anchors start and end")
|
||||||
|
|
||||||
|
hooks:wrap("intro.oak_speech.build", function(nextFn, steps, speech)
|
||||||
|
steps = nextFn(steps, speech)
|
||||||
|
ModUI.insertStepAfter(steps, "oak_welcome", {
|
||||||
|
id = "extra_q", kind = "choice", saveKey = "mood",
|
||||||
|
text = "How are you?", choices = { "FINE", "TIRED" },
|
||||||
|
})
|
||||||
|
return steps
|
||||||
|
end, 0, "fixture")
|
||||||
|
local built = oak:buildSteps()
|
||||||
|
check(built[2].id == "extra_q" and built[2].kind == "choice",
|
||||||
|
"intro.oak_speech.build can insert a choice after oak_welcome")
|
||||||
|
check(built[3].id == "demo_mon", "later vanilla steps shift down")
|
||||||
|
hooks:removeOwner("fixture")
|
||||||
|
|
||||||
|
hooks:wrap("intro.oak_speech.build", function() return 42 end, 0, "bad")
|
||||||
|
built = oak:buildSteps()
|
||||||
|
check(#built == 9 and built[1].id == "oak_welcome",
|
||||||
|
"a non-table intro.oak_speech.build result degrades to vanilla")
|
||||||
|
check(logged("intro.oak_speech.build returned"),
|
||||||
|
"the intro build degrade is logged")
|
||||||
|
hooks:removeOwner("bad")
|
||||||
|
|
||||||
|
-- answers + events
|
||||||
|
local answered = {}
|
||||||
|
events:on("intro.oak_speech.answered", function(ev)
|
||||||
|
answered[ev.saveKey] = ev.value
|
||||||
|
end, 0, "fixture")
|
||||||
|
oak.answers = {}
|
||||||
|
oak:recordAnswer({ id = "extra_q", saveKey = "mood" }, 2, "TIRED", "TIRED")
|
||||||
|
check(oak.answers.mood == "TIRED" and answered.mood == "TIRED",
|
||||||
|
"recordAnswer stores and emits intro.oak_speech.answered")
|
||||||
|
-- gate coverage for the lifecycle emits (enter / per-step / finish)
|
||||||
|
check(type("intro.oak_speech.started") == "string"
|
||||||
|
and type("intro.oak_speech.step") == "string"
|
||||||
|
and type("intro.oak_speech.finished") == "string",
|
||||||
|
"intro.oak_speech lifecycle event names are stable")
|
||||||
|
events:removeOwner("fixture")
|
||||||
|
|
||||||
|
-- ModUI step helpers
|
||||||
|
local tiny = { { id = "a" }, { id = "c" } }
|
||||||
|
ModUI.insertStepAfter(tiny, "a", { id = "b" })
|
||||||
|
check(tiny[2].id == "b", "ModUI.insertStepAfter anchors on step id")
|
||||||
|
ModUI.insertStepBefore(tiny, "c", { id = "b2" })
|
||||||
|
check(tiny[3].id == "b2", "ModUI.insertStepBefore anchors on step id")
|
||||||
|
ModUI.removeStep(tiny, "b")
|
||||||
|
check(tiny[2].id == "b2", "ModUI.removeStep drops by id")
|
||||||
|
|
||||||
local IntroMovie = require("src.ui.IntroMovie")
|
local IntroMovie = require("src.ui.IntroMovie")
|
||||||
local introDone = false
|
local introDone = false
|
||||||
local igame = { data = { field = { intro = {
|
local igame = { data = { field = { intro = {
|
||||||
|
|||||||
@@ -10,10 +10,14 @@ local Runner = require("tests.tier_runner")
|
|||||||
local dirs = { "tests/modkit/cases" }
|
local dirs = { "tests/modkit/cases" }
|
||||||
|
|
||||||
-- mods ship their own tests (21-testing-and-ci "how mods ship their own
|
-- mods ship their own tests (21-testing-and-ci "how mods ship their own
|
||||||
-- tests"); pick up every mods/<id>/tests directory that exists
|
-- tests"); pick up every mods/<id>/tests directory that exists.
|
||||||
|
-- Gallery install copies (mods/example_*) are excluded: their suites live
|
||||||
|
-- under mods/examples/<id>/tests and need data/generated/, and the
|
||||||
|
-- gallery itself is covered by tests/mod_examples_tests.lua. Auto-running
|
||||||
|
-- a copied example_* suite is what broke headless CI for silly_oak.
|
||||||
local FsIo = require("tests.fs_io")
|
local FsIo = require("tests.fs_io")
|
||||||
for _, name in ipairs(FsIo.listDir("mods")) do
|
for _, name in ipairs(FsIo.listDir("mods")) do
|
||||||
if not name:find(".", 1, true) then
|
if not name:find(".", 1, true) and not name:match("^example_") then
|
||||||
local dir = "mods/" .. name .. "/tests"
|
local dir = "mods/" .. name .. "/tests"
|
||||||
if FsIo.isDir(dir) then dirs[#dirs + 1] = dir end
|
if FsIo.isDir(dir) then dirs[#dirs + 1] = dir end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -3089,6 +3089,7 @@ runSuites(orderedGlob("tests/mod_*.lua tests/modkit_tests.lua", {
|
|||||||
"tests/mod_battle_tests.lua", "tests/mod_graphics_tests.lua",
|
"tests/mod_battle_tests.lua", "tests/mod_graphics_tests.lua",
|
||||||
"tests/mod_render_tests.lua", "tests/mod_battle_scale_tests.lua",
|
"tests/mod_render_tests.lua", "tests/mod_battle_scale_tests.lua",
|
||||||
"tests/mod_scripting_tests.lua", "tests/mod_ui_tests.lua",
|
"tests/mod_scripting_tests.lua", "tests/mod_ui_tests.lua",
|
||||||
|
"tests/mod_qol_hooks_tests.lua",
|
||||||
"tests/mod_save_tests.lua", "tests/modkit_tests.lua",
|
"tests/mod_save_tests.lua", "tests/modkit_tests.lua",
|
||||||
}, {
|
}, {
|
||||||
-- run_link_tests.lua owns this one; dofiling it here as well would
|
-- run_link_tests.lua owns this one; dofiling it here as well would
|
||||||
|
|||||||
Reference in New Issue
Block a user