intro api upgrade (#294)

* intro api upgrade

* api updates

* fix tests

* updated templates
This commit is contained in:
bryanthaboi
2026-07-27 08:37:11 -04:00
committed by GitHub
parent 08e8dfc416
commit be90e5b42c
42 changed files with 1880 additions and 153 deletions
+14 -1
View File
@@ -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:
+34 -19
View File
@@ -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
+115
View File
@@ -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
+5 -3
View File
@@ -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.
+43
View File
@@ -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

+107
View File
@@ -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."
}
+21
View File
@@ -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")
+17 -4
View File
@@ -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
+6 -2
View File
@@ -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,
+3
View File
@@ -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,
} }
+2
View File
@@ -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
View File
@@ -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.
+1
View File
@@ -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)
+63
View File
@@ -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
+16
View File
@@ -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
+12
View File
@@ -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
View File
@@ -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)
+1
View File
@@ -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
View File
@@ -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);
+3 -1
View File
@@ -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
+7 -6
View File
@@ -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
+3 -2
View File
@@ -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
View File
@@ -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
+28
View File
@@ -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
View File
@@ -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
View File
@@ -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
+1
View File
@@ -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
+5 -3
View File
@@ -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)
+3 -2
View File
@@ -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
+3 -2
View File
@@ -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)
+39 -2
View File
@@ -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
View File
@@ -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
+243
View File
@@ -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 -2
View File
@@ -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
+267
View File
@@ -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()
+53
View File
@@ -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 = {
+6 -2
View File
@@ -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
+1
View File
@@ -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