big ass modding update
@@ -0,0 +1,72 @@
|
||||
# Example mod gallery
|
||||
|
||||
Seven reference mods, one per modder persona. Each is small enough to read
|
||||
in one sitting, exercises a different slice of the mod API, and is a real,
|
||||
runnable, tested mod — not a snippet.
|
||||
|
||||
Copy the one closest to what you want to build.
|
||||
|
||||
| # | Mod | Persona | Category | What it does |
|
||||
|---|---|---|---|---|
|
||||
| 0 | [`../example_mew_starter`](../example_mew_starter) | (legacy) | `GAMEPLAY` | Oak's gift becomes a L20 Mew. The api-1 compatibility proof. |
|
||||
| 1 | [`example_balance_tweaks`](example_balance_tweaks) | Tweaker | `BALANCE` | Faster starters, half-price TMs, a re-slotted Route 1 |
|
||||
| 2 | [`example_shiny_palette`](example_shiny_palette) | Artist | `GRAPHICS` | A teal player recolor derived from your own cache |
|
||||
| 3 | [`example_jukebox`](example_jukebox) | Musician | `AUDIO` | An authored chip song, a new cry, a jukebox screen |
|
||||
| 4 | [`example_lost_parcel`](example_lost_parcel) | Quest author | `QUEST` | A two-town fetch quest over vanilla NPCs |
|
||||
| 5 | [`example_weather`](example_weather) | Mechanic designer | `MECHANIC` | Rain that scales WATER and FIRE damage, behind a ruleset |
|
||||
| 6 | [`example_dexnav`](example_dexnav) | Tool builder | `TOOL` | A START-menu dex overlay with an inter-mod API |
|
||||
| 7 | [`example_mini_conversion`](example_mini_conversion) | TC team | `TOTAL_CONVERSION` | Sable Cove: one town, three species, one badge |
|
||||
|
||||
## None of these load by default
|
||||
|
||||
The engine discovers mods one level below `mods/`. The gallery lives one
|
||||
level deeper, in `mods/examples/`, so a fresh install finds none of them
|
||||
and the vanilla game is unchanged — the parity invariant holds by
|
||||
construction.
|
||||
|
||||
To run one, copy it up a level:
|
||||
|
||||
```sh
|
||||
cp -r mods/examples/example_balance_tweaks mods/
|
||||
python3 tools/modkit.py validate mods/example_balance_tweaks --base imported
|
||||
```
|
||||
|
||||
then enable it in `options.lua` (`mods = { example_balance_tweaks = true }`)
|
||||
or toggle it in the F10 mod manager.
|
||||
|
||||
## Coverage
|
||||
|
||||
Between them the gallery writes into `pokemon`, `items`, `encounters`,
|
||||
`maps`, `sprites`, `palettes`, `icons`, `music`, `cries`, `screens`,
|
||||
`map_scripts`, `commands`, `tokens`, `statuses`, `rulesets`, `constants`
|
||||
and `field`, and exercises:
|
||||
|
||||
- the write verbs `register`, `override` and `patch`, plus `get` and
|
||||
`each` on the merged view
|
||||
- both buses — `events:on` / `events:emit` and `hooks:wrap` — across
|
||||
`music.select`, `battle.damage`, `ui.start_menu.items`, `ui.options.rows`,
|
||||
`battle.started`, `battle.turn_started`, `battle.ended`, `flag.changed`,
|
||||
`game.ready` and `assets.transformed`
|
||||
- `mod.save`, `mod.options`, `mod.exports`, `mod.commands`, `mod.ui`,
|
||||
`mod:read`, `mod.log` and `mod.path`
|
||||
- asset transforms, the `trueColor` opt-out, script labels and `choice`,
|
||||
parallel scripts, `mod:` field routing, replaying an overridden base talk
|
||||
handler through `MapScripts.baseTalk`, and the no-ROM-content posture
|
||||
|
||||
## What every entry has
|
||||
|
||||
```
|
||||
mods/examples/<id>/
|
||||
manifest.json api = 2, a category from the taxonomy, a semver engine range
|
||||
main.lua the entry chunk
|
||||
mod.card sharing metadata: summary, author, tags, differences, credits
|
||||
README.md what it demonstrates, which persona, the commands to try it
|
||||
CHANGELOG.md keep-a-changelog; headings match manifest.version
|
||||
tests/ one runnable suite asserting the mod's stated effect
|
||||
.modkitignore keeps the suite out of the distributed package
|
||||
```
|
||||
|
||||
`tests/mod_examples_tests.lua` in the engine's own suite loads all seven
|
||||
together and asserts the above, so the gallery cannot rot.
|
||||
|
||||
These example mods arent perfect and are just examples to show you basics of wahts possible, but way more than just this is possible.
|
||||
@@ -0,0 +1,12 @@
|
||||
# Changelog
|
||||
|
||||
Format: [keep a changelog](https://keepachangelog.com/en/1.1.0/).
|
||||
Version headings match `manifest.json`'s `version`.
|
||||
|
||||
## 1.0.0
|
||||
|
||||
### Added
|
||||
|
||||
- Base speed patches for VENUSAUR, CHARIZARD and BLASTOISE.
|
||||
- TM price halving driven off `content.items:each()`.
|
||||
- Route 1 grass re-slot.
|
||||
@@ -0,0 +1,59 @@
|
||||
# Balance Tweaks Example
|
||||
|
||||
Raises the final-stage Kanto starters to 100 base speed, halves every TM
|
||||
price, and re-slots the Route 1 grass table — all without copying a single
|
||||
record whole.
|
||||
|
||||
**Persona: the Tweaker.** A player who wants one number changed and copies
|
||||
an example to get there. This is the shortest complete mod in the gallery
|
||||
and the one to start from.
|
||||
|
||||
## Try it
|
||||
|
||||
```sh
|
||||
python3 tools/modkit.py validate mods/examples/example_balance_tweaks --base imported
|
||||
luajit mods/examples/example_balance_tweaks/tests/example_balance_tweaks_test.lua
|
||||
```
|
||||
|
||||
Then enable it: add `example_balance_tweaks = true` under `mods` in your
|
||||
`options.lua`, or toggle it in the F10 mod manager.
|
||||
|
||||
## What it demonstrates
|
||||
|
||||
| Seam | Where |
|
||||
|---|---|
|
||||
| `content.pokemon:patch` | `main.lua` — deep-merges one leaf, leaves the rest alone |
|
||||
| `content.items:each` | `main.lua` — walks the merged view to find TMs instead of listing them |
|
||||
| `content.encounters:patch` | `main.lua` — a list leaf replaces wholesale even inside a patch |
|
||||
| `content.<r>:get` | `main.lua` — the guard that turns a missing id into a log line, not a crash |
|
||||
|
||||
`patch` is the point. The legacy `mods/example_mew_starter` has to copy
|
||||
every field of Mew to change two sprite paths, because api 1 only had
|
||||
`override`. With `patch` you name the leaf:
|
||||
|
||||
```lua
|
||||
mod.content.pokemon:patch("VENUSAUR", { baseStats = { speed = 100 } })
|
||||
```
|
||||
|
||||
Everything not named — `learnset`, `types`, `evolutions`, `spriteFront` —
|
||||
keeps its base value, and a second mod patching `baseStats.attack` on the
|
||||
same species composes with this one instead of clobbering it.
|
||||
|
||||
## Exact changes
|
||||
|
||||
- `VENUSAUR` base speed 80 → 100
|
||||
- `BLASTOISE` base speed 78 → 100
|
||||
- `CHARIZARD` base speed 100 → 100 (already there; kept for symmetry)
|
||||
- every item whose `machine.kind == "TM"` has its `price` halved
|
||||
(`TM_TOXIC` 4000 → 2000, and 49 others; HMs carry `machine.kind == "HM"`
|
||||
and are left alone)
|
||||
- `ROUTE_1` grass `rate` 25 → 20, slot table re-weighted to include
|
||||
`SPEAROW`
|
||||
|
||||
Field meanings are in the generated registry reference (`modkit docs`),
|
||||
section `pokemon`, `items` and `encounters`.
|
||||
|
||||
## Credits
|
||||
|
||||
Base stat and mart price tables come from the player's own imported ROM;
|
||||
this mod ships numbers, not data.
|
||||
@@ -0,0 +1,44 @@
|
||||
-- Gallery #1 (Tweaker): pure data, no engine seams. Everything here is
|
||||
-- patch + each, so no record is ever copied whole and another mod editing
|
||||
-- the same species keeps its own fields.
|
||||
return function(mod)
|
||||
-- patch deep-merges only the leaves it names: learnset, sprites, types
|
||||
-- and evolutions all survive this speed change untouched
|
||||
for _, id in ipairs({ "VENUSAUR", "CHARIZARD", "BLASTOISE" }) do
|
||||
if mod.content.pokemon:get(id) then
|
||||
mod.content.pokemon:patch(id, { baseStats = { speed = 100 } })
|
||||
else
|
||||
-- degrade instead of crashing: a species mod loaded ahead of this
|
||||
-- one may have removed the vanilla starter line
|
||||
mod.log:warn("%s missing from the merged view; speed patch skipped", id)
|
||||
end
|
||||
end
|
||||
|
||||
-- each() walks the merged view (engine records plus every mod ahead of
|
||||
-- this one), so the TM list is discovered rather than hard-coded
|
||||
local halved = 0
|
||||
for id, item in mod.content.items:each() do
|
||||
local machine = item.machine
|
||||
if machine and machine.kind == "TM" and type(item.price) == "number"
|
||||
and item.price > 0 then
|
||||
mod.content.items:patch(id, { price = math.floor(item.price / 2) })
|
||||
halved = halved + 1
|
||||
end
|
||||
end
|
||||
mod.log:info("halved %d TM prices", halved)
|
||||
|
||||
-- lists replace wholesale even inside a patch, so a re-slotted encounter
|
||||
-- table is written out in full while the rate rides along as a leaf
|
||||
mod.content.encounters:patch("ROUTE_1", {
|
||||
grass = {
|
||||
rate = 20,
|
||||
slots = {
|
||||
{ level = 3, species = "PIDGEY" }, { level = 3, species = "RATTATA" },
|
||||
{ level = 4, species = "SPEAROW" }, { level = 2, species = "RATTATA" },
|
||||
{ level = 2, species = "PIDGEY" }, { level = 3, species = "SPEAROW" },
|
||||
{ level = 3, species = "PIDGEY" }, { level = 4, species = "RATTATA" },
|
||||
{ level = 4, species = "PIDGEY" }, { level = 5, species = "SPEAROW" },
|
||||
},
|
||||
},
|
||||
})
|
||||
end
|
||||
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"id": "example_balance_tweaks",
|
||||
"name": "Balance Tweaks Example",
|
||||
"version": "1.0.0",
|
||||
"api": 2,
|
||||
"entry": "main.lua",
|
||||
"profile": "content",
|
||||
"category": "BALANCE",
|
||||
"game_version": ">=1.0.0 <2.0.0",
|
||||
"priority": 100,
|
||||
"dependencies": [],
|
||||
"optional_dependencies": [],
|
||||
"conflicts": [],
|
||||
"description": "Tweaker gallery entry: patch and each over the merged view, with no whole-record copies."
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
-- Sharing metadata (25-community-and-ecosystem.md 3.2). Read by tooling
|
||||
-- and the manager detail pane; never by the loader's merge.
|
||||
return {
|
||||
summary = "Faster final starters, half-price TMs, a re-slotted Route 1.",
|
||||
author = "Pokemon Gen 1 Recompilation Project",
|
||||
contact = "https://github.com/bryanthaboi/pokemon-gen1-recomp-project",
|
||||
tags = { "balance", "data-only", "beginner" },
|
||||
differences = {
|
||||
changed = {
|
||||
"VENUSAUR and BLASTOISE base speed raised to 100 (CHARIZARD already was)",
|
||||
"every TM item's price halved",
|
||||
"Route 1 grass rate 25 -> 20 and SPEAROW added to the slot table",
|
||||
},
|
||||
added = {},
|
||||
known = { "TM prices halve once per load, not once per install" },
|
||||
},
|
||||
credits = {
|
||||
{ who = "pret/pokered", for_ = "the base stat and mart price tables this patches over" },
|
||||
},
|
||||
compat = { engine = ">=1.0.0 <2.0.0", modApi = 2 },
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
-- Standalone: luajit mods/examples/example_balance_tweaks/tests/example_balance_tweaks_test.lua
|
||||
-- Loads the mod through the real headless loader and asserts its stated
|
||||
-- effect against the player's imported dataset.
|
||||
package.path = "./?.lua;./?/init.lua;" .. package.path
|
||||
|
||||
local T = require("tests.modkit")
|
||||
local Data = require("src.core.Data")
|
||||
Data:load()
|
||||
|
||||
local run = T.sdk.loadMod("mods/examples/example_balance_tweaks", { data = Data })
|
||||
T.eq(#run.errors, 0, "loads clean (" .. tostring(run.errors[1]) .. ")")
|
||||
T.eq(run.mod and run.mod.state, "loaded", "reached the loaded state")
|
||||
|
||||
T.eq(Data.pokemon.VENUSAUR.baseStats.speed, 100, "VENUSAUR speed patched")
|
||||
T.eq(Data.pokemon.BLASTOISE.baseStats.speed, 100, "BLASTOISE speed patched")
|
||||
-- the patch named one leaf, so everything else survived
|
||||
T.check(#Data.pokemon.VENUSAUR.learnset > 0, "VENUSAUR keeps its learnset")
|
||||
T.eq(Data.pokemon.VENUSAUR.types[1], "GRASS", "VENUSAUR keeps its types")
|
||||
|
||||
T.eq(Data.items.TM_TOXIC.price, 2000, "TM price halved")
|
||||
T.eq(Data.items.POTION.price, 300, "a non-TM item is untouched")
|
||||
|
||||
T.eq(Data.encounters.ROUTE_1.grass.rate, 20, "Route 1 grass rate patched")
|
||||
T.eq(Data.encounters.ROUTE_1.grass.slots[3].species, "SPEAROW",
|
||||
"Route 1 slot table re-slotted")
|
||||
|
||||
run.release()
|
||||
T.finish("example_balance_tweaks")
|
||||
@@ -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
|
||||
|
||||
- The `ExampleDexNav` screen and its START-menu row.
|
||||
- `SORT BY` and `SHOW UNSEEN` mod options.
|
||||
- `countSeen`, `countOwned` and `species` exports.
|
||||
@@ -0,0 +1,88 @@
|
||||
# DexNav Example
|
||||
|
||||
A START-menu overlay listing every species in the merged dex with its
|
||||
seen/owned state, sortable, and publishing a small API other mods can call.
|
||||
|
||||
**Persona: the Tool Builder.** Consume the merged view, never `require` a
|
||||
private module, expose a stable inter-mod surface. This example is the
|
||||
reference for all three.
|
||||
|
||||
## Try it
|
||||
|
||||
```sh
|
||||
python3 tools/modkit.py validate mods/examples/example_dexnav --base imported
|
||||
luajit mods/examples/example_dexnav/tests/example_dexnav_test.lua
|
||||
```
|
||||
|
||||
Enable it (`example_dexnav = true` under `mods` in `options.lua`, or the
|
||||
F10 manager), then press START → **DEXNAV**. Its two options live in the
|
||||
manager's per-mod options pane.
|
||||
|
||||
## What it demonstrates
|
||||
|
||||
| Seam | Where |
|
||||
|---|---|
|
||||
| `content.screens:register` | `main.lua` — a factory the engine instantiates by id |
|
||||
| `hooks:wrap("ui.start_menu.items")` | `main.lua` — decorate, do not replace |
|
||||
| `mod.ui.insertBefore` | `main.lua` — anchor on a label, not a row index |
|
||||
| `mod.options:define` / `:get` | `main.lua` — auto-rendered rows in the manager |
|
||||
| `mod.exports` | `main.lua` — the inter-mod API |
|
||||
| `content.pokemon:each` | `main.lua` — the whole world, engine records included |
|
||||
|
||||
## Reading, not reaching
|
||||
|
||||
Every fact this mod displays comes from two public sources:
|
||||
|
||||
- `mod.content.pokemon:each()` — the merged species view. A tool that
|
||||
hard-codes 151 breaks the moment another mod registers a species; this
|
||||
one just gets longer.
|
||||
- `game.save.pokedex` — the seen/owned tables, handed in by the engine.
|
||||
|
||||
No `require("src.pokemon.…")`, no permission declared, nothing that a later
|
||||
refactor of an engine module can break.
|
||||
|
||||
## Anchoring a menu row
|
||||
|
||||
```lua
|
||||
mod.hooks:wrap("ui.start_menu.items", function(next, game, items)
|
||||
local out = next(game, items)
|
||||
if type(out) ~= "table" then return out end
|
||||
return mod.ui.insertBefore(out, "SAVE", { label = "DEXNAV", onSelect = ... })
|
||||
end)
|
||||
```
|
||||
|
||||
Two rules, both load-bearing:
|
||||
|
||||
1. **Call `next` first, then decorate what comes back.** Build a fresh list
|
||||
instead and every other mod's row disappears.
|
||||
2. **Anchor on a stable label, not an index.** `insertBefore` appends when
|
||||
the anchor is missing, so the row is always reachable even in a total
|
||||
conversion that renamed `SAVE`.
|
||||
|
||||
## Exporting an API
|
||||
|
||||
```lua
|
||||
mod.exports.countSeen = function(game) ... end
|
||||
```
|
||||
|
||||
Another mod reads it as:
|
||||
|
||||
```lua
|
||||
local nav = mod.find("example_dexnav")
|
||||
if nav then print(nav.exports.countSeen(game)) end
|
||||
```
|
||||
|
||||
`mod.find` returns nil when the other mod is absent, disabled, failed, or
|
||||
has not run yet — so a dependent degrades instead of crashing. Declare it
|
||||
in `optional_dependencies` if you can live without it and `dependencies`
|
||||
if you cannot.
|
||||
|
||||
## Empty state
|
||||
|
||||
`SHOW UNSEEN` off on a fresh save means an empty list. `ListMenu` draws
|
||||
`Nothing here.` and B still exits — never a blank frame with no way out.
|
||||
The title still reports `DEXNAV 0/0`, so the screen explains itself.
|
||||
|
||||
## Credits
|
||||
|
||||
- pret/pokered — the START-menu layout the row is anchored into.
|
||||
@@ -0,0 +1,98 @@
|
||||
-- Gallery #6 (Tool builder): a read-mostly overlay. Everything it knows
|
||||
-- comes from the merged view through the public mod API -- no private
|
||||
-- require, no engine table reached behind the loader's back -- and what it
|
||||
-- knows is published as a stable export other mods can call.
|
||||
local SCREEN = "ExampleDexNav"
|
||||
|
||||
return function(mod)
|
||||
mod.options:define({
|
||||
{ key = "sort", label = "SORT BY", type = "choice", default = "dex",
|
||||
choices = { { "DEX NO.", "dex" }, { "NAME", "name" } } },
|
||||
{ key = "unseen", label = "SHOW UNSEEN", type = "toggle", default = true },
|
||||
})
|
||||
|
||||
-- ------- the merged view, read once per open
|
||||
|
||||
-- content.pokemon:each() yields the engine's species AND every mod's,
|
||||
-- which is the whole point: a tool that hard-codes 151 breaks the moment
|
||||
-- someone adds a species.
|
||||
local function species()
|
||||
local rows = {}
|
||||
for id, mon in mod.content.pokemon:each() do
|
||||
rows[#rows + 1] = { id = id, name = mon.name or id, dex = mon.dex or 9999 }
|
||||
end
|
||||
return rows
|
||||
end
|
||||
|
||||
local function dexOf(game)
|
||||
return (game and game.save and game.save.pokedex) or { seen = {}, owned = {} }
|
||||
end
|
||||
|
||||
local function counts(game)
|
||||
local dex = dexOf(game)
|
||||
local seen, owned = 0, 0
|
||||
for _, row in ipairs(species()) do
|
||||
if dex.seen[row.id] then seen = seen + 1 end
|
||||
if dex.owned[row.id] then owned = owned + 1 end
|
||||
end
|
||||
return seen, owned
|
||||
end
|
||||
|
||||
-- ------- the inter-mod API
|
||||
-- Another mod reads this with mod.find("example_dexnav").exports; it is
|
||||
-- the supported way to depend on this one, and the reason nothing here
|
||||
-- reaches into a private module.
|
||||
|
||||
mod.exports.countSeen = function(game) return (counts(game)) end
|
||||
mod.exports.countOwned = function(game) return select(2, counts(game)) end
|
||||
mod.exports.species = species
|
||||
|
||||
-- ------- the screen
|
||||
|
||||
mod.content.screens:register(SCREEN, {
|
||||
new = function(game)
|
||||
local dex = dexOf(game)
|
||||
local showUnseen = mod.options:get("unseen")
|
||||
local rows = species()
|
||||
if mod.options:get("sort") == "name" then
|
||||
table.sort(rows, function(a, b) return a.name < b.name end)
|
||||
else
|
||||
table.sort(rows, function(a, b)
|
||||
if a.dex ~= b.dex then return a.dex < b.dex end
|
||||
return a.id < b.id
|
||||
end)
|
||||
end
|
||||
|
||||
local items = {}
|
||||
for _, row in ipairs(rows) do
|
||||
local state = dex.owned[row.id] and "OWN"
|
||||
or (dex.seen[row.id] and "SEEN" or "----")
|
||||
if showUnseen or state ~= "----" then
|
||||
items[#items + 1] = { label = row.name, right = state, value = row.id }
|
||||
end
|
||||
end
|
||||
|
||||
local seen, owned = counts(game)
|
||||
-- ListMenu draws "Nothing here." for an empty set, so a brand new
|
||||
-- save with SHOW UNSEEN off reads as a sentence, not a blank frame
|
||||
return mod.ui.ListMenu.new(game,
|
||||
("DEXNAV %d/%d"):format(owned, seen), items, {
|
||||
pageJump = true,
|
||||
onChoose = function(_, menu) menu:close() end,
|
||||
})
|
||||
end,
|
||||
})
|
||||
|
||||
-- ------- reaching it
|
||||
-- Call next() first, then decorate the list it returns: another mod's
|
||||
-- row survives, and the vanilla rows are never rebuilt by hand.
|
||||
|
||||
mod.hooks:wrap("ui.start_menu.items", function(next, game, items)
|
||||
local out = next(game, items)
|
||||
if type(out) ~= "table" then return out end
|
||||
return mod.ui.insertBefore(out, "SAVE", {
|
||||
label = "DEXNAV",
|
||||
onSelect = function() mod.ui.push(game, SCREEN) end,
|
||||
})
|
||||
end)
|
||||
end
|
||||
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"id": "example_dexnav",
|
||||
"name": "DexNav Example",
|
||||
"version": "1.0.0",
|
||||
"api": 2,
|
||||
"entry": "main.lua",
|
||||
"profile": "content",
|
||||
"category": "TOOL",
|
||||
"game_version": ">=1.0.0 <2.0.0",
|
||||
"priority": 100,
|
||||
"dependencies": [],
|
||||
"optional_dependencies": [],
|
||||
"conflicts": [],
|
||||
"description": "Tool-builder gallery entry: a start-menu overlay over the merged dex, with mod options and a stable inter-mod export."
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
-- Sharing metadata (25-community-and-ecosystem.md 3.2). Read by tooling
|
||||
-- and the manager detail pane; never by the loader's merge.
|
||||
return {
|
||||
summary = "A start-menu dex overlay with seen/owned counts and an inter-mod API.",
|
||||
author = "Pokemon Gen 1 Recompilation Project",
|
||||
contact = "https://github.com/bryanthaboi/pokemon-gen1-recomp-project",
|
||||
tags = { "ui", "tool", "quality-of-life" },
|
||||
differences = {
|
||||
changed = { "the START menu gains a DEXNAV row above SAVE" },
|
||||
added = {
|
||||
"ExampleDexNav screen",
|
||||
"mod options: SORT BY and SHOW UNSEEN",
|
||||
"exports countSeen / countOwned / species for other mods",
|
||||
},
|
||||
known = { "the list is built when the screen opens, so catching a mon mid-session needs a reopen" },
|
||||
},
|
||||
credits = {
|
||||
{ who = "pret/pokered", for_ = "the start-menu layout the row is anchored into" },
|
||||
},
|
||||
compat = { engine = ">=1.0.0 <2.0.0", modApi = 2 },
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
-- Standalone: luajit mods/examples/example_dexnav/tests/example_dexnav_test.lua
|
||||
-- Exercises the export surface, the start-menu wrap and the screen factory.
|
||||
package.path = "./?.lua;./?/init.lua;" .. package.path
|
||||
|
||||
local T = require("tests.modkit")
|
||||
local Runtime = require("src.mods.Runtime")
|
||||
local Data = require("src.core.Data")
|
||||
Data:load()
|
||||
|
||||
local Font = require("src.render.Font")
|
||||
Font.load(Data)
|
||||
|
||||
local run = T.sdk.loadMod("mods/examples/example_dexnav", { data = Data })
|
||||
T.eq(#run.errors, 0, "loads clean (" .. tostring(run.errors[1]) .. ")")
|
||||
|
||||
local exports = run.loader.exports.example_dexnav
|
||||
T.check(type(exports.countSeen) == "function", "countSeen is exported")
|
||||
T.check(type(exports.species) == "function", "species is exported")
|
||||
T.eq(#exports.species(), 151, "the export reads the whole merged dex")
|
||||
|
||||
local game = {
|
||||
data = Data,
|
||||
save = { pokedex = { seen = { PIKACHU = true, MEW = true },
|
||||
owned = { PIKACHU = true } } },
|
||||
}
|
||||
T.eq(exports.countSeen(game), 2, "countSeen counts the seen set")
|
||||
T.eq(exports.countOwned(game), 1, "countOwned counts the owned set")
|
||||
|
||||
-- an empty dex is a legal state, not a crash
|
||||
T.eq(exports.countSeen({ data = Data, save = {} }), 0, "a fresh save counts zero")
|
||||
|
||||
-- ------- the start-menu wrap decorates rather than replaces
|
||||
|
||||
local vanilla = { { label = "POKéDEX" }, { label = "SAVE" }, { label = "QUIT" } }
|
||||
local hooked = Runtime.call("ui.start_menu.items",
|
||||
function(_, items) return items end, game, vanilla)
|
||||
T.eq(#hooked, 4, "the wrap added exactly one row")
|
||||
T.eq(hooked[2].label, "DEXNAV", "the row is anchored before SAVE")
|
||||
T.eq(hooked[3].label, "SAVE", "the vanilla rows are still in order")
|
||||
|
||||
-- ------- the screen factory builds a real state
|
||||
|
||||
local Screens = require("src.ui.Screens")
|
||||
Screens.invalidate()
|
||||
local factory = Screens.get(game, "ExampleDexNav")
|
||||
T.check(factory and factory.new, "the screen resolves through the registry")
|
||||
local screen = factory.new(game)
|
||||
T.check(screen.items ~= nil and #screen.items == 151,
|
||||
"the list shows every species with SHOW UNSEEN on")
|
||||
T.eq(screen.title, "DEXNAV 1/2", "the title carries the owned/seen counts")
|
||||
|
||||
run.release()
|
||||
Screens.invalidate()
|
||||
T.finish("example_dexnav")
|
||||
@@ -0,0 +1,13 @@
|
||||
# Changelog
|
||||
|
||||
Format: [keep a changelog](https://keepachangelog.com/en/1.1.0/).
|
||||
Version headings match `manifest.json`'s `version`.
|
||||
|
||||
## 1.0.0
|
||||
|
||||
### Added
|
||||
|
||||
- `Music_ExamplePalletRain`, a two-pulse loop authored in `song.lua`.
|
||||
- A `music.select` wrap that swaps the Pallet Town map theme.
|
||||
- An authored chip cry for MEW.
|
||||
- The `ExampleJukebox` screen and its OPTIONS row.
|
||||
@@ -0,0 +1,99 @@
|
||||
# Jukebox Example
|
||||
|
||||
Adds one song authored note-by-note in Lua, swaps it in as the Pallet Town
|
||||
theme, replaces Mew's cry, and ships a jukebox screen that lists every song
|
||||
in the merged registry.
|
||||
|
||||
**Persona: the Musician.** Nothing here is a `.ogg`. The song is a
|
||||
Game Boy channel program assembled at load time by `ChipAsm`.
|
||||
|
||||
## Try it
|
||||
|
||||
```sh
|
||||
python3 tools/modkit.py validate mods/examples/example_jukebox --base imported
|
||||
luajit mods/examples/example_jukebox/tests/example_jukebox_test.lua
|
||||
|
||||
# render the song to a wav to hear it without launching the game
|
||||
python3 tools/modkit.py bounce Music_ExamplePalletRain --seconds 20 --out bounce
|
||||
```
|
||||
|
||||
Enable it (`example_jukebox = true` under `mods` in `options.lua`, or the
|
||||
F10 manager), then open **OPTIONS → JUKEBOX**.
|
||||
|
||||
## What it demonstrates
|
||||
|
||||
| Seam | Where |
|
||||
|---|---|
|
||||
| `content.music:register` (ChipAsm DSL) | `song.lua` + `main.lua` |
|
||||
| `content.cries:override` (chip program) | `main.lua` |
|
||||
| `hooks:wrap("music.select")` | `main.lua` — one choke point covers map, battle and jingle music |
|
||||
| `content.screens:register` | `main.lua` — a screen factory the engine instantiates by id |
|
||||
| `hooks:wrap("ui.options.rows")` | `main.lua` — how the player reaches the screen |
|
||||
| `content.music:each` | `main.lua` — the jukebox list is the merged view, not a hard-coded array |
|
||||
| `mod:read` | `main.lua` — loading a sibling file through the loader's filesystem |
|
||||
|
||||
## Authoring a song
|
||||
|
||||
`song.lua` returns what `ChipAsm.song{...}` builds: a self-contained
|
||||
program blob plus its channel layout. Events are Lua tables, one per
|
||||
command:
|
||||
|
||||
```lua
|
||||
{ duty = 2 },
|
||||
{ notetype = { speed = 12, volume = 11, fade = 2 } },
|
||||
{ octave = 4 },
|
||||
{ label = "lead" },
|
||||
{ note = "E", len = 6 }, { note = "D", len = 2 },
|
||||
{ loop = { count = 0, to = "lead" } },
|
||||
```
|
||||
|
||||
The assembler is the validator. An unknown note name or an out-of-range
|
||||
length raises *there*, naming the channel and event index, and `main.lua`
|
||||
turns that into one mod-attributed load error. The music system never
|
||||
latches on a bad program, because a bad program never reaches it.
|
||||
|
||||
Two shapes share the `music` registry and are dispatched per definition,
|
||||
not by a global flag: `{ chip = ... }` for an authored program and
|
||||
`{ file = "..." }` for an audio file. This example uses the first; a file
|
||||
track is one line:
|
||||
|
||||
```lua
|
||||
mod.content.music:register("Music_MyTheme", { file = mod.path .. "/theme.ogg" })
|
||||
```
|
||||
|
||||
Note the cry uses `ChipAsm.sfx{...}.chip` — the assembler returns
|
||||
`{ chip = program }`, and a cry record wants the program under its own
|
||||
`chip` key.
|
||||
|
||||
## Deferring is the parity guarantee
|
||||
|
||||
```lua
|
||||
mod.hooks:wrap("music.select", function(next, chosen, ctx)
|
||||
if ctx and ctx.reason == "map" and ctx.mapId == "PALLET_TOWN" then
|
||||
return next(SONG_ID, ctx)
|
||||
end
|
||||
return next(chosen, ctx)
|
||||
end)
|
||||
```
|
||||
|
||||
Every path that is not Pallet Town calls `next(chosen, ctx)` with the
|
||||
argument it was given, so playback everywhere else is exactly what it was
|
||||
before the mod loaded.
|
||||
|
||||
## Empty state
|
||||
|
||||
The jukebox is a `ListMenu`, which draws `Nothing here.` when its item list
|
||||
is empty rather than an empty frame. That cannot happen with the engine's
|
||||
45 songs present, but it is the behavior a mod screen owes the player.
|
||||
|
||||
## Permissions
|
||||
|
||||
This mod declares `engine_internals`, because playing a song from a screen
|
||||
currently needs `require("src.core.Music")` — the mod surface has no audio
|
||||
playback facade yet. Declaring it is the honest path: `modkit validate`
|
||||
accepts a declared require and flags an undeclared one.
|
||||
|
||||
## Credits
|
||||
|
||||
- Pallet Rain arrangement: this project.
|
||||
- pret/pokered: the channel command set `ChipAsm` assembles to.
|
||||
@@ -0,0 +1,96 @@
|
||||
-- Gallery #3 (Musician): one authored chip song, one hook that swaps the
|
||||
-- Pallet Town theme, one derived cry, and a jukebox screen that lists the
|
||||
-- merged music registry.
|
||||
local SONG_ID = "Music_ExamplePalletRain"
|
||||
|
||||
return function(mod)
|
||||
-- the song lives in its own file; mod:read + load keeps it addressable
|
||||
-- through the loader's filesystem instead of the host package.path, so
|
||||
-- the mod works the same installed as it does in the repo
|
||||
local source = mod:read("song.lua")
|
||||
if not source then
|
||||
mod.log:error("song.lua missing from %s -- reinstall the mod", mod.path)
|
||||
return
|
||||
end
|
||||
local chunk, compileErr = load(source, "@" .. mod.path .. "/song.lua")
|
||||
if not chunk then
|
||||
mod.log:error("song.lua did not compile: %s", tostring(compileErr))
|
||||
return
|
||||
end
|
||||
-- a malformed note table raises inside ChipAsm; catching it here turns
|
||||
-- the whole mod into a mod-attributed load error instead of latching the
|
||||
-- music system at playback time
|
||||
local ok, song = pcall(chunk)
|
||||
if not ok then
|
||||
mod.log:error("song.lua failed to assemble: %s", tostring(song))
|
||||
return
|
||||
end
|
||||
|
||||
mod.content.music:register(SONG_ID, song)
|
||||
|
||||
-- a derived cry: the ChipAsm effect command set (channels 5-8), keyed by
|
||||
-- species exactly like the vanilla cry table
|
||||
mod.content.cries:override("MEW", {
|
||||
-- .chip, not the whole return: ChipAsm hands back { chip = program }
|
||||
-- and a cry record carries the program under its own chip key
|
||||
chip = require("src.audio.ChipAsm").sfx{
|
||||
channels = {
|
||||
{ hw = 1, program = {
|
||||
{ pitchSweep = { pace = 3, subtract = false, shift = 2 } },
|
||||
{ squareNote = { len = 6, volume = 14, fade = 2, frequency = 0x5C0 } },
|
||||
{ squareNote = { len = 8, volume = 12, fade = 3, frequency = 0x680 } },
|
||||
} },
|
||||
},
|
||||
}.chip,
|
||||
pitch = 128, length = 128,
|
||||
})
|
||||
|
||||
-- music.select is the single choke point every song choice passes
|
||||
-- through. Defer to next() for everything that is not the case this mod
|
||||
-- cares about: with the mod installed but off the map, playback is
|
||||
-- byte-for-byte what it was.
|
||||
mod.hooks:wrap("music.select", function(next, chosen, ctx)
|
||||
if ctx and ctx.reason == "map" and ctx.mapId == "PALLET_TOWN" then
|
||||
return next(SONG_ID, ctx)
|
||||
end
|
||||
return next(chosen, ctx)
|
||||
end)
|
||||
|
||||
-- the jukebox itself: a screen factory in the screens registry
|
||||
mod.content.screens:register("ExampleJukebox", {
|
||||
new = function(game)
|
||||
local ids = {}
|
||||
for id in mod.content.music:each() do ids[#ids + 1] = id end
|
||||
table.sort(ids)
|
||||
local items = {}
|
||||
for _, id in ipairs(ids) do
|
||||
items[#items + 1] = { label = id:gsub("^Music_", ""), value = id }
|
||||
end
|
||||
-- ListMenu draws "Nothing here." on an empty set, so the empty state
|
||||
-- is a sentence rather than a blank box
|
||||
return mod.ui.ListMenu.new(game, "JUKEBOX", items, {
|
||||
onChoose = function(item)
|
||||
require("src.core.Music").play(game.data, item.value, true,
|
||||
{ reason = "direct" })
|
||||
end,
|
||||
onCancel = function()
|
||||
require("src.core.Music").stop()
|
||||
end,
|
||||
})
|
||||
end,
|
||||
})
|
||||
|
||||
-- reachable from OPTIONS; call next() first and decorate what comes back,
|
||||
-- so every other mod's rows survive this one
|
||||
mod.hooks:wrap("ui.options.rows", function(next, game, rows)
|
||||
local out = next(game, rows)
|
||||
if type(out) ~= "table" then return out end
|
||||
out[#out + 1] = {
|
||||
id = "example_jukebox",
|
||||
label = "JUKEBOX",
|
||||
value = function() return "OPEN" end,
|
||||
activate = function(g) mod.ui.push(g, "ExampleJukebox") end,
|
||||
}
|
||||
return out
|
||||
end)
|
||||
end
|
||||
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"id": "example_jukebox",
|
||||
"name": "Jukebox Example",
|
||||
"version": "1.0.0",
|
||||
"api": 2,
|
||||
"entry": "main.lua",
|
||||
"profile": "content",
|
||||
"category": "AUDIO",
|
||||
"game_version": ">=1.0.0 <2.0.0",
|
||||
"priority": 100,
|
||||
"permissions": ["engine_internals"],
|
||||
"dependencies": [],
|
||||
"optional_dependencies": [],
|
||||
"conflicts": [],
|
||||
"description": "Musician gallery entry: an authored ChipAsm song, a music.select hook, a new cry and a jukebox screen."
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
-- Sharing metadata (25-community-and-ecosystem.md 3.2). Read by tooling
|
||||
-- and the manager detail pane; never by the loader's merge.
|
||||
return {
|
||||
summary = "An authored chip song for Pallet Town, a new Mew cry, and a jukebox screen.",
|
||||
author = "Pokemon Gen 1 Recompilation Project",
|
||||
contact = "https://github.com/bryanthaboi/pokemon-gen1-recomp-project",
|
||||
tags = { "audio", "chiptune", "ui" },
|
||||
differences = {
|
||||
changed = {
|
||||
"Pallet Town's map theme becomes Music_ExamplePalletRain",
|
||||
"MEW's cry is replaced with an authored chip effect",
|
||||
},
|
||||
added = {
|
||||
"Music_ExamplePalletRain song record",
|
||||
"ExampleJukebox screen, reachable from the OPTIONS menu",
|
||||
},
|
||||
known = { "the jukebox plays looping songs only; jingles stop on their own" },
|
||||
},
|
||||
credits = {
|
||||
{ who = "Pokemon Gen 1 Recompilation Project", for_ = "the Pallet Rain arrangement" },
|
||||
{ who = "pret/pokered", for_ = "the channel command set ChipAsm assembles to" },
|
||||
},
|
||||
compat = { engine = ">=1.0.0 <2.0.0", modApi = 2 },
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
-- "Pallet Rain": a two-pulse loop authored in the ChipAsm note-event DSL
|
||||
-- (13-audio-modding.md). ChipAsm is on the loader's supported-require
|
||||
-- list, so authoring a song needs no permissions.
|
||||
--
|
||||
-- The assembler is the validator: an out-of-range length or an unknown
|
||||
-- note name raises here, at load, naming the channel and event index --
|
||||
-- not silently at playback.
|
||||
local ChipAsm = require("src.audio.ChipAsm")
|
||||
|
||||
return ChipAsm.song{
|
||||
tempo = 0x120,
|
||||
channels = {
|
||||
-- lead: a four-bar descending figure that loops forever
|
||||
{ hw = 1, program = {
|
||||
{ duty = 2 },
|
||||
{ notetype = { speed = 12, volume = 11, fade = 2 } },
|
||||
{ octave = 4 },
|
||||
{ label = "lead" },
|
||||
{ note = "E", len = 6 }, { note = "D", len = 2 },
|
||||
{ note = "C", len = 6 }, { rest = 2 },
|
||||
{ note = "A", len = 4 }, { note = "G", len = 4 },
|
||||
{ note = "C", len = 8 },
|
||||
{ note = "E", len = 6 }, { note = "G", len = 2 },
|
||||
{ note = "A", len = 8 },
|
||||
{ rest = 8 },
|
||||
{ loop = { count = 0, to = "lead" } },
|
||||
} },
|
||||
-- counter-line: same length, one octave down, softer
|
||||
{ hw = 2, program = {
|
||||
{ duty = 1 },
|
||||
{ notetype = { speed = 12, volume = 7, fade = 1 } },
|
||||
{ octave = 3 },
|
||||
{ label = "bass" },
|
||||
{ note = "C", len = 8 }, { note = "G", len = 8 },
|
||||
{ note = "A", len = 8 }, { note = "F", len = 8 },
|
||||
{ note = "C", len = 8 }, { note = "G", len = 8 },
|
||||
{ loop = { count = 0, to = "bass" } },
|
||||
} },
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
-- Standalone: luajit mods/examples/example_jukebox/tests/example_jukebox_test.lua
|
||||
-- Asserts the song assembles, the cry merges, and music.select swaps only
|
||||
-- the map this mod claims.
|
||||
package.path = "./?.lua;./?/init.lua;" .. package.path
|
||||
|
||||
local T = require("tests.modkit")
|
||||
local Runtime = require("src.mods.Runtime")
|
||||
local Data = require("src.core.Data")
|
||||
Data:load()
|
||||
|
||||
local run = T.sdk.loadMod("mods/examples/example_jukebox", { data = Data })
|
||||
T.eq(#run.errors, 0, "loads clean (" .. tostring(run.errors[1]) .. ")")
|
||||
|
||||
-- ------- the authored song
|
||||
|
||||
local song = Data.audio.songs.Music_ExamplePalletRain
|
||||
T.check(type(song) == "table", "the song registered")
|
||||
T.check(type(song.chip) == "table" and #song.chip.blob > 0,
|
||||
"it assembled to a non-empty program blob")
|
||||
T.eq(#song.chip.channels, 2, "both channels are laid out")
|
||||
T.eq(song.chip.channels[1].address, 0x4000,
|
||||
"the first channel is based at the 0x4000 window")
|
||||
|
||||
-- ------- the cry
|
||||
|
||||
local cry = Data.audio.cries.MEW
|
||||
T.check(type(cry) == "table" and type(cry.chip) == "table",
|
||||
"the MEW cry is an authored chip program")
|
||||
T.check(#cry.chip.blob > 0, "the cry program is non-empty")
|
||||
T.eq(cry.chip.channels[1].number, 5,
|
||||
"an sfx program lives on the effect channels (5-8)")
|
||||
|
||||
-- ------- the hook swaps exactly one map
|
||||
|
||||
local function select(song_, ctx)
|
||||
return Runtime.call("music.select", function(chosen) return chosen end, song_, ctx)
|
||||
end
|
||||
T.eq(select("Music_PalletTown", { reason = "map", mapId = "PALLET_TOWN" }),
|
||||
"Music_ExamplePalletRain", "Pallet Town gets the new theme")
|
||||
T.eq(select("Music_Routes1", { reason = "map", mapId = "ROUTE_1" }),
|
||||
"Music_Routes1", "every other map defers to the vanilla choice")
|
||||
T.eq(select("Music_Battle", { reason = "battle", kind = "wild" }),
|
||||
"Music_Battle", "battle music defers too")
|
||||
T.eq(select("Music_PalletTown", nil), "Music_PalletTown",
|
||||
"a direct play with no context defers")
|
||||
|
||||
-- ------- the screen and its options row
|
||||
|
||||
local Font = require("src.render.Font")
|
||||
Font.load(Data)
|
||||
local Screens = require("src.ui.Screens")
|
||||
Screens.invalidate()
|
||||
local factory = Screens.get({ data = Data }, "ExampleJukebox")
|
||||
T.check(factory and factory.new, "the jukebox resolves through the screens registry")
|
||||
local screen = factory.new({ data = Data })
|
||||
T.check(#screen.items > 0, "the jukebox lists the merged music registry")
|
||||
local listed = false
|
||||
for _, item in ipairs(screen.items) do
|
||||
if item.value == "Music_ExamplePalletRain" then listed = true end
|
||||
end
|
||||
T.check(listed, "the mod's own song is in the list")
|
||||
|
||||
local rows = Runtime.call("ui.options.rows", function(_, r) return r end,
|
||||
{ data = Data }, { { id = "text_speed" } })
|
||||
T.eq(#rows, 2, "the options hook added exactly one row")
|
||||
T.eq(rows[2].id, "example_jukebox", "the row is the jukebox entry")
|
||||
|
||||
run.release()
|
||||
Screens.invalidate()
|
||||
T.finish("example_jukebox")
|
||||
@@ -0,0 +1,15 @@
|
||||
# Changelog
|
||||
|
||||
Format: [keep a changelog](https://keepachangelog.com/en/1.1.0/).
|
||||
Version headings match `manifest.json`'s `version`.
|
||||
|
||||
## 1.0.0
|
||||
|
||||
### Added
|
||||
|
||||
- The two-town fetch quest over `VIRIDIAN_CITY` and `PEWTER_CITY`.
|
||||
- `EXAMPLE_LOST_PARCEL_PARCEL` key item and the `{EXAMPLE_PARCEL_REWARD}` token.
|
||||
- The `example_lost_parcel:count_ask` verb and the ambient parallel script.
|
||||
- `example_lost_parcel:base_nerd_chat`, which replays the Pewter super
|
||||
nerd's base handler through `MapScripts.baseTalk` so the branches the
|
||||
quest does not own keep the vanilla YES/NO conversation intact.
|
||||
@@ -0,0 +1,131 @@
|
||||
# The Lost Parcel
|
||||
|
||||
A courier in Viridian City dropped a parcel somewhere in Pewter City.
|
||||
Fetch it and he pays you a NUGGET.
|
||||
|
||||
**Persona: the Quest Author.** Two towns, two vanilla NPCs, a branching
|
||||
conversation, a key item, a reward and some ambience — and not one line of
|
||||
map data or engine source changed.
|
||||
|
||||
## Try it
|
||||
|
||||
```sh
|
||||
python3 tools/modkit.py validate mods/examples/example_lost_parcel --base imported
|
||||
luajit mods/examples/example_lost_parcel/tests/example_lost_parcel_test.lua
|
||||
```
|
||||
|
||||
Enable it (`example_lost_parcel = true` under `mods` in `options.lua`, or
|
||||
the F10 manager), then:
|
||||
|
||||
```
|
||||
VIRIDIAN CITY PEWTER CITY
|
||||
┌───────────────────┐ ┌───────────────────┐
|
||||
│ GAMBLER ◀──────┼── accept ───┼──▶ SUPER NERD │
|
||||
│ (quest giver) │ │ (has the │
|
||||
│ ▲ │ │ parcel) │
|
||||
└───────┼───────────┘ └─────────┬─────────┘
|
||||
└──────────── return ───────────────┘
|
||||
```
|
||||
|
||||
Talk to the gambler in Viridian (the one south of the Poké Mart), say
|
||||
`SURE`, walk to Pewter, talk to the super nerd by the museum, walk back.
|
||||
|
||||
## What it demonstrates
|
||||
|
||||
| Seam | Where |
|
||||
|---|---|
|
||||
| `content.map_scripts:register` (compose) | `main.lua` — two maps, no map edits |
|
||||
| talk override on a real `TEXT_` constant | `main.lua` — `TEXT_VIRIDIANCITY_GAMBLER1`, `TEXT_PEWTERCITY_SUPER_NERD1` |
|
||||
| `choice` + `label` + `jump_if_true/false` | `main.lua` — a five-branch conversation |
|
||||
| `MOD_` flag convention | `main.lua` — `MOD_EXAMPLE_LOST_PARCEL_*` |
|
||||
| `set_field "mod:key"` | `main.lua` — quest scratch state in `save.modData[mod.id]` |
|
||||
| `mod.save:get/set` | `main.lua` — the same value through the loader-side namespace |
|
||||
| `content.commands:register` (table form) | `main.lua` — a `foreground` verb of the mod's own |
|
||||
| `MapScripts.baseTalk` (replay the overridden handler) | `main.lua` — `example_lost_parcel:base_nerd_chat` |
|
||||
| `content.tokens:register` | `main.lua` — `{EXAMPLE_PARCEL_REWARD}` |
|
||||
| `content.items:register` | `main.lua` — the parcel key item |
|
||||
| a parallel ambient script | `main.lua` — `scripts.example_nerd_pace` + `onEnter` |
|
||||
| `events:on` / `events:emit` | `main.lua` — announcing completion under `mod.<id>.*` |
|
||||
|
||||
## How the compose merge works
|
||||
|
||||
`map_scripts` is a **compose** registry, not a record registry. Registering
|
||||
does not replace the engine's contribution for a map; it prepends to an
|
||||
ordered chain, and each key composes by its own rule
|
||||
(`09-scripting-and-quests.md` §4.4):
|
||||
|
||||
| key | rule |
|
||||
|---|---|
|
||||
| `talk`, `scripts` | single winner per name; `false` suppresses and falls through |
|
||||
| `onEnter`, `onVictory`, `onBoulderMoved` | all contributions run, each `pcall`-guarded |
|
||||
| `onStep`, `onInteract` | first truthy return consumes the step |
|
||||
|
||||
So this mod's `onEnter` for Pewter City runs *alongside* the engine's, not
|
||||
instead of it. Its `talk` entry for `TEXT_PEWTERCITY_SUPER_NERD1` does win
|
||||
outright — the engine's handler for that constant stops being dispatched
|
||||
the moment this mod loads. Giving it back is the last branch's whole job:
|
||||
|
||||
```lua
|
||||
{ "label", "vanilla" },
|
||||
{ "example_lost_parcel:base_nerd_chat" },
|
||||
```
|
||||
|
||||
```lua
|
||||
local base = MapScripts.baseTalk("PEWTER_CITY", "TEXT_PEWTERCITY_SUPER_NERD1")
|
||||
base(ctx.game, ctx.overworld, ctx.npc, function() ctx.runner:resume() end)
|
||||
ctx.runner:yield()
|
||||
```
|
||||
|
||||
`MapScripts.baseTalk` reaches the engine handler still sitting behind the
|
||||
override (`09-scripting-and-quests.md` §6) — the supported replacement for
|
||||
re-wrapping it. A `{ "show_text", "TEXT_PEWTERCITY_SUPER_NERD1" }` row
|
||||
*looks* like it does the same thing and does not: this NPC's base handler
|
||||
is a Lua function that asks YES/NO and answers with one of two follow-ups,
|
||||
while `show_text` resolves the constant to its opening line and stops. So
|
||||
before the quest starts and after the parcel is taken, the player gets the
|
||||
whole conversation they always got, choice included. The test drives both
|
||||
answers, in both states, and asserts every line.
|
||||
|
||||
This is also why the manifest declares `engine_internals`: replaying a base
|
||||
handler means requiring `src.script.MapScripts`.
|
||||
|
||||
## Flags, fields and mod state
|
||||
|
||||
Three storage routes, three jobs:
|
||||
|
||||
- **`MOD_`-prefixed flags** — the quest's public state machine. In the
|
||||
normal flag namespace so `check_flag` works, prefixed so it can never
|
||||
collide with a pokered event constant.
|
||||
- **`set_field "mod:asked_count"`** — script-visible scratch state, routed
|
||||
into `save.modData[mod.id]` by the owning contribution's attribution.
|
||||
Two copies of a quest cannot collide on one key.
|
||||
- **`mod.save:get/set`** — the same namespace from Lua, for code that is
|
||||
not a script row.
|
||||
|
||||
## Verb metadata
|
||||
|
||||
The custom verb is registered in the table form:
|
||||
|
||||
```lua
|
||||
mod.content.commands:register("example_lost_parcel:count_ask", {
|
||||
foreground = true,
|
||||
fn = function(ctx) ... end,
|
||||
})
|
||||
```
|
||||
|
||||
`foreground = true` marks it illegal inside a parallel script, so the
|
||||
ambient runner can never touch quest state. Namespacing the verb with the
|
||||
mod id keeps it from colliding with another mod's — `register` on a name
|
||||
the engine already owns is an error, and replacing one requires `override`.
|
||||
|
||||
## Validation
|
||||
|
||||
Every row is checked against the merged command set once all entry chunks
|
||||
have run. Typo a verb or jump to a label that does not exist and this mod
|
||||
fails at *load* with the row number, is rolled back whole, and says so in
|
||||
the manager — it never half-loads into a broken conversation.
|
||||
|
||||
## Credits
|
||||
|
||||
- pret/pokered — the `TEXT_` constants and base conversations this
|
||||
composes with.
|
||||
@@ -0,0 +1,172 @@
|
||||
-- Gallery #4 (Quest author): the worked multi-map fetch quest from
|
||||
-- 09-scripting-and-quests.md 6. A courier in Viridian City lost a parcel
|
||||
-- in Pewter City; the player retrieves it for a NUGGET.
|
||||
--
|
||||
-- No map is edited and no engine file is touched. Both NPCs are vanilla
|
||||
-- objects addressed by their real TEXT_ constants, and the base
|
||||
-- conversation is still reachable on every branch the quest does not own.
|
||||
local MapScripts = require("src.script.MapScripts")
|
||||
|
||||
local PARCEL = "EXAMPLE_LOST_PARCEL_PARCEL"
|
||||
local REWARD = "NUGGET"
|
||||
|
||||
-- flags a mod writes are MOD_-prefixed by convention, so a save never
|
||||
-- confuses them with the pokered event namespace
|
||||
local STARTED = "MOD_EXAMPLE_LOST_PARCEL_STARTED"
|
||||
local TAKEN = "MOD_EXAMPLE_LOST_PARCEL_TAKEN"
|
||||
local DONE = "MOD_EXAMPLE_LOST_PARCEL_DONE"
|
||||
|
||||
-- the Pewter super nerd's object index on PEWTER_CITY; the ambient script
|
||||
-- makes this one fidget while the parcel is still lying around
|
||||
local NERD_INDEX = 3
|
||||
|
||||
return function(mod)
|
||||
-- ------- the reward item
|
||||
|
||||
mod.content.items:register(PARCEL, {
|
||||
id = PARCEL,
|
||||
name = "PARCEL?",
|
||||
price = 0,
|
||||
keyItem = true,
|
||||
tossable = false,
|
||||
})
|
||||
|
||||
-- ------- a text token, so the reward name is written once
|
||||
|
||||
mod.content.tokens:register("EXAMPLE_PARCEL_REWARD", function(game)
|
||||
local item = game and game.data and game.data.items[REWARD]
|
||||
return item and item.name or REWARD
|
||||
end)
|
||||
|
||||
-- ------- a script verb of this mod's own
|
||||
-- The table form carries dispatch metadata: foreground marks it illegal
|
||||
-- inside a parallel script, which is what keeps the ambient runner from
|
||||
-- ever touching quest state.
|
||||
mod.content.commands:register("example_lost_parcel:count_ask", {
|
||||
foreground = true,
|
||||
fn = function(ctx)
|
||||
-- mod: fields route into save.modData[owner], so quest scratch state
|
||||
-- is attributable and two quests never collide on one key
|
||||
local base = ctx.save.modData and ctx.save.modData[mod.id]
|
||||
local asked = (base and base.asked_count or 0) + 1
|
||||
ctx.save.modData = ctx.save.modData or {}
|
||||
ctx.save.modData[mod.id] = ctx.save.modData[mod.id] or {}
|
||||
ctx.save.modData[mod.id].asked_count = asked
|
||||
-- the same number through the loader-side namespace, which is what a
|
||||
-- screen or another mod would read
|
||||
mod.save:set("asked_count", asked)
|
||||
end,
|
||||
})
|
||||
|
||||
-- ------- handing a branch back to the base conversation
|
||||
-- talk dispatch is single-winner, so the Pewter rows below replace the
|
||||
-- engine's handler outright. Re-running the TEXT_ constant with
|
||||
-- show_text would only replay its opening line: the base handler is a
|
||||
-- Lua function that asks YES/NO and answers with one of two follow-ups,
|
||||
-- and none of that survives a text lookup. baseTalk reaches the handler
|
||||
-- still sitting behind the override (09 6), so the branches the quest
|
||||
-- does not own play the whole vanilla conversation.
|
||||
mod.content.commands:register("example_lost_parcel:base_nerd_chat", {
|
||||
foreground = true,
|
||||
fn = function(ctx)
|
||||
local base = MapScripts.baseTalk("PEWTER_CITY", "TEXT_PEWTERCITY_SUPER_NERD1")
|
||||
if not base then return end
|
||||
local runner = ctx.runner
|
||||
base(ctx.game, ctx.overworld, ctx.npc, function() runner:resume() end)
|
||||
runner:yield()
|
||||
end,
|
||||
})
|
||||
|
||||
-- ------- Viridian City: the quest giver
|
||||
|
||||
mod.content.map_scripts:register("VIRIDIAN_CITY", {
|
||||
talk = {
|
||||
TEXT_VIRIDIANCITY_GAMBLER1 = {
|
||||
{ "check_flag", DONE },
|
||||
{ "jump_if_true", "after" },
|
||||
{ "check_flag", STARTED },
|
||||
{ "jump_if_true", "pending" },
|
||||
{ "show_text", "I dropped a parcel\nsomewhere in\nPEWTER CITY..." },
|
||||
{ "choice", { "SURE", "NO WAY" } },
|
||||
{ "jump_if_false", "refused" },
|
||||
{ "set_flag", STARTED },
|
||||
{ "set_field", "mod:asked_count", 0 },
|
||||
{ "show_text", "Thanks! A {EXAMPLE_PARCEL_REWARD}\nawaits you!" },
|
||||
{ "jump", "end" },
|
||||
|
||||
{ "label", "pending" },
|
||||
{ "example_lost_parcel:count_ask" },
|
||||
{ "check_item", PARCEL },
|
||||
{ "jump_if_false", "remind" },
|
||||
{ "take_item", PARCEL },
|
||||
{ "give_item", REWARD },
|
||||
{ "set_flag", DONE },
|
||||
{ "emote", "player", "happy", 45 },
|
||||
{ "show_text", "You found it!\nHere, as promised!" },
|
||||
{ "jump", "end" },
|
||||
|
||||
{ "label", "remind" },
|
||||
{ "show_text", "It's a small brown\nparcel. PEWTER CITY!" },
|
||||
{ "jump", "end" },
|
||||
|
||||
{ "label", "refused" },
|
||||
{ "show_text", "Aww. GYMs are\nclosed anyway..." },
|
||||
{ "jump", "end" },
|
||||
|
||||
{ "label", "after" },
|
||||
{ "show_text", "Thanks again,\n{PLAYER}!" },
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
-- ------- Pewter City: the parcel, and some ambience while it is lost
|
||||
|
||||
mod.content.map_scripts:register("PEWTER_CITY", {
|
||||
talk = {
|
||||
TEXT_PEWTERCITY_SUPER_NERD1 = {
|
||||
{ "check_flag", STARTED },
|
||||
{ "jump_if_false", "vanilla" },
|
||||
{ "check_flag", TAKEN },
|
||||
{ "jump_if_true", "vanilla" },
|
||||
{ "show_text", "Someone dropped\nthis parcel by the\nMUSEUM." },
|
||||
{ "give_item", PARCEL, 1, false },
|
||||
{ "set_flag", TAKEN },
|
||||
{ "show_text", "{PLAYER} got the\nparcel back!" },
|
||||
{ "jump", "end" },
|
||||
|
||||
-- every branch the quest does not own replays the base handler, so
|
||||
-- the vanilla conversation is never lost to the override
|
||||
{ "label", "vanilla" },
|
||||
{ "example_lost_parcel:base_nerd_chat" },
|
||||
},
|
||||
},
|
||||
|
||||
-- all-run: this composes with the engine's own onEnter for the map
|
||||
-- instead of replacing it
|
||||
onEnter = function(game, ow)
|
||||
local flags = game.save and game.save.flags or {}
|
||||
if flags[STARTED] and not flags[TAKEN] then
|
||||
ow:queueScript({ { "run_parallel", "PEWTER_CITY/example_nerd_pace" } })
|
||||
end
|
||||
end,
|
||||
|
||||
scripts = {
|
||||
-- background-legal verbs only; the runner rejects foreground rows in
|
||||
-- a parallel slot, and the script dies on map exit
|
||||
example_nerd_pace = {
|
||||
{ "label", "top" },
|
||||
{ "march_in_place", NERD_INDEX, true }, { "wait", 90 },
|
||||
{ "march_in_place", NERD_INDEX, false }, { "wait", 150 },
|
||||
{ "jump", "top" },
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
-- quest completion is worth announcing to other mods; a mod may only
|
||||
-- broadcast under its own prefix
|
||||
mod.events:on("flag.changed", function(ev)
|
||||
if ev.name == DONE and ev.value then
|
||||
mod.events:emit("mod.example_lost_parcel.completed", { reward = REWARD })
|
||||
end
|
||||
end)
|
||||
end
|
||||
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"id": "example_lost_parcel",
|
||||
"name": "The Lost Parcel",
|
||||
"version": "1.0.0",
|
||||
"api": 2,
|
||||
"entry": "main.lua",
|
||||
"profile": "content",
|
||||
"category": "QUEST",
|
||||
"game_version": ">=1.0.0 <2.0.0",
|
||||
"priority": 100,
|
||||
"permissions": ["engine_internals"],
|
||||
"dependencies": [],
|
||||
"optional_dependencies": [],
|
||||
"conflicts": [],
|
||||
"description": "Quest-author gallery entry: a two-town fetch quest over real TEXT constants, with choices, labels, MOD_ flags and a parallel ambient script."
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
-- Sharing metadata (25-community-and-ecosystem.md 3.2). Read by tooling
|
||||
-- and the manager detail pane; never by the loader's merge.
|
||||
return {
|
||||
summary = "A courier in Viridian lost a parcel in Pewter. Fetch it for a NUGGET.",
|
||||
author = "Pokemon Gen 1 Recompilation Project",
|
||||
contact = "https://github.com/bryanthaboi/pokemon-gen1-recomp-project",
|
||||
tags = { "quest", "story", "scripting" },
|
||||
differences = {
|
||||
changed = {
|
||||
"the Viridian gambler and the Pewter super nerd gain quest branches; "
|
||||
.. "their vanilla lines still play on every other branch",
|
||||
},
|
||||
added = {
|
||||
"EXAMPLE_LOST_PARCEL_PARCEL key item",
|
||||
"{EXAMPLE_PARCEL_REWARD} text token",
|
||||
"example_lost_parcel:count_ask script verb",
|
||||
"example_lost_parcel:base_nerd_chat script verb, which replays the "
|
||||
.. "super nerd's base conversation the quest branches around",
|
||||
"an ambient parallel script on PEWTER_CITY while the parcel is lost",
|
||||
},
|
||||
known = { "the parcel can be tossed from the bag; the quest then stalls at the reminder line" },
|
||||
},
|
||||
credits = {
|
||||
{ who = "pret/pokered", for_ = "the TEXT_ constants and base conversations this composes with" },
|
||||
},
|
||||
compat = { engine = ">=1.0.0 <2.0.0", modApi = 2 },
|
||||
}
|
||||
@@ -0,0 +1,163 @@
|
||||
-- Standalone: luajit mods/examples/example_lost_parcel/tests/example_lost_parcel_test.lua
|
||||
-- Plays the quest end to end headlessly: accept, fetch, hand over.
|
||||
package.path = "./?.lua;./?/init.lua;" .. package.path
|
||||
|
||||
local T = require("tests.modkit")
|
||||
local Data = require("src.core.Data")
|
||||
Data:load()
|
||||
|
||||
local Font = require("src.render.Font")
|
||||
local ScriptRunner = require("src.script.ScriptRunner")
|
||||
local MapScripts = require("src.script.MapScripts")
|
||||
-- the engine attaches its hand-ported scripts as the base contribution at
|
||||
-- boot; the quest composes on top of them, so the harness needs them too
|
||||
require("data.scripts.init")
|
||||
Font.load(Data)
|
||||
|
||||
local run = T.sdk.loadMod("mods/examples/example_lost_parcel", { data = Data })
|
||||
T.eq(#run.errors, 0, "loads clean (" .. tostring(run.errors[1]) .. ")")
|
||||
T.check(Data.items.EXAMPLE_LOST_PARCEL_PARCEL ~= nil, "the parcel item merged")
|
||||
T.check(Data.tokens.EXAMPLE_PARCEL_REWARD ~= nil, "the reward token merged")
|
||||
T.check(Data.commands["example_lost_parcel:count_ask"] ~= nil,
|
||||
"the mod's script verb merged")
|
||||
|
||||
-- a stack whose boxes are answered from OUTSIDE the coroutine: a text box
|
||||
-- pushed by show_text resolves on the next drive tick, never re-entrantly
|
||||
local choice = 1
|
||||
local function newGame()
|
||||
local game = { data = Data, save = {
|
||||
flags = {}, inventory = {}, modData = {},
|
||||
player = { name = "RED", rival = "BLUE" },
|
||||
} }
|
||||
local stack = { states = {} }
|
||||
function stack:push(state) self.states[#self.states + 1] = state end
|
||||
function stack:pop() return table.remove(self.states) end
|
||||
function stack:top() return self.states[#self.states] end
|
||||
game.stack = stack
|
||||
game.shown = {} -- first line of every text box, in order
|
||||
game.asked = 0 -- YES/NO boxes the conversation put up
|
||||
return game
|
||||
end
|
||||
|
||||
-- advance one pending box or emote hold; choice menus pick `choice`
|
||||
local function settle(game, ow)
|
||||
if ow.emote then
|
||||
local held = ow.emote
|
||||
ow.emote = nil
|
||||
held.onDone()
|
||||
return true
|
||||
end
|
||||
local top = table.remove(game.stack.states)
|
||||
if not top then return false end
|
||||
if top.pages then
|
||||
game.lastText = top.pages[1] and top.pages[1][1]
|
||||
game.shown[#game.shown + 1] = game.lastText
|
||||
end
|
||||
if top.items then
|
||||
local item = top.items[choice]
|
||||
if item and item.onSelect then item.onSelect() end
|
||||
elseif top.onChoose then
|
||||
-- a bare ChoiceBox: what the engine's own Lua talk handlers ask with
|
||||
game.asked = game.asked + 1
|
||||
top.onChoose(choice == 1)
|
||||
elseif top.onDone then
|
||||
top.onDone()
|
||||
end
|
||||
return true
|
||||
end
|
||||
|
||||
-- the first line of a generated text constant, which is what a TextBox
|
||||
-- paginates onto its first row
|
||||
local function firstLine(s) return (tostring(s):match("^[^\n\f\v]*")) end
|
||||
|
||||
-- the overworld the talk dispatch would supply; its map label is what
|
||||
-- show_text resolves a bare TEXT_ constant through
|
||||
local function overworldFor(mapId)
|
||||
return { map = { id = mapId, def = { label = Data.maps[mapId].label } } }
|
||||
end
|
||||
|
||||
local function talk(game, mapId, textConst)
|
||||
local rows = MapScripts.talkScript(mapId, textConst)
|
||||
T.check(rows ~= nil, mapId .. "." .. textConst .. " has a talk script")
|
||||
local ow = overworldFor(mapId)
|
||||
local runner = ScriptRunner.new(game, ow)
|
||||
runner:run(rows, { source = MapScripts.talkSource(mapId, textConst) })
|
||||
for _ = 1, 400 do
|
||||
if not runner:isRunning() then break end
|
||||
if not settle(game, ow) then runner:update() end
|
||||
end
|
||||
T.check(not runner:isRunning(), "the " .. textConst .. " script completed")
|
||||
end
|
||||
|
||||
-- ------- branch 1: refuse the quest
|
||||
|
||||
do
|
||||
choice = 2
|
||||
local game = newGame()
|
||||
talk(game, "VIRIDIAN_CITY", "TEXT_VIRIDIANCITY_GAMBLER1")
|
||||
T.check(not game.save.flags.MOD_EXAMPLE_LOST_PARCEL_STARTED,
|
||||
"refusing the choice leaves the quest unstarted")
|
||||
end
|
||||
|
||||
-- ------- branch 2: accept, fetch, deliver
|
||||
|
||||
local game = newGame()
|
||||
choice = 1
|
||||
talk(game, "VIRIDIAN_CITY", "TEXT_VIRIDIANCITY_GAMBLER1")
|
||||
T.check(game.save.flags.MOD_EXAMPLE_LOST_PARCEL_STARTED, "accepting sets the started flag")
|
||||
T.eq(game.save.modData.example_lost_parcel.asked_count, 0,
|
||||
"set_field mod: wrote into the mod's own save namespace")
|
||||
|
||||
-- The override wins talk dispatch outright, so the branches the quest does
|
||||
-- not own owe the player the whole base conversation -- which for this NPC
|
||||
-- is an opening line, a YES/NO prompt and one of two follow-ups, not a
|
||||
-- single line. Both answers are driven, before the quest starts and again
|
||||
-- once the parcel is gone.
|
||||
local NERD_INTRO = Data.text._PewterCitySuperNerd1DidYouCheckOutMuseumText
|
||||
local NERD_YES = Data.text._PewterCitySuperNerd1WerentThoseFossilsAmazingText
|
||||
local NERD_NO = Data.text._PewterCitySuperNerd1YouHaveToGoText
|
||||
T.check(NERD_INTRO and NERD_YES and NERD_NO,
|
||||
"the base super nerd conversation is in the generated text")
|
||||
|
||||
local function readsVanillaNerd(when, flags)
|
||||
for _, answer in ipairs({ 1, 2 }) do
|
||||
local plain = newGame()
|
||||
for name, value in pairs(flags or {}) do plain.save.flags[name] = value end
|
||||
choice = answer
|
||||
talk(plain, "PEWTER_CITY", "TEXT_PEWTERCITY_SUPER_NERD1")
|
||||
T.check((plain.save.inventory.EXAMPLE_LOST_PARCEL_PARCEL or 0) == 0,
|
||||
when .. ": the base branch never hands out the parcel")
|
||||
T.eq(plain.asked, 1, when .. ": the vanilla YES/NO prompt still comes up")
|
||||
T.eq(#plain.shown, 2, when .. ": the opening line and a follow-up both play")
|
||||
T.eq(plain.shown[1], firstLine(NERD_INTRO), when .. ": the vanilla opening line")
|
||||
T.eq(plain.shown[2], firstLine(answer == 1 and NERD_YES or NERD_NO),
|
||||
when .. ": the follow-up answers the choice the player made")
|
||||
end
|
||||
end
|
||||
|
||||
readsVanillaNerd("before the quest")
|
||||
readsVanillaNerd("after the parcel is taken", {
|
||||
MOD_EXAMPLE_LOST_PARCEL_STARTED = true,
|
||||
MOD_EXAMPLE_LOST_PARCEL_TAKEN = true,
|
||||
})
|
||||
choice = 1
|
||||
|
||||
talk(game, "PEWTER_CITY", "TEXT_PEWTERCITY_SUPER_NERD1")
|
||||
T.check(game.save.flags.MOD_EXAMPLE_LOST_PARCEL_TAKEN, "the parcel is taken")
|
||||
T.check((game.save.inventory.EXAMPLE_LOST_PARCEL_PARCEL or 0) > 0,
|
||||
"the parcel is in the bag")
|
||||
|
||||
talk(game, "VIRIDIAN_CITY", "TEXT_VIRIDIANCITY_GAMBLER1")
|
||||
T.check(game.save.flags.MOD_EXAMPLE_LOST_PARCEL_DONE, "the quest completes")
|
||||
T.check((game.save.inventory.EXAMPLE_LOST_PARCEL_PARCEL or 0) == 0,
|
||||
"the parcel is consumed")
|
||||
T.check((game.save.inventory.NUGGET or 0) > 0, "the NUGGET reward is paid")
|
||||
T.eq(game.save.modData.example_lost_parcel.asked_count, 1,
|
||||
"the mod's own verb counted the one pending visit")
|
||||
|
||||
-- the ambient script is background-legal: no foreground verb in it
|
||||
local rows = MapScripts.namedScript("PEWTER_CITY", "example_nerd_pace")
|
||||
T.check(rows ~= nil, "the parallel ambient script is registered")
|
||||
|
||||
run.release()
|
||||
T.finish("example_lost_parcel")
|
||||
@@ -0,0 +1,13 @@
|
||||
# Changelog
|
||||
|
||||
Format: [keep a changelog](https://keepachangelog.com/en/1.1.0/).
|
||||
Version headings match `manifest.json`'s `version`.
|
||||
|
||||
## 1.0.0
|
||||
|
||||
### Added
|
||||
|
||||
- Three original species with cries and icons, and their art generator.
|
||||
- The `SABLE_COVE` map and encounter table.
|
||||
- `SABLE_TIDE_BADGE` and the one-badge constants override.
|
||||
- The `SableTitle` screen and the `field.boot` override that reaches it.
|
||||
@@ -0,0 +1,103 @@
|
||||
# Sable Cove (Mini Conversion)
|
||||
|
||||
The smallest thing that is recognizably a *different game*: its own title
|
||||
screen, its own starting town, its own three-species dex, its own single
|
||||
badge — running on the same engine, with the same import.
|
||||
|
||||
**Persona: the Total-Conversion Team.** This is the capstone skeleton. It
|
||||
is deliberately incomplete as a game and deliberately complete as a
|
||||
demonstration of which seams a conversion owns.
|
||||
|
||||
## Legal callout (read this first)
|
||||
|
||||
A total conversion on this engine is a **recipe, not a redistribution**.
|
||||
|
||||
- The Red import still runs. It supplies the fallback infrastructure this
|
||||
conversion sits on: the `OVERWORLD` tileset, the font, the move table,
|
||||
the type chart. That data lives on the player's machine, decoded from
|
||||
the player's own ROM.
|
||||
- The conversion overrides on top. Everything under `assets/` here is
|
||||
original work, plotted pixel by pixel by
|
||||
`tools/make_assets.py` — run it yourself and diff the output.
|
||||
- It never ships extracted content, and it never launders extracted
|
||||
content into "new" species by transforming Red sprites. If your
|
||||
conversion wants to *derive* art from the player's cache, that is what
|
||||
`assets_transforms` is for — see
|
||||
`mods/examples/example_shiny_palette/` for the worked pattern.
|
||||
|
||||
## Try it
|
||||
|
||||
```sh
|
||||
python3 tools/modkit.py validate mods/examples/example_mini_conversion --base imported
|
||||
python3 tools/modkit.py lint mods/examples/example_mini_conversion
|
||||
luajit mods/examples/example_mini_conversion/tests/example_mini_conversion_test.lua
|
||||
|
||||
# regenerate the original art from its shape tables
|
||||
python3 mods/examples/example_mini_conversion/tools/make_assets.py
|
||||
```
|
||||
|
||||
Enable it (`example_mini_conversion = true` under `mods` in `options.lua`,
|
||||
or the F10 manager) and start the game. You land on the SABLE COVE title
|
||||
screen; NEW GAME spawns you in Sable Cove with 1500 money as SABLE.
|
||||
|
||||
## What it demonstrates
|
||||
|
||||
| Seam | Where |
|
||||
|---|---|
|
||||
| `profile = "total_conversion"` | `manifest.json` — implies `affects_link` |
|
||||
| `content.field:patch("boot", …)` | `main.lua` — spawn, names, money, boot screens |
|
||||
| `content.constants:patch` / `:override` | `main.lua` — dex size, level cap, badge list |
|
||||
| `content.pokemon:register` | `main.lua` — three species with full records |
|
||||
| `content.cries:register` (ChipAsm) | `main.lua` — one authored effect per species |
|
||||
| `content.icons:register` | `main.lua` — party icons keyed by species id |
|
||||
| `content.maps:register` | `main.lua` — one map on the imported tileset |
|
||||
| `content.encounters:register` | `main.lua` — its wild table |
|
||||
| `content.items:register` | `main.lua` — the badge, which is an item |
|
||||
| `content.screens:register` | `main.lua` — the title screen the boot config names |
|
||||
| `events:on("game.ready")` | `main.lua` — checking the boot merge actually took |
|
||||
|
||||
## patch vs override on a deep registry
|
||||
|
||||
`constants` and `field` are **deep** registries. Two rules differ from the
|
||||
record registries, and both bite a conversion:
|
||||
|
||||
1. `register` and `patch` are the same verb. A partial payload is the
|
||||
normal case; only the keys you name move.
|
||||
2. **Lists append.** That is deliberate — two mods each adding a badge both
|
||||
land. But a conversion wants to *replace* the badge list, and appending
|
||||
would leave Kanto's eight in front of its one:
|
||||
|
||||
```lua
|
||||
mod.content.constants:patch("badges", { … }) -- 8 + 1 = 9 badges
|
||||
mod.content.constants:override("badges", { … }) -- 1 badge
|
||||
```
|
||||
|
||||
`override` is the verb that drops a list. The test asserts both behaviors.
|
||||
|
||||
`field.boot` is the opposite case: `patch` is right there, because the keys
|
||||
this conversion does not name (`startFacing`, the `splash` and `newGame`
|
||||
screen ids) should keep the engine's values.
|
||||
|
||||
## Priority
|
||||
|
||||
`"priority": 900`. A conversion wants to merge *after* content mods so its
|
||||
`field.boot` and `constants` win. If another mod still beats it, the
|
||||
`game.ready` listener says so by name instead of leaving the player on a
|
||||
map they did not expect.
|
||||
|
||||
## What a real conversion adds next
|
||||
|
||||
This skeleton stops at the boundary of the mechanism demonstration. A
|
||||
shipping conversion continues with:
|
||||
|
||||
- `map_scripts` for the story (see `mods/examples/example_lost_parcel/`)
|
||||
- `maps:remove` / `pokemon:remove` tombstones to hide Kanto content
|
||||
- `content.text` and `text_pointers` for its own dialogue
|
||||
- `content.music` for its soundtrack (see `mods/examples/example_jukebox/`)
|
||||
- `link_fields` and an honest `affects_link` so its players do not corrupt
|
||||
each other's saves in a trade
|
||||
|
||||
## Credits
|
||||
|
||||
- All original sprite art: this project (`tools/make_assets.py`).
|
||||
- pret/pokered: the `OVERWORLD` tileset, font and move table this builds on.
|
||||
|
After Width: | Height: | Size: 191 B |
|
After Width: | Height: | Size: 262 B |
|
After Width: | Height: | Size: 161 B |
|
After Width: | Height: | Size: 207 B |
|
After Width: | Height: | Size: 288 B |
|
After Width: | Height: | Size: 174 B |
|
After Width: | Height: | Size: 240 B |
|
After Width: | Height: | Size: 326 B |
|
After Width: | Height: | Size: 190 B |
@@ -0,0 +1,180 @@
|
||||
-- Gallery #7 (Total-conversion team): the smallest thing that is
|
||||
-- recognizably a different game. Its own boot config, its own title
|
||||
-- screen, its own three-species dex, its own single badge, and one map.
|
||||
--
|
||||
-- LEGAL POSTURE (constraint 1): the Red import still runs and supplies the
|
||||
-- fallback infrastructure this conversion sits on -- the OVERWORLD tileset,
|
||||
-- the font, the move table. The conversion overrides on top. Every pixel
|
||||
-- under assets/ is original work generated by tools/make_assets.py; nothing
|
||||
-- here transforms extracted Red art into "new" content.
|
||||
local MAP = "SABLE_COVE"
|
||||
local BADGE = "SABLE_TIDE_BADGE"
|
||||
|
||||
-- three original species; ids are namespaced so a mixed load cannot
|
||||
-- collide with Red's
|
||||
local DEX = {
|
||||
{ id = "SABLE_EMBERKIT", name = "EMBERKIT", dex = 1, art = "emberkit",
|
||||
types = { "FIRE" }, stats = { hp = 45, attack = 60, defense = 40,
|
||||
speed = 65, special = 50 } },
|
||||
{ id = "SABLE_TIDEPUP", name = "TIDEPUP", dex = 2, art = "tidepup",
|
||||
types = { "WATER" }, stats = { hp = 50, attack = 48, defense = 60,
|
||||
speed = 45, special = 55 } },
|
||||
{ id = "SABLE_MOSSLING", name = "MOSSLING", dex = 3, art = "mossling",
|
||||
types = { "GRASS", "POISON" }, stats = { hp = 55, attack = 50,
|
||||
defense = 55, speed = 40,
|
||||
special = 60 } },
|
||||
}
|
||||
|
||||
return function(mod)
|
||||
local ChipAsm = require("src.audio.ChipAsm")
|
||||
|
||||
-- ------- species, cries and icons
|
||||
|
||||
for _, entry in ipairs(DEX) do
|
||||
mod.content.pokemon:register(entry.id, {
|
||||
id = entry.id,
|
||||
name = entry.name,
|
||||
dex = entry.dex,
|
||||
types = entry.types,
|
||||
baseStats = entry.stats,
|
||||
catchRate = 190,
|
||||
baseExp = 64,
|
||||
growthRate = "MEDIUM_FAST",
|
||||
level1Moves = { "TACKLE" },
|
||||
learnset = {
|
||||
{ level = 7, move = "GROWL" },
|
||||
{ level = 13, move = "QUICK_ATTACK" },
|
||||
},
|
||||
evolutions = {},
|
||||
spriteFront = mod.path .. "/assets/" .. entry.art .. "_front.png",
|
||||
spriteBack = mod.path .. "/assets/" .. entry.art .. "_back.png",
|
||||
frontSize = 5,
|
||||
cry = entry.id,
|
||||
icon = { image = mod.path .. "/assets/" .. entry.art .. "_icon.png",
|
||||
frames = 2 },
|
||||
})
|
||||
|
||||
-- one authored chip effect per species, keyed by species id exactly
|
||||
-- like the vanilla cry table
|
||||
mod.content.cries:register(entry.id, {
|
||||
chip = ChipAsm.sfx{
|
||||
channels = { { hw = 1, program = {
|
||||
{ pitchSweep = { pace = 2 + entry.dex, subtract = entry.dex == 2,
|
||||
shift = 3 } },
|
||||
{ squareNote = { len = 5, volume = 13, fade = 2,
|
||||
frequency = 0x480 + entry.dex * 0x60 } },
|
||||
} } },
|
||||
}.chip,
|
||||
pitch = 128, length = 128,
|
||||
})
|
||||
|
||||
mod.content.icons:register(entry.id, {
|
||||
image = mod.path .. "/assets/" .. entry.art .. "_icon.png",
|
||||
frames = 2,
|
||||
})
|
||||
end
|
||||
|
||||
-- ------- the badge, which is an item like every vanilla badge
|
||||
|
||||
mod.content.items:register(BADGE, {
|
||||
id = BADGE, name = "TIDEBADGE", price = 0, keyItem = true, tossable = false,
|
||||
})
|
||||
|
||||
-- ------- the rules the engine used to hard-code
|
||||
-- deep registry: register and patch are the same verb, and only the keys
|
||||
-- named here move. Everything else keeps its imported value.
|
||||
|
||||
mod.content.constants:patch("dexSize", #DEX)
|
||||
mod.content.constants:patch("dexDigits", 1)
|
||||
mod.content.constants:patch("levelCap", 50)
|
||||
-- override, not patch: under deep semantics a list APPENDS, so patching
|
||||
-- here would leave Kanto's eight badges in front of this one. override
|
||||
-- is the verb that drops a list, which is exactly what a conversion wants
|
||||
mod.content.constants:override("badges", { { id = BADGE, name = "TIDE" } })
|
||||
mod.content.constants:override("hmMoves", { "CUT", "SURF" })
|
||||
|
||||
-- ------- the one map
|
||||
|
||||
local blocks = {}
|
||||
for i = 1, 10 * 9 do blocks[i] = 1 end
|
||||
mod.content.maps:register(MAP, {
|
||||
id = MAP,
|
||||
label = "SableCove",
|
||||
index = 1000,
|
||||
tileset = "OVERWORLD",
|
||||
width = 10, height = 9,
|
||||
blocks = blocks,
|
||||
borderBlock = 11,
|
||||
warps = {}, objects = {}, signs = {},
|
||||
})
|
||||
|
||||
mod.content.encounters:register(MAP, {
|
||||
grass = { rate = 25, slots = {
|
||||
{ level = 3, species = "SABLE_EMBERKIT" },
|
||||
{ level = 3, species = "SABLE_TIDEPUP" },
|
||||
{ level = 3, species = "SABLE_MOSSLING" },
|
||||
{ level = 4, species = "SABLE_TIDEPUP" },
|
||||
{ level = 4, species = "SABLE_MOSSLING" },
|
||||
{ level = 5, species = "SABLE_EMBERKIT" },
|
||||
{ level = 5, species = "SABLE_TIDEPUP" },
|
||||
{ level = 5, species = "SABLE_MOSSLING" },
|
||||
{ level = 6, species = "SABLE_EMBERKIT" },
|
||||
{ level = 6, species = "SABLE_MOSSLING" },
|
||||
} },
|
||||
})
|
||||
|
||||
-- ------- the new game itself
|
||||
|
||||
mod.content.field:patch("boot", {
|
||||
startMap = MAP, startX = 5, startY = 4, startFacing = "down",
|
||||
playerName = "SABLE", rivalName = "CORAL",
|
||||
startMoney = 1500,
|
||||
lastHeal = { map = MAP, x = 5, y = 4 },
|
||||
namePresets = { player = { "SABLE", "WREN", "PIKE" },
|
||||
rival = { "CORAL", "REEF", "SHOAL" } },
|
||||
-- the conversion owns the boot flow; splash and newGame keep the
|
||||
-- engine screens, which is the point of naming them individually
|
||||
screens = { title = "SableTitle" },
|
||||
})
|
||||
|
||||
-- ------- the title screen
|
||||
|
||||
mod.content.screens:register("SableTitle", {
|
||||
new = function(game, opts)
|
||||
opts = opts or {}
|
||||
local Font = mod.ui.Font
|
||||
local state = { game = game, isOpaque = true, blink = 0 }
|
||||
|
||||
function state:update()
|
||||
self.blink = (self.blink + 1) % 60
|
||||
local input = game.input
|
||||
if not input then return end
|
||||
if input:wasPressed("a") or input:wasPressed("start") then
|
||||
if opts.onNewGame then opts.onNewGame() end
|
||||
end
|
||||
end
|
||||
|
||||
function state:draw()
|
||||
love.graphics.setColor(1, 1, 1, 1)
|
||||
love.graphics.rectangle("fill", 0, 0, 160, 144)
|
||||
love.graphics.setColor(0, 0, 0, 1)
|
||||
Font.draw("SABLE COVE", 40, 40)
|
||||
Font.draw("A MINI CONVERSION", 12, 56)
|
||||
if self.blink < 40 then Font.draw("PRESS A", 52, 104) end
|
||||
end
|
||||
|
||||
return state
|
||||
end,
|
||||
})
|
||||
|
||||
mod.events:on("game.ready", function(ev)
|
||||
-- the payload carries the live Game; a conversion uses it to check
|
||||
-- that its own boot config actually took
|
||||
local boot = ev.game and ev.game.data and ev.game.data.field
|
||||
and ev.game.data.field.boot
|
||||
if not (boot and boot.startMap == MAP) then
|
||||
mod.log:warn("another mod owns field.boot; raise this mod's priority "
|
||||
.. "above %s to win the merge", tostring(boot and boot.startMap))
|
||||
end
|
||||
end)
|
||||
end
|
||||
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"id": "example_mini_conversion",
|
||||
"name": "Sable Cove (Mini Conversion)",
|
||||
"version": "1.0.0",
|
||||
"api": 2,
|
||||
"entry": "main.lua",
|
||||
"profile": "total_conversion",
|
||||
"category": "TOTAL_CONVERSION",
|
||||
"game_version": ">=1.0.0 <2.0.0",
|
||||
"priority": 900,
|
||||
"dependencies": [],
|
||||
"optional_dependencies": [],
|
||||
"conflicts": [],
|
||||
"description": "Total-conversion gallery entry: a one-town game with its own boot, title, three-species dex and one badge."
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
-- Sharing metadata (25-community-and-ecosystem.md 3.2). Read by tooling
|
||||
-- and the manager detail pane; never by the loader's merge.
|
||||
return {
|
||||
summary = "Sable Cove: one town, three species, one badge. The smallest whole conversion.",
|
||||
author = "Pokemon Gen 1 Recompilation Project",
|
||||
contact = "https://github.com/bryanthaboi/pokemon-gen1-recomp-project",
|
||||
tags = { "total-conversion", "capstone" },
|
||||
differences = {
|
||||
changed = {
|
||||
"field.boot spawns on SABLE_COVE as SABLE, rival CORAL, 1500 money",
|
||||
"constants.dexSize 151 -> 3, dexDigits 3 -> 1, levelCap 100 -> 50",
|
||||
"constants.badges replaced with one TIDEBADGE",
|
||||
"constants.hmMoves replaced with CUT and SURF",
|
||||
"the title screen is SableTitle",
|
||||
},
|
||||
added = {
|
||||
"SABLE_EMBERKIT, SABLE_TIDEPUP and SABLE_MOSSLING with cries and icons",
|
||||
"SABLE_TIDE_BADGE key item",
|
||||
"the SABLE_COVE map and its encounter table",
|
||||
},
|
||||
known = {
|
||||
"no gym, no story and no warps yet: this is the skeleton the TC guide elaborates",
|
||||
"Red's maps and species stay merged and reachable; a real conversion removes them",
|
||||
},
|
||||
},
|
||||
credits = {
|
||||
{ who = "Pokemon Gen 1 Recompilation Project", for_ = "all original sprite art under assets/" },
|
||||
{ who = "pret/pokered", for_ = "the OVERWORLD tileset, font and move table this builds on" },
|
||||
},
|
||||
compat = { engine = ">=1.0.0 <2.0.0", modApi = 2 },
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
-- Standalone: luajit mods/examples/example_mini_conversion/tests/example_mini_conversion_test.lua
|
||||
-- Asserts the conversion owns the boot flow, the dex and the badge list.
|
||||
package.path = "./?.lua;./?/init.lua;" .. package.path
|
||||
|
||||
local T = require("tests.modkit")
|
||||
local Data = require("src.core.Data")
|
||||
Data:load()
|
||||
|
||||
local run = T.sdk.loadMod("mods/examples/example_mini_conversion", { data = Data })
|
||||
T.eq(#run.errors, 0, "loads clean (" .. tostring(run.errors[1]) .. ")")
|
||||
T.eq(run.mod and run.mod.manifest.profile, "total_conversion",
|
||||
"the manifest declares the total_conversion profile")
|
||||
|
||||
-- ------- boot: the new game the conversion starts
|
||||
|
||||
local boot = Data.field.boot
|
||||
T.eq(boot.startMap, "SABLE_COVE", "boot spawns on the conversion's own map")
|
||||
T.eq(boot.playerName, "SABLE", "boot renames the player")
|
||||
T.eq(boot.screens.title, "SableTitle", "the conversion owns the title screen")
|
||||
-- patch is a merge: the keys the conversion did not name keep their values
|
||||
T.eq(boot.startFacing, "down", "an unnamed boot key survives the patch")
|
||||
|
||||
-- ------- the dex
|
||||
|
||||
T.eq(Data.constants.dexSize, 3, "the dex shrinks to three species")
|
||||
T.eq(#Data.constants.badges, 1, "one badge replaces the eight")
|
||||
T.eq(Data.constants.badges[1].id, "SABLE_TIDE_BADGE", "the badge is the mod's item")
|
||||
T.check(Data.items.SABLE_TIDE_BADGE ~= nil, "the badge item merged")
|
||||
-- constants is a deep registry, so untouched keys keep their imported value
|
||||
T.eq(Data.constants.partyMax, 6, "an unpatched constant is unchanged")
|
||||
-- and a deep list REPLACES only because the mod said override
|
||||
T.eq(#Data.constants.hmMoves, 2, "override drops a deep list instead of appending")
|
||||
|
||||
for _, id in ipairs({ "SABLE_EMBERKIT", "SABLE_TIDEPUP", "SABLE_MOSSLING" }) do
|
||||
local mon = Data.pokemon[id]
|
||||
T.check(mon ~= nil, id .. " merged into the species table")
|
||||
T.check(Data.audio.cries[id] ~= nil, id .. " has a cry")
|
||||
T.check(Data.icons.bySpecies[id] ~= nil, id .. " has an icon")
|
||||
-- the art it points at really exists, not a path into the void. Read it
|
||||
-- back through the loader's own filesystem: mod.path is whatever the
|
||||
-- loader mounted the mod at, which is not the repo-relative directory
|
||||
for _, path in ipairs({ mon.spriteFront, mon.spriteBack }) do
|
||||
T.check(run.loader.fs.getInfo(path) ~= nil,
|
||||
"sprite exists: " .. tostring(path))
|
||||
end
|
||||
end
|
||||
|
||||
-- ------- the map and its encounters
|
||||
|
||||
local map = Data.maps.SABLE_COVE
|
||||
T.check(map ~= nil, "the conversion's map merged")
|
||||
T.eq(#map.blocks, map.width * map.height, "the block array matches the map size")
|
||||
T.eq(#Data.encounters.SABLE_COVE.grass.slots, 10, "the map has a full slot table")
|
||||
|
||||
-- ------- the title screen resolves through the registry
|
||||
|
||||
local Screens = require("src.ui.Screens")
|
||||
Screens.invalidate()
|
||||
local factory = Screens.get({ data = Data }, "SableTitle")
|
||||
T.check(factory and factory.new, "SableTitle resolves through the screens registry")
|
||||
local reached = false
|
||||
local state = factory.new({ data = Data, input = {
|
||||
wasPressed = function(_, key) return key == "a" end } },
|
||||
{ onNewGame = function() reached = true end })
|
||||
state:update()
|
||||
T.check(reached, "pressing A on the title starts a new game")
|
||||
|
||||
run.release()
|
||||
Screens.invalidate()
|
||||
T.finish("example_mini_conversion")
|
||||
@@ -0,0 +1,121 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Regenerate this mod's original sprite art.
|
||||
|
||||
python3 mods/examples/example_mini_conversion/tools/make_assets.py
|
||||
|
||||
Every pixel below is plotted from the shape tables in this file, so the
|
||||
output is original work and nothing is read from the player's imported
|
||||
cache. The four colors are the Game Boy shade ramp; the renderer
|
||||
re-shades them into the active palette, so no trueColor opt-out is needed.
|
||||
|
||||
Front sheets are 40x40 (frontSize 5), backs 32x32 and icons 16x32
|
||||
(two 16x16 frames), matching what the battle and party screens expect.
|
||||
"""
|
||||
|
||||
import os
|
||||
|
||||
from PIL import Image
|
||||
|
||||
ROOT = os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "assets")
|
||||
|
||||
# lightest first; index 0 is the transparent-white background
|
||||
SHADES = [(248, 248, 248), (168, 168, 168), (88, 88, 88), (8, 8, 8)]
|
||||
|
||||
# Each species is a coarse silhouette painted from primitives: the point is
|
||||
# that these are geometric marks, not creature art traced from anything.
|
||||
SPECIES = {
|
||||
"emberkit": {"body": "triangle", "accent": 1},
|
||||
"tidepup": {"body": "diamond", "accent": 1},
|
||||
"mossling": {"body": "hex", "accent": 2},
|
||||
}
|
||||
|
||||
|
||||
def blank(w, h):
|
||||
return [[0] * w for _ in range(h)]
|
||||
|
||||
|
||||
def stroke(grid, x, y, shade):
|
||||
if 0 <= y < len(grid) and 0 <= x < len(grid[0]):
|
||||
grid[y][x] = shade
|
||||
|
||||
|
||||
def triangle(grid, cx, cy, r, shade):
|
||||
for row in range(r * 2):
|
||||
half = row // 2
|
||||
for x in range(cx - half, cx + half + 1):
|
||||
stroke(grid, x, cy - r + row, shade)
|
||||
|
||||
|
||||
def diamond(grid, cx, cy, r, shade):
|
||||
for dy in range(-r, r + 1):
|
||||
span = r - abs(dy)
|
||||
for dx in range(-span, span + 1):
|
||||
stroke(grid, cx + dx, cy + dy, shade)
|
||||
|
||||
|
||||
def hexagon(grid, cx, cy, r, shade):
|
||||
for dy in range(-r, r + 1):
|
||||
span = r if abs(dy) <= r // 2 else r - (abs(dy) - r // 2)
|
||||
for dx in range(-span, span + 1):
|
||||
stroke(grid, cx + dx, cy + dy, shade)
|
||||
|
||||
|
||||
SHAPES = {"triangle": triangle, "diamond": diamond, "hex": hexagon}
|
||||
|
||||
|
||||
def outline(grid, shade):
|
||||
"""Darken every lit pixel that touches an unlit one."""
|
||||
h, w = len(grid), len(grid[0])
|
||||
edges = []
|
||||
for y in range(h):
|
||||
for x in range(w):
|
||||
if not grid[y][x]:
|
||||
continue
|
||||
for dx, dy in ((1, 0), (-1, 0), (0, 1), (0, -1)):
|
||||
nx, ny = x + dx, y + dy
|
||||
if not (0 <= nx < w and 0 <= ny < h) or not grid[ny][nx]:
|
||||
edges.append((x, y))
|
||||
break
|
||||
for x, y in edges:
|
||||
grid[y][x] = shade
|
||||
|
||||
|
||||
def save(grid, path):
|
||||
h, w = len(grid), len(grid[0])
|
||||
img = Image.new("RGBA", (w, h))
|
||||
img.putdata([SHADES[grid[y][x]] + (255,)
|
||||
for y in range(h) for x in range(w)])
|
||||
os.makedirs(os.path.dirname(path), exist_ok=True)
|
||||
img.save(path)
|
||||
print("wrote", os.path.relpath(path))
|
||||
|
||||
|
||||
def build(name, spec):
|
||||
shape = SHAPES[spec["body"]]
|
||||
accent = spec["accent"]
|
||||
|
||||
front = blank(40, 40)
|
||||
shape(front, 20, 22, 13, accent)
|
||||
shape(front, 20, 12, 5, 2)
|
||||
for x in (16, 24):
|
||||
stroke(front, x, 11, 3)
|
||||
stroke(front, x, 12, 3)
|
||||
outline(front, 3)
|
||||
save(front, os.path.join(ROOT, name + "_front.png"))
|
||||
|
||||
back = blank(32, 32)
|
||||
shape(back, 16, 20, 11, accent)
|
||||
outline(back, 3)
|
||||
save(back, os.path.join(ROOT, name + "_back.png"))
|
||||
|
||||
# two 16x16 frames stacked: the party-menu bob
|
||||
icon = blank(16, 32)
|
||||
for frame, lift in enumerate((0, 1)):
|
||||
shape(icon, 8, 9 + frame * 16 - lift, 5, accent)
|
||||
outline(icon, 3)
|
||||
save(icon, os.path.join(ROOT, name + "_icon.png"))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
for name, spec in sorted(SPECIES.items()):
|
||||
build(name, spec)
|
||||
@@ -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
|
||||
|
||||
- `transforms.lua` recolor of the player overworld sheets.
|
||||
- `EXAMPLE_SHINY` palette record and a `PALLET` town palette override.
|
||||
- `trueColor` opt-out patches for `SPRITE_RED` and `SPRITE_RED_BIKE`.
|
||||
@@ -0,0 +1,78 @@
|
||||
# Shiny Palette Example
|
||||
|
||||
Recolors the player's overworld sheets to teal and repaints Pallet Town —
|
||||
and ships no ROM-derived pixels to do it.
|
||||
|
||||
**Persona: the Artist.** This is the canonical answer to "how do I ship a
|
||||
recolor legally": you ship the *transform*, not the image.
|
||||
|
||||
## Try it
|
||||
|
||||
```sh
|
||||
python3 tools/modkit.py validate mods/examples/example_shiny_palette --base imported
|
||||
python3 tools/modkit.py lint mods/examples/example_shiny_palette
|
||||
luajit mods/examples/example_shiny_palette/tests/example_shiny_palette_test.lua
|
||||
```
|
||||
|
||||
Enable it (`example_shiny_palette = true` under `mods` in `options.lua`, or
|
||||
the F10 manager) and start the game. On first load the transform runs once
|
||||
and writes `save/mod-derived/example_shiny_palette/sprites/*.png`. Delete
|
||||
that directory to force a re-run.
|
||||
|
||||
## What it demonstrates
|
||||
|
||||
| Seam | Where |
|
||||
|---|---|
|
||||
| `assets_transforms` | `manifest.json` + `transforms.lua` — the recipe that derives art |
|
||||
| `content.palettes:register` | `main.lua` — the v2 named-record palette shape |
|
||||
| `content.palettes:override` | `main.lua` — the vanilla four-triple shape |
|
||||
| `content.sprites:patch` | `main.lua` — `trueColor` opt-out, nothing else touched |
|
||||
| `events:on("assets.transformed")` | `main.lua` — the empty-state warning |
|
||||
|
||||
## The legal pattern
|
||||
|
||||
`transforms.lua` runs inside a restricted context with exactly two
|
||||
filesystem roots: read `assets/generated/**` (the player's own imported
|
||||
cache) and write `save/mod-derived/example_shiny_palette/**`. There is no
|
||||
`require`, no `love`, no `io`, no `os`. The only way data leaves the
|
||||
sandbox is the `ctx` table.
|
||||
|
||||
Because the derived file keeps the *same relative name* as the cache file
|
||||
it came from, the asset resolver finds it automatically:
|
||||
|
||||
```
|
||||
assets/generated/sprites/red.png <- the player's import
|
||||
save/mod-derived/example_shiny_palette/sprites/red.png <- this mod's recolor
|
||||
```
|
||||
|
||||
Every consumer of the first path transparently gets the second. No
|
||||
`sprites:override`, no path string in `main.lua` — and `modkit lint` can
|
||||
prove the repo carries no cache-derived bytes, because it carries no bytes
|
||||
at all.
|
||||
|
||||
The one thing that *does* need a registry entry is the 4-shade contract.
|
||||
The renderer normally re-shades an overworld sheet into the current
|
||||
palette's four grays, which would throw the teal away. `trueColor = true`
|
||||
opts out:
|
||||
|
||||
```lua
|
||||
mod.content.sprites:patch("SPRITE_RED", { trueColor = true })
|
||||
```
|
||||
|
||||
`patch`, not `override`: `image`, `frames` and `walker` stay whatever the
|
||||
merged view already holds, so the derived sheet keeps supplying the pixels.
|
||||
|
||||
## Empty state
|
||||
|
||||
No ROM imported yet? `ctx.exists(rel)` is false, the transform writes
|
||||
nothing, the mod still loads, and `main.lua` logs a remediation line naming
|
||||
the directory to delete once you have imported. It never errors.
|
||||
|
||||
## Original assets
|
||||
|
||||
`assets/accent_sparkle.png` is a 16x16 four-shade sparkle drawn for this
|
||||
example. It is the only image in the directory and it is original work.
|
||||
|
||||
## Credits
|
||||
|
||||
- pret/pokered — the overworld sheet layout the transform recolors.
|
||||
|
After Width: | Height: | Size: 224 B |
@@ -0,0 +1,37 @@
|
||||
-- Gallery #2 (Artist): a recolor that ships no pixels. transforms.lua
|
||||
-- derives the sheets from the player's own cache; this file only declares
|
||||
-- the palette records and the one flag the recolor needs.
|
||||
return function(mod)
|
||||
-- v2 record shape: a named table of four colors, lightest first
|
||||
mod.content.palettes:register("EXAMPLE_SHINY", {
|
||||
colors = {
|
||||
{ r = 248, g = 248, b = 248 },
|
||||
{ r = 120, g = 224, b = 216 },
|
||||
{ r = 32, g = 128, b = 152 },
|
||||
{ r = 8, g = 32, b = 64 },
|
||||
},
|
||||
})
|
||||
|
||||
-- vanilla raw shape: four {r,g,b} triples. Overriding a town palette is
|
||||
-- the smallest visible artist change there is -- no assets involved.
|
||||
mod.content.palettes:override("PALLET", {
|
||||
{ 248, 248, 248 }, { 152, 232, 224 }, { 64, 152, 168 }, { 8, 32, 64 },
|
||||
})
|
||||
|
||||
-- trueColor opts SPRITE_RED out of the 4-shade re-shade so the teal the
|
||||
-- transform baked in survives to the screen. patch, not override: the
|
||||
-- image path and frame count stay whatever the merged view already has,
|
||||
-- which is how the derived sheet keeps supplying the pixels.
|
||||
mod.content.sprites:patch("SPRITE_RED", { trueColor = true })
|
||||
mod.content.sprites:patch("SPRITE_RED_BIKE", { trueColor = true })
|
||||
|
||||
mod.events:on("assets.transformed", function(ev)
|
||||
if ev.modId ~= mod.id then return end
|
||||
if ev.count == 0 then
|
||||
mod.log:warn("no sheets derived -- import your ROM first, then "
|
||||
.. "delete save/mod-derived/%s to re-run the transform", mod.id)
|
||||
else
|
||||
mod.log:info("derived %d recolored sheets", ev.count)
|
||||
end
|
||||
end)
|
||||
end
|
||||
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"id": "example_shiny_palette",
|
||||
"name": "Shiny Palette Example",
|
||||
"version": "1.0.0",
|
||||
"api": 2,
|
||||
"entry": "main.lua",
|
||||
"profile": "content",
|
||||
"category": "GRAPHICS",
|
||||
"game_version": ">=1.0.0 <2.0.0",
|
||||
"priority": 100,
|
||||
"assets_transforms": "transforms.lua",
|
||||
"dependencies": [],
|
||||
"optional_dependencies": [],
|
||||
"conflicts": [],
|
||||
"description": "Artist gallery entry: a recolored player sheet derived from the player's own cache, plus two palette records."
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
-- Sharing metadata (25-community-and-ecosystem.md 3.2). Read by tooling
|
||||
-- and the manager detail pane; never by the loader's merge.
|
||||
return {
|
||||
summary = "A teal player recolor derived from your own cache, plus two palette records.",
|
||||
author = "Pokemon Gen 1 Recompilation Project",
|
||||
contact = "https://github.com/bryanthaboi/pokemon-gen1-recomp-project",
|
||||
tags = { "cosmetic", "graphics", "beginner" },
|
||||
screenshots = {
|
||||
{ transform = "shots/pallet_town.lua", caption = "Pallet Town under the recolored palette" },
|
||||
},
|
||||
differences = {
|
||||
changed = {
|
||||
"SPRITE_RED and SPRITE_RED_BIKE opt into trueColor",
|
||||
"the PALLET town palette is recolored",
|
||||
},
|
||||
added = { "EXAMPLE_SHINY palette record" },
|
||||
known = {
|
||||
"the derived sheets only appear after a ROM import; without a cache "
|
||||
.. "the transform writes nothing and the vanilla sheets keep rendering",
|
||||
},
|
||||
},
|
||||
credits = {
|
||||
{ who = "pret/pokered", for_ = "the overworld sheet layout the transform recolors" },
|
||||
},
|
||||
compat = { engine = ">=1.0.0 <2.0.0", modApi = 2 },
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
-- Standalone: luajit mods/examples/example_shiny_palette/tests/example_shiny_palette_test.lua
|
||||
-- Asserts the palette records merge and the transform degrades cleanly
|
||||
-- when there is no imported cache to read.
|
||||
package.path = "./?.lua;./?/init.lua;" .. package.path
|
||||
|
||||
local T = require("tests.modkit")
|
||||
local Data = require("src.core.Data")
|
||||
Data:load()
|
||||
|
||||
-- The transform reads assets/generated/** and writes save/mod-derived/**.
|
||||
-- Hiding the cache is what puts this run on the no-cache path, which is
|
||||
-- the branch a mod owes the player: write nothing, load anyway. The pixel
|
||||
-- path needs a real LOVE run (love.image is not in the headless stub).
|
||||
local MOD = "mods/examples/example_shiny_palette"
|
||||
|
||||
local function noCacheFs()
|
||||
local inner = T.fs.new(".")
|
||||
local overlay = {}
|
||||
local hidden = "assets/generated/"
|
||||
local mount = "mods/example_shiny_palette"
|
||||
|
||||
local function map(path)
|
||||
if path == mount then return MOD end
|
||||
if path and path:sub(1, #mount + 1) == mount .. "/" then
|
||||
return MOD .. path:sub(#mount + 1)
|
||||
end
|
||||
return path
|
||||
end
|
||||
|
||||
local fs = { root = inner.root }
|
||||
function fs.read(path)
|
||||
if path:sub(1, #hidden) == hidden then return nil end
|
||||
return overlay[path] or inner.read(map(path))
|
||||
end
|
||||
function fs.write(path, body) overlay[path] = body return true end
|
||||
function fs.createDirectory() return true end
|
||||
function fs.load(path) return inner.load(map(path)) end
|
||||
function fs.getInfo(path)
|
||||
if path == "mods" then return { type = "directory" } end
|
||||
if path:sub(1, #hidden) == hidden then return nil end
|
||||
if overlay[path] then return { type = "file" } end
|
||||
return inner.getInfo(map(path))
|
||||
end
|
||||
function fs.getDirectoryItems(path)
|
||||
if path == "mods" then return { "example_shiny_palette" } end
|
||||
return inner.getDirectoryItems(map(path))
|
||||
end
|
||||
return fs
|
||||
end
|
||||
|
||||
local run = T.sdk.loadMod(MOD, { data = Data, fs = noCacheFs() })
|
||||
T.eq(#run.errors, 0,
|
||||
"loads clean with no cache to transform (" .. tostring(run.errors[1]) .. ")")
|
||||
T.eq(run.mod and run.mod.manifest.assets_transforms, "transforms.lua",
|
||||
"the manifest declares its transform")
|
||||
|
||||
-- ------- palettes
|
||||
|
||||
local shiny = Data.palettes.palettes.EXAMPLE_SHINY
|
||||
T.check(shiny ~= nil, "the v2 named palette record merged")
|
||||
T.eq(#shiny.colors, 4, "it carries exactly four colors")
|
||||
T.eq(shiny.colors[1].r, 248, "the lightest shade is first")
|
||||
|
||||
local pallet = Data.palettes.palettes.PALLET
|
||||
T.eq(#pallet, 4, "the town palette override kept the raw four-triple shape")
|
||||
T.eq(pallet[2][2], 232, "the override took")
|
||||
|
||||
-- ------- the trueColor opt-out, applied by patch
|
||||
|
||||
for _, id in ipairs({ "SPRITE_RED", "SPRITE_RED_BIKE" }) do
|
||||
local sprite = Data.sprites[id]
|
||||
T.eq(sprite.trueColor, true, id .. " opted into trueColor")
|
||||
T.check(sprite.image ~= nil and sprite.image ~= "",
|
||||
id .. " kept its sheet path (patch named only the flag)")
|
||||
T.check(sprite.frames ~= nil, id .. " kept its frame count")
|
||||
end
|
||||
|
||||
-- ------- the recipe itself compiles and is a function(ctx)
|
||||
|
||||
local chunk = assert(loadfile(MOD .. "/transforms.lua"))
|
||||
local transform = chunk()
|
||||
T.check(type(transform) == "function", "transforms.lua returns a function(ctx)")
|
||||
|
||||
-- driven with an empty cache it must write nothing and not raise
|
||||
local wrote = 0
|
||||
local ok, err = pcall(transform, {
|
||||
exists = function() return false end,
|
||||
readImage = function() error("must not read without exists()", 0) end,
|
||||
writeImage = function() wrote = wrote + 1 end,
|
||||
recolor = function(img) return img end,
|
||||
})
|
||||
T.check(ok, "the recipe survives an empty cache (" .. tostring(err) .. ")")
|
||||
T.eq(wrote, 0, "and writes nothing rather than failing the mod")
|
||||
|
||||
-- with a cache present it derives one file per declared sheet
|
||||
wrote = 0
|
||||
local read = {}
|
||||
T.check(pcall(transform, {
|
||||
exists = function() return true end,
|
||||
readImage = function(rel) read[#read + 1] = rel return { rel } end,
|
||||
writeImage = function() wrote = wrote + 1 end,
|
||||
recolor = function(img) return img end,
|
||||
}), "the recipe runs over a populated cache")
|
||||
T.eq(wrote, #read, "every sheet it read, it wrote back")
|
||||
T.check(wrote >= 1, "at least one sheet is derived")
|
||||
|
||||
run.release()
|
||||
T.finish("example_shiny_palette")
|
||||
@@ -0,0 +1,32 @@
|
||||
-- Asset transform: the whole point of this example. It runs once at
|
||||
-- install inside the restricted context -- read the player's own imported
|
||||
-- cache, write under save/mod-derived/<id>/ -- so the repo ships the
|
||||
-- recipe and never a ROM-derived pixel.
|
||||
--
|
||||
-- The derived path mirrors the cache path, so Assets.resolve picks it up
|
||||
-- for every consumer of assets/generated/sprites/red.png with no registry
|
||||
-- entry at all. A recolor is exactly this: read, recolor, write back
|
||||
-- under the same relative name.
|
||||
local SHEETS = {
|
||||
"sprites/red.png",
|
||||
"sprites/red_bike.png",
|
||||
}
|
||||
|
||||
-- lightest shade first; the recolor buckets every ink pixel into one of
|
||||
-- these four by luminance, matching the importer's own 4-gray split
|
||||
local TEAL = {
|
||||
{ 248, 248, 248 },
|
||||
{ 120, 224, 216 },
|
||||
{ 32, 128, 152 },
|
||||
{ 8, 32, 64 },
|
||||
}
|
||||
|
||||
return function(ctx)
|
||||
for _, rel in ipairs(SHEETS) do
|
||||
-- a player who has not imported yet simply gets no derived art; the
|
||||
-- vanilla sheet keeps rendering and the mod stays loaded
|
||||
if ctx.exists(rel) then
|
||||
ctx.writeImage(ctx.recolor(ctx.readImage(rel), TEAL), rel)
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -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
|
||||
|
||||
- `EXAMPLE_RAIN` status record and the `example_weather_battles` ruleset.
|
||||
- A `battle.damage` wrap that scales WATER and FIRE while it rains.
|
||||
- Rain lifecycle driven by `battle.started` / `battle.turn_started` / `battle.ended`.
|
||||
@@ -0,0 +1,96 @@
|
||||
# Weather Battles Example
|
||||
|
||||
Adds rain: for the first five turns of every battle, WATER moves deal 1.5x
|
||||
damage and FIRE moves deal 0.5x. Only under the `WEATHER` ruleset — pick
|
||||
`gen1_faithful` and the mod is installed and inert.
|
||||
|
||||
**Persona: the Mechanic Designer.** A new battle mechanic, no engine fork.
|
||||
The status and ruleset registries plus one hook carry the whole thing.
|
||||
|
||||
## Try it
|
||||
|
||||
```sh
|
||||
python3 tools/modkit.py validate mods/examples/example_weather --base imported
|
||||
luajit mods/examples/example_weather/tests/example_weather_test.lua
|
||||
```
|
||||
|
||||
Enable it (`example_weather = true` under `mods` in `options.lua`, or the
|
||||
F10 manager), then **OPTIONS → RULESET → WEATHER**.
|
||||
|
||||
## What it demonstrates
|
||||
|
||||
| Seam | Where |
|
||||
|---|---|
|
||||
| `content.statuses:register` | `main.lua` — declaring a field effect |
|
||||
| `content.rulesets:register` | `main.lua` — a ruleset the OPTIONS menu lists automatically |
|
||||
| `content.rulesets:get` | `main.lua` — deriving from vanilla without requiring a private module |
|
||||
| `hooks:wrap("battle.damage")` | `main.lua` — the one behavior change |
|
||||
| `events:on("battle.started" / "battle.turn_started" / "battle.ended")` | `main.lua` — the rain counter |
|
||||
| `mod.save:get/set` | `main.lua` — per-mod state, not a global |
|
||||
|
||||
## Parity, at the mod level
|
||||
|
||||
The engine's promise is that a mod-free game is unchanged. This example
|
||||
makes the same promise one level up: a *player* who installs it but does
|
||||
not select the ruleset gets vanilla battles.
|
||||
|
||||
```lua
|
||||
if not (ctx.ruleset and ctx.ruleset.exampleWeather and raining()) then
|
||||
return next(ctx)
|
||||
end
|
||||
```
|
||||
|
||||
`next(ctx)` with the arguments it was handed *is* the vanilla call. No
|
||||
allocation, no rounding, no reordering — the same number the engine would
|
||||
have produced. Everything above that line is a gate, and every gate that
|
||||
fails defers.
|
||||
|
||||
## Preserving multiple returns
|
||||
|
||||
`Damage.compute` returns two values: the damage number and an info table
|
||||
carrying the crit flag and the type multiplier. A wrapper that returns only
|
||||
the first silently throws the second away, and the battle log stops saying
|
||||
"A critical hit!".
|
||||
|
||||
```lua
|
||||
local damage, info = next(ctx)
|
||||
if type(damage) ~= "number" then return damage, info end
|
||||
return math.max(1, math.floor(damage * scale)), info
|
||||
```
|
||||
|
||||
Hook chains preserve every return value, so passing `info` back through is
|
||||
all it takes.
|
||||
|
||||
## Deriving a ruleset from vanilla
|
||||
|
||||
A ruleset record is the *whole* rule table — `oneIn256Miss`,
|
||||
`critUsesBaseSpeed`, `randMin`, `randMax` and the rest. Registering one
|
||||
that only sets `name` would silently drop every Gen 1 quirk. So this mod
|
||||
reads the vanilla record out of the merged registry and copies it:
|
||||
|
||||
```lua
|
||||
local base = mod.content.rulesets:get("gen1_faithful")
|
||||
local weather = {}
|
||||
for key, value in pairs(base) do weather[key] = value end
|
||||
weather.name = "WEATHER"
|
||||
weather.exampleWeather = true
|
||||
```
|
||||
|
||||
`:get` on a registry is the public path to engine content. It needs no
|
||||
permission, and it composes: if another mod patched `gen1_faithful` first,
|
||||
this ruleset inherits that patch too.
|
||||
|
||||
`exampleWeather` is not in the ruleset schema. Unknown fields on a record
|
||||
registry are preserved rather than rejected — that is what makes rulesets
|
||||
extensible, and it is how the damage hook recognizes its own ruleset
|
||||
without a second lookup.
|
||||
|
||||
## Missing dependency, handled
|
||||
|
||||
If another mod removed `gen1_faithful`, `:get` returns nil. This example
|
||||
logs a remediation line and returns — the rest of the game keeps working
|
||||
and the manager shows one attributed message. No `assert`, no crash.
|
||||
|
||||
## Credits
|
||||
|
||||
- pret/pokered — the damage formula the hook scales.
|
||||
@@ -0,0 +1,86 @@
|
||||
-- Gallery #5 (Mechanic designer): a new battle mechanic with no engine
|
||||
-- fork. A rain field effect scales WATER and FIRE damage, lives behind an
|
||||
-- opt-in ruleset, and keeps its own counter in mod.save.
|
||||
--
|
||||
-- The parity lesson is the ruleset gate: install this mod, leave the
|
||||
-- ruleset on gen1_faithful, and every battle is byte-for-byte vanilla
|
||||
-- because the hook returns next(...) untouched.
|
||||
local RULESET = "example_weather_battles"
|
||||
local RAIN_TURNS = 5
|
||||
|
||||
local BOOST = { WATER = 1.5 }
|
||||
local DAMPEN = { FIRE = 0.5 }
|
||||
|
||||
return function(mod)
|
||||
-- ------- the field effect, declared as a status record
|
||||
|
||||
mod.content.statuses:register("EXAMPLE_RAIN", {
|
||||
id = "EXAMPLE_RAIN",
|
||||
label = "RAIN",
|
||||
hudLabel = "RAIN",
|
||||
-- a field effect is never inflicted on a battler; the record is the
|
||||
-- declaration the HUD and other mods read, the hook is the behavior
|
||||
canInflict = function() return false end,
|
||||
})
|
||||
|
||||
-- ------- the ruleset that turns it on
|
||||
-- Read the vanilla record out of the merged registry rather than
|
||||
-- requiring the module: same table, no engine_internals permission.
|
||||
|
||||
local base = mod.content.rulesets:get("gen1_faithful")
|
||||
if not base then
|
||||
mod.log:error("gen1_faithful missing from the rulesets registry; "
|
||||
.. "another mod removed it, so %s cannot be derived", RULESET)
|
||||
return
|
||||
end
|
||||
local weather = {}
|
||||
for key, value in pairs(base) do weather[key] = value end
|
||||
weather.name = "WEATHER"
|
||||
-- the marker the damage hook gates on; unknown fields ride through the
|
||||
-- schema untouched, which is what makes rulesets extensible
|
||||
weather.exampleWeather = true
|
||||
mod.content.rulesets:register(RULESET, weather)
|
||||
|
||||
-- ------- rain lifecycle, in this mod's own save namespace
|
||||
|
||||
local function raining()
|
||||
return (mod.save:get("turnsLeft", 0)) > 0
|
||||
end
|
||||
|
||||
mod.events:on("battle.started", function(ev)
|
||||
local ruleset = ev.battle and ev.battle.ruleset
|
||||
if ruleset and ruleset.exampleWeather then
|
||||
mod.save:set("turnsLeft", RAIN_TURNS)
|
||||
else
|
||||
mod.save:set("turnsLeft", 0)
|
||||
end
|
||||
end)
|
||||
|
||||
mod.events:on("battle.turn_started", function()
|
||||
local left = mod.save:get("turnsLeft", 0)
|
||||
if left > 0 then mod.save:set("turnsLeft", left - 1) end
|
||||
end)
|
||||
|
||||
mod.events:on("battle.ended", function()
|
||||
mod.save:set("turnsLeft", 0)
|
||||
end)
|
||||
|
||||
-- ------- the one behavior change
|
||||
|
||||
mod.hooks:wrap("battle.damage", function(next, ctx)
|
||||
-- the two gates, cheapest first: the player has to have picked the
|
||||
-- ruleset, and it has to still be raining
|
||||
if not (ctx.ruleset and ctx.ruleset.exampleWeather and raining()) then
|
||||
return next(ctx)
|
||||
end
|
||||
local moveType = ctx.move and ctx.move.type
|
||||
local scale = BOOST[moveType] or DAMPEN[moveType]
|
||||
if not scale then return next(ctx) end
|
||||
|
||||
-- Damage.compute returns (damage, info); pass the second value through
|
||||
-- untouched or the crit and type-effectiveness flags vanish
|
||||
local damage, info = next(ctx)
|
||||
if type(damage) ~= "number" then return damage, info end
|
||||
return math.max(1, math.floor(damage * scale)), info
|
||||
end)
|
||||
end
|
||||
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"id": "example_weather",
|
||||
"name": "Weather Battles Example",
|
||||
"version": "1.0.0",
|
||||
"api": 2,
|
||||
"entry": "main.lua",
|
||||
"profile": "overhaul",
|
||||
"category": "MECHANIC",
|
||||
"game_version": ">=1.0.0 <2.0.0",
|
||||
"priority": 100,
|
||||
"dependencies": [],
|
||||
"optional_dependencies": [],
|
||||
"conflicts": [],
|
||||
"description": "Mechanic-designer gallery entry: a rain field effect behind an opt-in ruleset, driven by the battle.damage hook."
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
-- Sharing metadata (25-community-and-ecosystem.md 3.2). Read by tooling
|
||||
-- and the manager detail pane; never by the loader's merge.
|
||||
return {
|
||||
summary = "Opt-in rain: WATER hits harder, FIRE hits softer, for five turns a battle.",
|
||||
author = "Pokemon Gen 1 Recompilation Project",
|
||||
contact = "https://github.com/bryanthaboi/pokemon-gen1-recomp-project",
|
||||
tags = { "battle", "mechanic", "ruleset", "hardcore" },
|
||||
differences = {
|
||||
changed = {
|
||||
"under the WEATHER ruleset only: WATER move damage x1.5 and FIRE x0.5 while it rains",
|
||||
},
|
||||
added = {
|
||||
"example_weather_battles ruleset, selectable in OPTIONS",
|
||||
"EXAMPLE_RAIN field-effect status record",
|
||||
},
|
||||
known = {
|
||||
"rain is unconditional at battle start and has no on-screen indicator yet",
|
||||
},
|
||||
},
|
||||
credits = {
|
||||
{ who = "pret/pokered", for_ = "the damage formula the hook scales" },
|
||||
},
|
||||
compat = { engine = ">=1.0.0 <2.0.0", modApi = 2 },
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
-- Standalone: luajit mods/examples/example_weather/tests/example_weather_test.lua
|
||||
-- Drives the battle.damage hook through the runtime bus, both with the
|
||||
-- ruleset on and with it off.
|
||||
package.path = "./?.lua;./?/init.lua;" .. package.path
|
||||
|
||||
local T = require("tests.modkit")
|
||||
local Runtime = require("src.mods.Runtime")
|
||||
local Data = require("src.core.Data")
|
||||
Data:load()
|
||||
|
||||
local run = T.sdk.loadMod("mods/examples/example_weather", { data = Data })
|
||||
T.eq(#run.errors, 0, "loads clean (" .. tostring(run.errors[1]) .. ")")
|
||||
|
||||
local weather = Data.rulesets.example_weather_battles
|
||||
T.check(weather ~= nil, "the ruleset merged and is selectable")
|
||||
T.eq(weather.name, "WEATHER", "the ruleset carries a display name")
|
||||
T.eq(weather.oneIn256Miss, Data.rulesets.gen1_faithful.oneIn256Miss,
|
||||
"the derived ruleset keeps every gen1_faithful rule")
|
||||
T.check(Data.statuses.EXAMPLE_RAIN ~= nil, "the rain status record merged")
|
||||
|
||||
-- the vanilla stand-in the hook wraps; 100 damage and an info table
|
||||
local function vanilla() return 100, { crit = false, typeMult = 10 } end
|
||||
|
||||
local function hit(ruleset, moveType)
|
||||
return Runtime.call("battle.damage", vanilla,
|
||||
{ ruleset = ruleset, move = { type = moveType } })
|
||||
end
|
||||
|
||||
-- ------- ruleset off: the mod is installed and changes nothing
|
||||
|
||||
Runtime.emit("battle.started", { battle = { ruleset = Data.rulesets.gen1_faithful } })
|
||||
T.eq(hit(Data.rulesets.gen1_faithful, "WATER"), 100,
|
||||
"gen1_faithful is untouched with the mod installed")
|
||||
T.eq(hit(Data.rulesets.gen1_faithful, "FIRE"), 100,
|
||||
"FIRE is untouched under gen1_faithful too")
|
||||
|
||||
-- ------- ruleset on: rain scales WATER up and FIRE down
|
||||
|
||||
Runtime.emit("battle.started", { battle = { ruleset = weather } })
|
||||
T.eq(hit(weather, "WATER"), 150, "rain boosts WATER damage")
|
||||
T.eq(hit(weather, "FIRE"), 50, "rain dampens FIRE damage")
|
||||
T.eq(hit(weather, "NORMAL"), 100, "every other type is untouched")
|
||||
|
||||
local damage, info = hit(weather, "WATER")
|
||||
T.eq(damage, 150, "the scaled damage is the first return")
|
||||
T.check(info ~= nil and info.typeMult == 10,
|
||||
"the info table survives the wrap")
|
||||
|
||||
-- ------- the counter runs out
|
||||
|
||||
for _ = 1, 5 do Runtime.emit("battle.turn_started", {}) end
|
||||
T.eq(hit(weather, "WATER"), 100, "rain stops after its turn count")
|
||||
|
||||
Runtime.emit("battle.started", { battle = { ruleset = weather } })
|
||||
T.eq(hit(weather, "WATER"), 150, "a new battle starts the rain again")
|
||||
Runtime.emit("battle.ended", {})
|
||||
T.eq(hit(weather, "WATER"), 100, "battle.ended clears the rain")
|
||||
|
||||
run.release()
|
||||
T.finish("example_weather")
|
||||