Compare commits
9 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| cb22684c2a | |||
| 94be169e35 | |||
| 36188ef18a | |||
| 7a1b4e5c45 | |||
| eba6e8d752 | |||
| ec5a72b365 | |||
| 9325d56046 | |||
| d650e605b0 | |||
| 47923d95b3 |
@@ -0,0 +1,101 @@
|
||||
name: Bug report
|
||||
description: Something in the game is broken, wrong, or not behaving like the original.
|
||||
labels: ["bug"]
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
A screenshot is worth more than any description. If you can grab one, grab one.
|
||||
If you genuinely can't, that's fine, but then the details below need to be thorough
|
||||
enough that someone can find the bug without ever seeing your screen.
|
||||
|
||||
- type: input
|
||||
id: summary
|
||||
attributes:
|
||||
label: One line summary
|
||||
description: What's broken, in a sentence.
|
||||
placeholder: Warp at the south end of the Route 5-8 underground path drops you in the wrong spot
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: dropdown
|
||||
id: os
|
||||
attributes:
|
||||
label: Which build are you running
|
||||
options:
|
||||
- macOS
|
||||
- Windows
|
||||
- Both
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: input
|
||||
id: version
|
||||
attributes:
|
||||
label: Version
|
||||
description: The release you downloaded. It's in the release title, like 0.1.4.
|
||||
placeholder: 0.1.4
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: input
|
||||
id: location
|
||||
attributes:
|
||||
label: Where in the game
|
||||
description: >
|
||||
Be specific. Route, town, building, floor, menu, battle. If it's a map
|
||||
transition, say which side you came in from.
|
||||
placeholder: Underground path between Route 5 and Route 8, south entrance
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: screenshot
|
||||
attributes:
|
||||
label: Screenshot, or a detailed description if you couldn't take one
|
||||
description: >
|
||||
Drag the image straight into this box. If you couldn't get a screenshot,
|
||||
write "no screenshot" and then describe exactly what was on screen: where
|
||||
the player sprite was, what the tiles around it looked like, any text on
|
||||
screen, whether anything was frozen or flickering. The more concrete, the better.
|
||||
placeholder: |
|
||||
Drop the image here.
|
||||
|
||||
Or, with no screenshot:
|
||||
Screen faded out like a normal warp, then faded back in with the player
|
||||
standing on the grass tile north of the Route 6 gate house instead of
|
||||
inside the tunnel. Player was facing down. No text box. Music kept playing
|
||||
the underground path track instead of switching to the Route 6 track.
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: steps
|
||||
attributes:
|
||||
label: How to make it happen
|
||||
description: Numbered steps, starting from a point someone else can get to.
|
||||
placeholder: |
|
||||
1. Start a new game, get to Cerulean
|
||||
2. Go south to Route 5, enter the underground path building
|
||||
3. Walk to the south end of the tunnel
|
||||
4. Step onto the stairs
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: expected
|
||||
attributes:
|
||||
label: What should have happened
|
||||
placeholder: Should have come out inside the Route 8 side entrance building, facing up.
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: extra
|
||||
attributes:
|
||||
label: Anything else
|
||||
description: >
|
||||
Does it happen every time or only sometimes. Did it start after a specific
|
||||
release. Anything you were doing right before it. Leave blank if nothing comes to mind.
|
||||
validations:
|
||||
required: false
|
||||
@@ -0,0 +1 @@
|
||||
blank_issues_enabled: false
|
||||
@@ -0,0 +1,88 @@
|
||||
name: Feature request
|
||||
description: Ask for something new, or a change to how something already works.
|
||||
labels: ["enhancement"]
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
"Can we add X" on its own is hard to act on. Say what you want, why you want it,
|
||||
and how you picture it working. A request with real detail is one someone can
|
||||
actually build without guessing at what you meant.
|
||||
|
||||
- type: input
|
||||
id: summary
|
||||
attributes:
|
||||
label: One line summary
|
||||
description: What you want, in a sentence.
|
||||
placeholder: Add a run toggle so you can move at bike speed without the bike
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: what
|
||||
attributes:
|
||||
label: What do you want
|
||||
description: >
|
||||
Describe it properly. What is it, where does it live in the game, what does the
|
||||
player see or do. If it changes something that already exists, say what it does today
|
||||
and what it should do instead.
|
||||
placeholder: |
|
||||
A hold-to-run button, the way later Pokemon games do it. Hold B while moving on
|
||||
foot and the player moves at bike speed. Release and you go back to walking.
|
||||
Doesn't work indoors where the bike doesn't work, doesn't work in battles or menus.
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: why
|
||||
attributes:
|
||||
label: Why is this worth doing
|
||||
description: >
|
||||
What's annoying or missing right now. What does this fix. If it's just because you
|
||||
think it would be fun, say that, it's a real answer.
|
||||
placeholder: |
|
||||
Backtracking across routes you've already cleared is slow, and the bike is a menu
|
||||
dive away and doesn't work in a lot of places. Running would cut a lot of dead time
|
||||
without making the game easier.
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: how
|
||||
attributes:
|
||||
label: How should it work
|
||||
description: >
|
||||
The specifics. Which button, which menu, what happens in the edge cases. If you don't
|
||||
know, say what you'd expect as a player and leave the rest open.
|
||||
placeholder: |
|
||||
- Hold B on the overworld to run
|
||||
- Same speed as the bike
|
||||
- Disabled anywhere the bike is disabled
|
||||
- Sprite should probably need a running animation, not sure what that takes
|
||||
- No effect on encounter rates
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: dropdown
|
||||
id: scope
|
||||
attributes:
|
||||
label: Does this change how the original game plays
|
||||
description: >
|
||||
Some requests are quality of life, some change the actual game. Both are fine,
|
||||
it just helps to know which one you're asking for.
|
||||
options:
|
||||
- Quality of life, original game is untouched
|
||||
- Changes how the game plays
|
||||
- Not sure
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: extra
|
||||
attributes:
|
||||
label: Anything else
|
||||
description: >
|
||||
Reference screenshots, how another game does it, related issues. Leave blank
|
||||
if nothing comes to mind.
|
||||
validations:
|
||||
required: false
|
||||
@@ -0,0 +1,163 @@
|
||||
name: ci
|
||||
|
||||
# The ROM-free test run (21-testing-and-ci §CI).
|
||||
#
|
||||
# CI has no ROM and never will: data/generated/ is produced by a SHA-1
|
||||
# verified import of a cartridge dump, and no ROM bytes are ever committed.
|
||||
# That is why the suite is tiered -- T1 (primitives), T2 (engine invariants)
|
||||
# and T4 (mod SDK) run against the committed tests/fixture_data dataset, so
|
||||
# they need no ROM, no display and no assets beyond what is in the repo.
|
||||
# The T3 content tier asserts Pokemon Red facts; scripts/test.sh detects
|
||||
# data/generated/ is absent and skips it rather than failing.
|
||||
#
|
||||
# Runs alongside release.yml, which is untouched by this file.
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
pull_request:
|
||||
|
||||
# a force-push while CI is mid-run should cancel the stale run, not queue
|
||||
concurrency:
|
||||
group: ci-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
headless:
|
||||
name: headless suites (no ROM)
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
# LuaJIT, not lua5.4: LOVE 11.x embeds LuaJIT 2.1 and the engine is
|
||||
# written to Lua 5.1 semantics, so CI must run the interpreter the
|
||||
# game actually ships with or it would green-light 5.4-only syntax.
|
||||
- name: install luajit
|
||||
run: sudo apt-get update && sudo apt-get install -y luajit
|
||||
|
||||
- name: interpreter version
|
||||
run: luajit -v
|
||||
|
||||
- name: run every ROM-free tier
|
||||
run: ./scripts/test.sh
|
||||
|
||||
fixture-dataset:
|
||||
name: fixture dataset integrity
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- run: sudo apt-get update && sudo apt-get install -y luajit
|
||||
- run: python3 -m pip install --upgrade pillow
|
||||
|
||||
# the fixture PNGs are committed (they are 8x8 placeholders, not
|
||||
# ROM-derived); assert they are still readable 4-shade images rather
|
||||
# than regenerating them, so a corrupted commit is caught
|
||||
- name: fixture assets are valid PNGs
|
||||
run: |
|
||||
python3 - <<'PY'
|
||||
import glob, sys
|
||||
from PIL import Image
|
||||
paths = sorted(glob.glob("tests/fixture_data/assets/*.png"))
|
||||
if not paths:
|
||||
sys.exit("no fixture assets found")
|
||||
for path in paths:
|
||||
with Image.open(path) as image:
|
||||
image.load()
|
||||
print(f"ok {path} {image.size} {image.mode}")
|
||||
print(f"\n{len(paths)} fixture assets valid")
|
||||
PY
|
||||
|
||||
# the fingerprint golden is the parity tripwire; prove it still
|
||||
# matches the dataset on a clean checkout
|
||||
- name: fingerprint gate
|
||||
run: luajit tests/engine/gate_fingerprint.lua
|
||||
|
||||
- name: parity-guarantee meta-test
|
||||
run: luajit tests/engine/gate_meta_coverage.lua
|
||||
|
||||
# Only the differ is under test here, and the job is named for that. The
|
||||
# capture half of the golden pipeline does not exist: a POKEPORT_DRIVER
|
||||
# chunk runs after main.lua has already booted the game, and
|
||||
# src/core/Data.lua has no POKEPORT_DATA_DIR branch, so no LOVE process
|
||||
# can be pointed at tests/fixture_data. There is deliberately no step
|
||||
# here that runs scripts/test.sh with WITH_SHOTS: it would have nothing
|
||||
# to capture and nothing to diff, and a job that green-lights on skipped
|
||||
# work is worse than an absent one.
|
||||
shot-differ:
|
||||
name: screenshot differ (capture not yet wired)
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- run: python3 -m pip install --upgrade pillow
|
||||
|
||||
# 21-testing-and-ci §"Testing & acceptance criteria": compare_shots
|
||||
# flags a deliberately corrupted golden and passes the clean one.
|
||||
# That is the half of the pipeline this repo can actually prove.
|
||||
- name: compare_shots self-test
|
||||
run: |
|
||||
set -e
|
||||
python3 - <<'PY'
|
||||
import os
|
||||
from PIL import Image
|
||||
os.makedirs("/tmp/g", exist_ok=True)
|
||||
os.makedirs("/tmp/s", exist_ok=True)
|
||||
base = Image.new("RGB", (160, 144), (255, 255, 255))
|
||||
for x in range(0, 160, 8):
|
||||
for y in range(0, 144, 8):
|
||||
base.putpixel((x, y), (0, 0, 0))
|
||||
base.save("/tmp/g/clean.png")
|
||||
base.save("/tmp/s/clean.png")
|
||||
base.save("/tmp/g/broken.png")
|
||||
bad = base.copy()
|
||||
for x in range(40, 60):
|
||||
for y in range(40, 60):
|
||||
bad.putpixel((x, y), (255, 0, 0))
|
||||
bad.save("/tmp/s/broken.png")
|
||||
PY
|
||||
if python3 tools/compare_shots.py /tmp/g /tmp/s; then
|
||||
echo "compare_shots passed a corrupted golden -- differ is broken"
|
||||
exit 1
|
||||
fi
|
||||
rm /tmp/g/broken.png /tmp/s/broken.png
|
||||
python3 tools/compare_shots.py /tmp/g /tmp/s
|
||||
|
||||
# an empty golden directory must not read as success
|
||||
- name: differ refuses to pass vacuously
|
||||
run: |
|
||||
set -e
|
||||
mkdir -p /tmp/empty-goldens /tmp/empty-shots
|
||||
if python3 tools/compare_shots.py /tmp/empty-goldens /tmp/empty-shots; then
|
||||
echo "compare_shots passed with no goldens"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
lint:
|
||||
name: mod lint (no ROM-derived content)
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
# the MK305 dump check key-diffs shipped tables through luajit, and
|
||||
# modkit treats a missing interpreter as a fatal MK100 -- without
|
||||
# this install the gate would fail instead of failing open
|
||||
- run: sudo apt-get update && sudo apt-get install -y luajit
|
||||
- run: python3 -m pip install --upgrade pillow
|
||||
|
||||
# constraint 1, enforced automatically: a committed mod that ships
|
||||
# ROM-derived bytes fails the build. `lint` is the no-ROM-content
|
||||
# check; `validate` is deliberately not run here because it resolves
|
||||
# a mod against the fixture dataset, and a Red-content mod such as
|
||||
# example_mew_starter legitimately does not resolve against it.
|
||||
- name: lint every committed mod
|
||||
run: |
|
||||
set -e
|
||||
found=0
|
||||
for mod in mods/*/; do
|
||||
[ -f "$mod/manifest.json" ] || continue
|
||||
found=1
|
||||
echo "== $mod"
|
||||
python3 tools/modkit.py lint "${mod%/}"
|
||||
done
|
||||
if [ "$found" = "0" ]; then
|
||||
echo "no committed mods to lint"
|
||||
fi
|
||||
@@ -14,6 +14,12 @@ name: Release
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
# CI/workflow and docs-only changes don't ship anything to users, so they
|
||||
# don't earn a release. A push touching these *and* real source still
|
||||
# releases; only pushes confined entirely to these paths are skipped.
|
||||
paths-ignore:
|
||||
- '.github/**'
|
||||
- '**.md'
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
version:
|
||||
@@ -23,6 +29,8 @@ on:
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
issues: read
|
||||
pull-requests: read
|
||||
|
||||
concurrency:
|
||||
group: release
|
||||
@@ -206,7 +214,40 @@ jobs:
|
||||
v="${{ steps.ver.outputs.version }}"
|
||||
tag="${{ steps.ver.outputs.tag }}"
|
||||
|
||||
# Issues this release closes, per GitHub's own "closing issues" links
|
||||
# (works for squash, rebase, and merge commits alike). Scans every
|
||||
# commit since the previous tag so a skipped release run doesn't drop
|
||||
# issues on the floor.
|
||||
prev_tag="$(git tag -l 'v*' --sort=-v:refname | grep -v "^${tag}$" | head -1 || true)"
|
||||
range="${prev_tag:+${prev_tag}..}$GITHUB_SHA"
|
||||
|
||||
closed=""
|
||||
for pr in $(git log --pretty=%H "$range" \
|
||||
| xargs -I{} gh api "repos/$GITHUB_REPOSITORY/commits/{}/pulls" \
|
||||
--jq '.[].number' 2>/dev/null \
|
||||
| sort -un || true); do
|
||||
closed+="$(gh api graphql \
|
||||
-f owner="${GITHUB_REPOSITORY%/*}" \
|
||||
-f name="${GITHUB_REPOSITORY#*/}" \
|
||||
-F pr="$pr" \
|
||||
-f query='
|
||||
query($owner:String!, $name:String!, $pr:Int!) {
|
||||
repository(owner:$owner, name:$name) {
|
||||
pullRequest(number:$pr) {
|
||||
closingIssuesReferences(first:50) { nodes { number title } }
|
||||
}
|
||||
}
|
||||
}' \
|
||||
--jq '.data.repository.pullRequest.closingIssuesReferences.nodes[]
|
||||
| "- #\(.number) \(.title)"' 2>/dev/null || true)"$'\n'
|
||||
done
|
||||
closed="$(printf '%s' "$closed" | grep . | sort -t'#' -k2 -n || true)"
|
||||
|
||||
notes="Download the correct version for your computer below."
|
||||
if [ -n "$closed" ]; then
|
||||
notes+=$'\n\n## Issues closed\n\n'"$closed"
|
||||
fi
|
||||
printf 'Release notes:\n%s\n' "$notes"
|
||||
|
||||
gh release create "$tag" \
|
||||
--target "$GITHUB_SHA" \
|
||||
|
||||
@@ -0,0 +1,326 @@
|
||||
# Contributing to the mod platform
|
||||
|
||||
Two routes
|
||||
|
||||
| You are... | Lane | Review bar |
|
||||
|---|---|---|
|
||||
| adding a mod to the gallery, or listing one in the showcase | [Lane A](#route-a--contributing-a-mod) | template + polish checklist + green `modkit validate` |
|
||||
| changing the loader, a registry schema, an event/hook name, or a manifest field | [Lane B](#route-b--contributing-an-engine--mod-api-change) | RFC + backward-compat statement + parity test + generated docs |
|
||||
|
||||
If you are not sure which lane you are in, ask this: **could my change make
|
||||
somebody else's existing mod behave differently?** If yes, it is Lane B.
|
||||
|
||||
---
|
||||
|
||||
## Route A — contributing a mod
|
||||
|
||||
### 1. Scaffold
|
||||
|
||||
```sh
|
||||
python3 tools/modkit.py scaffold my_mod --profile content
|
||||
```
|
||||
|
||||
`--profile` is one of `content`, `overhaul`, `total_conversion`. The
|
||||
scaffold refuses to overwrite an existing directory, and prints the next
|
||||
commands.
|
||||
|
||||
Or copy the gallery entry closest to your intent — that is what the gallery
|
||||
is for:
|
||||
|
||||
| You want to... | Copy |
|
||||
|---|---|
|
||||
| change numbers | `mods/examples/example_balance_tweaks` |
|
||||
| change art | `mods/examples/example_shiny_palette` |
|
||||
| add music or cries | `mods/examples/example_jukebox` |
|
||||
| add a quest, NPC or dialogue | `mods/examples/example_lost_parcel` |
|
||||
| change how battles work | `mods/examples/example_weather` |
|
||||
| add a screen or a tool | `mods/examples/example_dexnav` |
|
||||
| build a whole new game | `mods/examples/example_mini_conversion` |
|
||||
|
||||
### 2. What the PR must contain
|
||||
|
||||
1. **A green `modkit validate`.** CI runs it; so should you.
|
||||
|
||||
```sh
|
||||
python3 tools/modkit.py validate mods/examples/<id> --base imported
|
||||
python3 tools/modkit.py lint mods/examples/<id>
|
||||
```
|
||||
|
||||
`validate` drives the *real* loader headlessly, so a mod that passes
|
||||
here does not surface load errors in game. `--base imported` folds
|
||||
against the full vanilla id space; without it, rules that can only be
|
||||
decided against real Red content (`MK103`, the patch-target check) are
|
||||
reported as skipped rather than guessed at.
|
||||
|
||||
2. **A `tests/` directory** with at least one suite that loads the mod
|
||||
through the headless loader and asserts its *stated effect* — not just
|
||||
that it loaded.
|
||||
|
||||
```lua
|
||||
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/my_mod", { data = Data })
|
||||
T.eq(#run.errors, 0, "loads clean")
|
||||
T.eq(Data.pokemon.PIKACHU.baseStats.speed, 120, "the patch landed")
|
||||
run.release()
|
||||
T.finish("my_mod")
|
||||
```
|
||||
|
||||
Add a `.modkitignore` listing the suite so it stays out of the
|
||||
distributed package — a test requiring engine modules is a
|
||||
private-require finding against the shipped archive, and `pack` treats
|
||||
warnings as fatal.
|
||||
|
||||
3. **A `README.md`** that opens with one sentence saying what the mod does,
|
||||
names its persona, and gives the three commands to try it. No
|
||||
prerequisites the scaffold did not already create.
|
||||
|
||||
4. **A `mod.card`** meeting the [§3.2 schema](#modcard):
|
||||
|
||||
```lua
|
||||
return {
|
||||
summary = "One sentence, <=100 chars.",
|
||||
author = "Your handle", -- never blank; no author is anonymous by omission
|
||||
tags = { "balance", "beginner" },
|
||||
differences = { changed = {…}, added = {…}, known = {…} },
|
||||
credits = { { who = "…", for_ = "original chiptune arrangement" } },
|
||||
compat = { engine = ">=1.0.0 <2.0.0", modApi = 2 },
|
||||
}
|
||||
```
|
||||
|
||||
5. **A `CHANGELOG.md`** in keep-a-changelog format, with a heading matching
|
||||
`manifest.version`. `validate` warns when the version advanced without
|
||||
one.
|
||||
|
||||
6. **Disabled by default.** Gallery entries live in `mods/examples/`, which
|
||||
the loader's one-level discovery does not walk, so a fresh install
|
||||
discovers none of them and the vanilla game is unchanged.
|
||||
|
||||
7. **No ROM-derived bytes.** Art and audio ship as originals or as a
|
||||
`transforms.lua` operating on the player's own cache. `modkit lint`
|
||||
is the hard floor; see
|
||||
[the legal posture](#legal-posture-non-negotiable).
|
||||
|
||||
CI checks 1, 2, 6 and 7 mechanically. A reviewer checks 3, 4, 5 and the
|
||||
polish checklist.
|
||||
|
||||
### 3. Category
|
||||
|
||||
`manifest.category` is a closed vocabulary. An unknown value is a warning,
|
||||
not a hard error, so the list can grow without breaking old mods.
|
||||
|
||||
| category | Meaning | Typical profile |
|
||||
|---|---|---|
|
||||
| `TWEAK` | Small data edits: stats, prices, learnsets, encounter tables | content |
|
||||
| `BALANCE` | Systematic rebalance across many records or a ruleset | content / overhaul |
|
||||
| `CONTENT` | New species / moves / items / maps / trainers | content |
|
||||
| `QUEST` | New story, NPCs, dialogue, cutscenes | content |
|
||||
| `MECHANIC` | New or changed battle/field mechanics via hooks/effects | overhaul |
|
||||
| `GRAPHICS` | Sprite / tileset / palette / font changes | content |
|
||||
| `AUDIO` | Music, sfx, cries | content |
|
||||
| `UI` | New or modified screens, menus, overlays | content / overhaul |
|
||||
| `TOOL` | Dev/QoL utilities, overlays, inter-mod libraries | content |
|
||||
| `TOTAL_CONVERSION` | Full re-theme; owns its own tri-ledger | total_conversion |
|
||||
| `OTHER` | Fallback | any |
|
||||
|
||||
`GAMEPLAY` is accepted as an alias for `TWEAK`, so `example_mew_starter`
|
||||
keeps validating with the value it has shipped since before the taxonomy
|
||||
existed.
|
||||
|
||||
### 4. `mod.card`
|
||||
|
||||
The manifest is the *engine's* contract: identity, load order, dependencies,
|
||||
permissions, profile. The card is the *human-facing* one: who made this,
|
||||
what it changes, what it does not do yet. It is never read by the loader's
|
||||
merge — only by tooling and the manager's detail pane — so an absent or
|
||||
malformed card can never break a load.
|
||||
|
||||
Two fields deserve their own note:
|
||||
|
||||
- **`differences`** is a self-declared tri-ledger, mirroring the discipline
|
||||
the engine holds itself to. `changed` and `added` let a player see the
|
||||
blast radius before installing; `known` is where you are honest about
|
||||
what is rough. A card with an empty `known` on a complex mod reads as
|
||||
carelessness, not polish.
|
||||
- **`screenshots[].transform`** describes a screenshot by the *driver
|
||||
script* that regenerates it from the player's build, rather than shipping
|
||||
the pixels. That is the legal posture extended to your marketing: a
|
||||
distributed mod never carries ROM-derived bytes, not even in its preview
|
||||
images.
|
||||
|
||||
### 5. Tags
|
||||
|
||||
Lowercase kebab strings, open vocabulary. The showcase generator
|
||||
lowercases and de-dupes. A recommended starting set: `beginner`,
|
||||
`data-only`, `quality-of-life`, `hardcore`, `cosmetic`, `story`, `ruleset`,
|
||||
`audio`, `ui`, `total-conversion`.
|
||||
|
||||
---
|
||||
|
||||
## Route B — contributing an engine / mod-API change
|
||||
|
||||
Changing the loader, a registry schema, an event or hook name, or a manifest
|
||||
field touches the **compatibility surface** the project promises to hold
|
||||
stable. Those PRs carry five obligations.
|
||||
|
||||
### 1. An RFC
|
||||
|
||||
`docs/rfcs/NNNN-<slug>.md`, covering:
|
||||
|
||||
- **Motivation** — the mod that cannot be written today.
|
||||
- **The decision it extends or amends** — name the D-number and the plan
|
||||
file, so the change is traceable to the design it modifies.
|
||||
- **The exact API delta** — new registry names, new schema fields, new
|
||||
event/hook names and their payload shapes and call sites.
|
||||
- **A migration note for existing mods** — what an author has to do, if
|
||||
anything. "Nothing" is a valid and preferred answer.
|
||||
|
||||
### 2. A backward-compatibility statement
|
||||
|
||||
Show that the v1 surface still works: `content.X:register/override/get`,
|
||||
`events:on`, `hooks:wrap`, `mod.log`, `mod:read`, the manifest v1 fields,
|
||||
and `pokemon.before_give`.
|
||||
|
||||
**A change that would break a v1 mod is rejected unless it is
|
||||
additive-with-alias.** `mods/example_mew_starter` is the live proof: it is
|
||||
api 1, uses `category = "GAMEPLAY"`, copies a whole species record because
|
||||
`patch` did not exist yet, and it must keep loading unchanged.
|
||||
|
||||
### 3. A parity-guarantee test
|
||||
|
||||
Two tests, not one:
|
||||
|
||||
- **The no-mod test** — vanilla behavior is unchanged with nothing
|
||||
installed. A new hook with no subscriber must return the vanilla value;
|
||||
a new registry must be a provable no-op when empty; a new event must not
|
||||
allocate its payload when nothing wants it (`Runtime.wants(name)` /
|
||||
`Runtime.wantsHook(name)` guard the hot paths).
|
||||
- **The mod-API test** — the new seam, exercised through the *public* mod
|
||||
API rather than by reaching into internals. If the test has to require a
|
||||
private module to drive your seam, the seam is not finished.
|
||||
|
||||
### 4. Docs with the change
|
||||
|
||||
The reference pages are generated from `src/mods/Schemas.lua`, so a new
|
||||
registry or a new schema field lands with its catalog entry in the same PR
|
||||
and the generator runs clean:
|
||||
|
||||
```sh
|
||||
luajit tools/gen_registry_docs.lua # in-repo default
|
||||
luajit tools/gen_registry_docs.lua ../project.wiki # the wiki checkout
|
||||
```
|
||||
|
||||
The prose reference lives in the GitHub wiki; the generated pages are
|
||||
written into a checkout of it, so they cannot drift from the engine.
|
||||
|
||||
### 5. Deprecation etiquette
|
||||
|
||||
**Nothing is removed.** A superseded seam is marked deprecated in the
|
||||
generated reference with its replacement named, keeps firing and working,
|
||||
and is listed in the deprecations page.
|
||||
|
||||
`pokemon.before_give` is the worked precedent: the `pokemon.give` hook
|
||||
supersedes it, and it is grandfathered forever anyway.
|
||||
|
||||
### Review
|
||||
|
||||
PRs touching `src/mods/`, `src/mods/Schemas.lua`, or the event/hook catalog
|
||||
need the RFC label and a green parity gate before merge.
|
||||
|
||||
---
|
||||
|
||||
## The polish checklist
|
||||
|
||||
Every gallery example and every community mod the guide recommends meets
|
||||
this bar. `[auto]` items are checked by `modkit validate`; `[review]` items
|
||||
by a human.
|
||||
|
||||
### Error messages
|
||||
|
||||
- `[auto]` No bare `error()` or `assert()` in mod callbacks. Every failure
|
||||
path uses `mod.log:warn` / `mod.log:error` — the loader already prefixes
|
||||
`[modid]` — **and names a remediation**:
|
||||
|
||||
```lua
|
||||
-- no
|
||||
local mew = assert(mod.content.pokemon:get("MEW"), "Mew is missing")
|
||||
|
||||
-- yes
|
||||
if not mod.content.pokemon:get("MEW") then
|
||||
mod.log:warn("MEW missing from the merged view -- is a species mod "
|
||||
.. "loaded before this one? speed patch skipped")
|
||||
return
|
||||
end
|
||||
```
|
||||
|
||||
- `[review]` Every registration is validated against its schema, so a typo
|
||||
is a load-time message naming the field, not a nil-index crash three
|
||||
screens later.
|
||||
|
||||
### Empty states
|
||||
|
||||
- `[review]` Every screen a mod adds renders a sentence when its data set
|
||||
is empty — "No songs registered", "Nothing seen yet" — never a blank box.
|
||||
`ListMenu` gives you this for free.
|
||||
|
||||
### First-run experience
|
||||
|
||||
- `[review]` The README opens with one sentence of what the mod does, then
|
||||
the commands to try it.
|
||||
- `[auto]` The mod loads clean on a fresh install — zero `Loader.errors` —
|
||||
with only its declared dependencies.
|
||||
- `[review]` Options have sane defaults, so the mod does something useful
|
||||
before the player opens its options pane.
|
||||
|
||||
### Credits and honoring authors
|
||||
|
||||
- `[review]` `mod.card.credits` names every upstream contribution — art,
|
||||
music arrangement, borrowed code — and what it was for. A mod that ports
|
||||
another community work credits it and links it.
|
||||
- `[auto]` `mod.card.author` (or `authors`) is present and non-empty. The
|
||||
showcase and the manager both surface it, so no author is anonymous by
|
||||
omission.
|
||||
- `[review]` Asset provenance is honest: originals declared original,
|
||||
cache-derived output produced by a declared transform, third-party assets
|
||||
credited and license-compatible.
|
||||
|
||||
### Legal posture (non-negotiable)
|
||||
|
||||
- `[auto]` **No ROM-derived bytes in the packaged mod.** `modkit pack`
|
||||
refuses otherwise, and `pack` runs `validate --strict`, so even warnings
|
||||
block the archive.
|
||||
- `[review]` A total conversion carries the TC legal callout: the Red
|
||||
import still runs and supplies fallback infrastructure, the conversion
|
||||
overrides on top, and it distributes recipes rather than extracted
|
||||
content.
|
||||
|
||||
---
|
||||
|
||||
## Versioning etiquette
|
||||
|
||||
Three version numbers coexist.
|
||||
|
||||
**Engine version** — `src/core/Version.lua`. Major = a breaking change to
|
||||
the mod-facing schemas or API; minor = new backward-compatible seams;
|
||||
patch = bugfix.
|
||||
|
||||
**Mod API version** — the integer `modApi`, currently `2`. Bumped only on a
|
||||
breaking change to the `mod` object surface. A manifest's `api` field pins
|
||||
the surface the mod was written against, so an api-2 mod keeps working when
|
||||
the engine ships api 3.
|
||||
|
||||
**Your mod's version** — the manifest `version`, semver:
|
||||
|
||||
| bump | when |
|
||||
|---|---|
|
||||
| patch | data fixes; no save-shape change, no new content ids |
|
||||
| minor | new content ids, new options with defaults, new optional deps |
|
||||
| major | removed or renamed content ids, a changed `mod.save` shape (needs a `mod.migrations:add(sinceVersion, fn)` entry), or a raised `game_version` floor |
|
||||
|
||||
Declare the engine range you target in `game_version` (a semver range, e.g.
|
||||
`">=1.0.0 <2.0.0"`). The loader checks it on load; a mismatch is a clear,
|
||||
mod-attributed manager error, never a silent partial load.
|
||||
|
||||
Every version change gets a `CHANGELOG.md` heading. `modkit validate` warns
|
||||
when `manifest.version` advanced without one.
|
||||
@@ -10,19 +10,11 @@ This project does not include a ROM, emulate the Game Boy, transpile assembly,
|
||||
or download a disassembly. A canonical US Pokemon Red ROM is the only game
|
||||
content input.
|
||||
|
||||
```text
|
||||
first boot
|
||||
Pokemon Red ROM -> in-app Lua importer -> private LÖVE save directory
|
||||
-> generated Lua data and PNGs
|
||||
-> compact audio channel programs
|
||||
-> LÖVE2D engine
|
||||
```
|
||||
|
||||
The ROM is verified, used during import, and then released from memory. It is
|
||||
not copied into the cache. Later launches load the private generated cache and
|
||||
do not ask for the ROM again.
|
||||
|
||||
## Packaged App
|
||||
## Quick Start
|
||||
|
||||
Open the desktop app. On first boot, choose your legally obtained `.gb` file
|
||||
or drop it onto the window. Import takes a few seconds and the game starts
|
||||
@@ -30,114 +22,69 @@ automatically.
|
||||
|
||||
Only the canonical 1 MiB US Red ROM is accepted. The importer verifies SHA-1
|
||||
`ea9bcae617fdf159b045185467ae58b2e4a48b9a` before creating any game data.
|
||||
The packaged app contains neither a ROM nor pre-extracted game data.
|
||||
The packaged app contains neither a ROM nor pre-extracted game data. Music,
|
||||
sound effects, and cries are synthesized while the game runs from compact
|
||||
audio channel programs copied out of the verified ROM.
|
||||
|
||||
Music, sound effects, and cries are synthesized while the game runs from
|
||||
compact Game Boy audio channel programs copied out of the verified ROM. No
|
||||
WAV or OGG library is bundled or generated.
|
||||
## Controls
|
||||
|
||||
## Source Checkout
|
||||
arrow keys or WASD move; Z, Enter, or Space is A; X or Backspace is
|
||||
B; Escape opens START. F1 saves and F2 loads. Controllers are supported.
|
||||
|
||||
The source launchers retain an optional Python workflow for developers. Place
|
||||
the ROM in the project folder and double-click `Play-Mac.command` or
|
||||
`Play-Windows.bat`, or run:
|
||||
## Running From Source
|
||||
|
||||
Requires LÖVE 11.x. Place the ROM in the project folder and double-click
|
||||
`Play-Mac.command` or `Play-Windows.bat`, or run:
|
||||
|
||||
```sh
|
||||
scripts/setup.sh --rom "/path/to/Pokemon Red.gb"
|
||||
scripts/run.sh
|
||||
```
|
||||
|
||||
Windows PowerShell:
|
||||
then `love .` for later launches. Windows PowerShell scripts, the optional
|
||||
developer data build, test suites, and cache management are covered in
|
||||
[Developer Setup](https://github.com/bryanthaboi/pokemon-gen1-recomp-project/wiki/Guide-Developer-Setup).
|
||||
|
||||
```powershell
|
||||
powershell -ExecutionPolicy Bypass -File scripts\setup.ps1 -Rom C:\path\red.gb
|
||||
powershell -ExecutionPolicy Bypass -File scripts\run.ps1
|
||||
```
|
||||
## Modding
|
||||
|
||||
The setup scripts also accept `ROM_PATH`. With no argument, they use the first
|
||||
`.gb` file in the project root.
|
||||
The game ships a native mod platform: content registries, events and hooks,
|
||||
per-mod saves and options, and an in-game manager. The full modding book —
|
||||
getting started, a twelve-rung tutorial ladder, a cookbook, and the generated
|
||||
reference — lives on the
|
||||
[project wiki](https://github.com/bryanthaboi/pokemon-gen1-recomp-project/wiki).
|
||||
|
||||
## Developer Data Build
|
||||
Shipped example mods, one per kind of author, live in [`mods/`](mods/).
|
||||
|
||||
Requirements: Python 3.10+ and Pillow.
|
||||
## Bugs and Ideas
|
||||
|
||||
```sh
|
||||
python3 -m pip install pillow
|
||||
python3 tools/build_data.py --rom "/path/to/Pokemon Red.gb" --clean
|
||||
```
|
||||
Found a bug? A warp dropping you somewhere it shouldn't, a battle doing math
|
||||
that looks wrong, text in the wrong box, anything that does not match the
|
||||
original game.
|
||||
[Open a bug report](https://github.com/bryanthaboi/pokemon-gen1-recomp-project/issues/new?template=bug_report.yml).
|
||||
Attach a screenshot if you can. It saves a lot of back and forth, and if you
|
||||
can't get one, the form asks you to describe what you saw instead.
|
||||
|
||||
This command produces the data modules and 495 PNGs in the source tree for
|
||||
development and parity checks. It is not used by the packaged app.
|
||||
Thought of a feature that could be good, or a way to improve one that already
|
||||
exists?
|
||||
[Open a feature request](https://github.com/bryanthaboi/pokemon-gen1-recomp-project/issues/new?template=feature_request.yml).
|
||||
Say what you want, why it is worth doing, and how you picture it working. A
|
||||
request with real detail is one that can actually get built.
|
||||
|
||||
## Running
|
||||
## More
|
||||
|
||||
Requires LÖVE 11.x:
|
||||
|
||||
```sh
|
||||
love .
|
||||
```
|
||||
|
||||
Controls: arrow keys or WASD move; Z, Enter, or Space is A; X or Backspace is
|
||||
B; Escape opens START. F1 saves and F2 loads. Controllers are supported.
|
||||
|
||||
## Link Play
|
||||
|
||||
START > LINK connects two copies directly over UDP. The host chooses HOST A
|
||||
GAME and shares the shown address; the other player chooses JOIN A GAME.
|
||||
The default port is 7777 and can be overridden with `POKEPORT_LINK_PORT`.
|
||||
|
||||
## Save Editor
|
||||
|
||||
Edit party, boxes, items, events, map location, and Pokédex flags without
|
||||
playing through the game. Close the game first, then from the repo root:
|
||||
|
||||
```sh
|
||||
love . --editor
|
||||
# or
|
||||
POKEPORT_EDITOR=1 love .
|
||||
# open a specific save
|
||||
love . --editor --save "/path/to/save.lua"
|
||||
```
|
||||
|
||||
By default it loads the game's LÖVE save (`save.lua`):
|
||||
|
||||
- macOS: `~/Library/Application Support/LOVE/pokemon-love2d/save.lua (or without the LOVE in a built version)`
|
||||
- Linux: `~/.local/share/love/pokemon-love2d/save.lua`
|
||||
- Windows: `%APPDATA%\love\pokemon-love2d\save.lua`
|
||||
|
||||
If that file is missing or you want another copy, use **Open...**, drop a
|
||||
`save.lua` onto the window, or pass `--save`. Each write makes a
|
||||
`save.lua.bak-YYYYMMDD-HHMMSS` backup first.
|
||||
|
||||
See `tools/save-editor/README.md` for headless tests.
|
||||
|
||||
## Layout
|
||||
|
||||
```text
|
||||
tools/ ROM decoder, save editor, and developer verification tools
|
||||
data/generated/ generated Lua game data (gitignored)
|
||||
data/scripts/ hand-ported map behavior
|
||||
assets/generated generated graphics and compact audio cache (gitignored)
|
||||
src/ hand-written LÖVE engine
|
||||
scripts/ setup, run, and packaging helpers
|
||||
mobile/ Android and iOS build trees
|
||||
tests/ headless behavior and parity suites
|
||||
docs/ architecture, behavior notes, and platform docs
|
||||
```
|
||||
|
||||
See `docs/architecture.md` for runtime details and
|
||||
`docs/behavior-porting-notes.md` for formula provenance.
|
||||
|
||||
## Delete generated files (mac)
|
||||
|
||||
```sh
|
||||
rm -rf data/generated assets/generated \
|
||||
"$HOME/Library/Application Support/LOVE/pokemon-love2d/data/generated" \
|
||||
"$HOME/Library/Application Support/LOVE/pokemon-love2d/assets/generated"
|
||||
|
||||
rm -f "$HOME/Library/Application Support/LOVE/pokemon-love2d/rom-cache.complete"
|
||||
|
||||
```
|
||||
- [Link play](https://github.com/bryanthaboi/pokemon-gen1-recomp-project/wiki/Guide-Link-Play)
|
||||
— START > LINK connects two copies directly over UDP.
|
||||
- [Save editor](https://github.com/bryanthaboi/pokemon-gen1-recomp-project/wiki/Guide-Save-Editor)
|
||||
— edit party, boxes, items, events, and Pokédex flags outside the game.
|
||||
- `docs/architecture.md` — runtime details;
|
||||
`docs/behavior-porting-notes.md` — formula provenance.
|
||||
|
||||
## Special Thanks
|
||||
This project would not be possible without [pret](https://github.com/pret) > the pret band of decompiling maniacs > and their [pokered](https://github.com/pret/pokered) disassembly.
|
||||
|
||||
This project would not be possible without [pret](https://github.com/pret) >
|
||||
the pret band of decompiling maniacs > and their
|
||||
[pokered](https://github.com/pret/pokered) disassembly.
|
||||
|
||||
## Wanna Support My Work?
|
||||
[](https://buymeacoffee.com/bryanthaboi)
|
||||
|
||||
|
||||
@@ -16,7 +16,11 @@ function love.conf(t)
|
||||
t.window.height = 800
|
||||
else
|
||||
t.identity = os.getenv("POKEPORT_IDENTITY") or "pokemon-love2d"
|
||||
t.window.title = "Pokemon Red (Gen 1 Recompilation Project)"
|
||||
-- Version.lua has zero requires, so it is loadable this early; fall
|
||||
-- back to the plain title if the source is not mounted yet
|
||||
local ok, Version = pcall(require, "src.core.Version")
|
||||
t.window.title = ok and Version.title()
|
||||
or "Pokemon Red (Gen 1 Recompilation Project)"
|
||||
t.window.width = 160 * 4
|
||||
t.window.height = 144 * 4
|
||||
end
|
||||
|
||||
@@ -1,22 +1,18 @@
|
||||
-- Viridian City flavor dialogue (pokered/scripts/ViridianCity.asm).
|
||||
-- Ports the text_asm bodies for GAMBLER1, YOUNGSTER2, GIRL and OLD_MAN.
|
||||
-- Ports the text_asm bodies for GAMBLER1, YOUNGSTER2 and GIRL.
|
||||
--
|
||||
-- Not ported here (already handled elsewhere / not talk-reachable):
|
||||
-- * TEXT_VIRIDIANCITY_FISHER (TM42 gift) -- already ported as a
|
||||
-- `gift()` entry in data/scripts/story5.lua's M.VIRIDIAN_CITY.talk.
|
||||
-- * TEXT_VIRIDIANCITY_OLD_MAN_SLEEPY / TEXT_VIRIDIANCITY_GYM_LOCKED --
|
||||
-- these are step-triggered blocking texts (ViridianCityCheckGotPokedexScript /
|
||||
-- ViridianCityCheckGymOpenScript), not npc talk text_asm bodies; the
|
||||
-- gates themselves are already implemented via story5.lua's onStep
|
||||
-- chain (viridianOldManStep / viridianGymLock) for this map.
|
||||
-- * The old man's catch-training minigame trigger (SCRIPT_VIRIDIANCITY_
|
||||
-- OLD_MAN_START_CATCH_TRAINING / battle vs. WEEDLE) is a full
|
||||
-- scripted-battle cutscene outside this task's Commands vocabulary
|
||||
-- (no static_battle-style "battle a scripted old-man WEEDLE" command
|
||||
-- exists); we port the real YES/NO branch text he speaks but the
|
||||
-- "yes" branch here just shows the "I'll show you how" line rather
|
||||
-- than actually starting the minigame, since that machinery isn't
|
||||
-- ported to this map yet.
|
||||
-- * TEXT_VIRIDIANCITY_OLD_MAN (the walking man at (17,5)) and
|
||||
-- TEXT_VIRIDIANCITY_OLD_MAN_SLEEPY (the sleeper at (18,9)) -- both
|
||||
-- live in data/scripts/story.lua, which owns this map's onStep gate
|
||||
-- and can reach the `old_man_demo` command for the real catch
|
||||
-- tutorial. Keep them there: story.lua loads BEFORE this file, so a
|
||||
-- duplicate here would silently win the merge.
|
||||
-- * TEXT_VIRIDIANCITY_GYM_LOCKED -- a step-triggered blocking text
|
||||
-- (ViridianCityCheckGymOpenScript), implemented by story5.lua's
|
||||
-- onStep chain (viridianGymLock -> viridianOldManStep) for this map.
|
||||
|
||||
local M = {}
|
||||
|
||||
@@ -91,24 +87,6 @@ M.VIRIDIAN_CITY = {
|
||||
end
|
||||
end,
|
||||
|
||||
-- ViridianCityOldManText (scripts/ViridianCity.asm): once he's had
|
||||
-- his coffee, he asks (YES/NO) whether you want to learn how to
|
||||
-- catch Pokemon. YES leads into the catch-training minigame
|
||||
-- (SCRIPT_VIRIDIANCITY_OLD_MAN_START_CATCH_TRAINING, not ported --
|
||||
-- see file header); NO just brushes you off ("Time is money...").
|
||||
TEXT_VIRIDIANCITY_OLD_MAN = function(game, ow, npc, done)
|
||||
local t = text(game)
|
||||
ask(game, t._ViridianCityOldManHadMyCoffeeNowText
|
||||
or "Ahh, I've had my\ncoffee now and I\nfeel great!\nSure you can go\nthrough!\nAre you in a\nhurry?", function(yes)
|
||||
if yes then
|
||||
push(game, t._ViridianCityOldManKnowHowToCatchPokemonText
|
||||
or "I see you're using\na POKéDEX.\nWhen you catch a\nPOKéMON, POKéDEX\nis automatically\nupdated.\nWhat? Don't you\nknow how to catch\nPOKéMON?\nI'll show you\nhow to then.", done)
|
||||
else
|
||||
push(game, t._ViridianCityOldManTimeIsMoneyText
|
||||
or "Time is money...\nGo along then.", done)
|
||||
end
|
||||
end)
|
||||
end,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@@ -5,18 +5,26 @@
|
||||
-- onBoulderMoved = fn } where a script is a list of { "command", args... }
|
||||
-- rows executed by src/script/ScriptRunner.lua. Every hand-ported script
|
||||
-- cites the pokered source it was ported from.
|
||||
--
|
||||
-- The modules land in src/script/MapScripts.lua as the engine's base
|
||||
-- contribution: attachBase keeps the historical merge (talk tables merge
|
||||
-- per TEXT constant, other hooks are replaced by later files, so
|
||||
-- different files can each add NPCs to the same map), and mod
|
||||
-- contributions from the map_scripts registry compose on top of it.
|
||||
|
||||
local registry = {
|
||||
PALLET_TOWN = require("data.scripts.pallet_town"),
|
||||
OAKS_LAB = require("data.scripts.oaks_lab"),
|
||||
REDS_HOUSE_1F = require("data.scripts.reds_house"),
|
||||
CELADON_MANSION_ROOF_HOUSE = require("data.scripts.celadon_eevee"),
|
||||
}
|
||||
local MapScripts = require("src.script.MapScripts")
|
||||
|
||||
-- story-critical scripts, one table per map. Later files MERGE into
|
||||
-- earlier ones: talk tables merge per TEXT constant, other hooks
|
||||
-- (onEnter, onVictory, ...) are replaced, so different files can each
|
||||
-- add NPCs to the same map.
|
||||
for _, mapEntry in ipairs({
|
||||
{ "PALLET_TOWN", "data.scripts.pallet_town" },
|
||||
{ "OAKS_LAB", "data.scripts.oaks_lab" },
|
||||
{ "REDS_HOUSE_1F", "data.scripts.reds_house" },
|
||||
{ "CELADON_MANSION_ROOF_HOUSE", "data.scripts.celadon_eevee" },
|
||||
}) do
|
||||
MapScripts.attachBase(mapEntry[1], require(mapEntry[2]))
|
||||
end
|
||||
|
||||
-- story-critical scripts, one table per map, in the order the old merge
|
||||
-- loop required them
|
||||
for _, file in ipairs({ "data.scripts.story", "data.scripts.story2",
|
||||
"data.scripts.story3", "data.scripts.story4",
|
||||
"data.scripts.story5", "data.scripts.story6",
|
||||
@@ -24,33 +32,24 @@ for _, file in ipairs({ "data.scripts.story", "data.scripts.story2",
|
||||
"data.scripts.safari", "data.scripts.seafoam",
|
||||
"data.scripts.gyms" }) do
|
||||
for mapId, mod in pairs(require(file)) do
|
||||
local existing = registry[mapId]
|
||||
if not existing then
|
||||
registry[mapId] = mod
|
||||
else
|
||||
for k, v in pairs(mod) do
|
||||
if k == "talk" and existing.talk then
|
||||
for textConst, script in pairs(v) do
|
||||
existing.talk[textConst] = script
|
||||
end
|
||||
else
|
||||
existing[k] = v
|
||||
end
|
||||
end
|
||||
end
|
||||
MapScripts.attachBase(mapId, mod)
|
||||
end
|
||||
end
|
||||
|
||||
local M = {}
|
||||
|
||||
function M.get(mapId)
|
||||
return registry[mapId]
|
||||
return MapScripts.get(mapId)
|
||||
end
|
||||
|
||||
-- script to run when the player talks to an object with this TEXT_ constant
|
||||
function M.talkScript(mapId, textConst)
|
||||
local mod = registry[mapId]
|
||||
return mod and mod.talk and mod.talk[textConst] or nil
|
||||
return MapScripts.talkScript(mapId, textConst)
|
||||
end
|
||||
|
||||
-- attribution for that script's run: the owning mod's source, nil for base
|
||||
function M.talkSource(mapId, textConst)
|
||||
return MapScripts.talkSource(mapId, textConst)
|
||||
end
|
||||
|
||||
return M
|
||||
|
||||
@@ -58,33 +58,40 @@ return {
|
||||
TEXT_OAKSLAB_OAK1 = {
|
||||
{ "face_player" }, -- 1
|
||||
{ "check_flag", "EVENT_GOT_OAKS_PARCEL" }, -- 2
|
||||
{ "jump_if_false", 12 }, -- 3
|
||||
{ "jump_if_false", 14 }, -- 3
|
||||
{ "check_flag", "EVENT_OAK_GOT_PARCEL" }, -- 4
|
||||
{ "jump_if_true", 12 }, -- 5
|
||||
{ "jump_if_true", 14 }, -- 5
|
||||
{ "show_text", "_OaksLabOak1DeliverParcelText" }, -- 6
|
||||
{ "take_item", "OAKS_PARCEL", 1 }, -- 7
|
||||
{ "set_flag", "EVENT_OAK_GOT_PARCEL" }, -- 8
|
||||
{ "show_text", "_OaksLabOak1PokemonAroundTheWorldText" }, -- 9
|
||||
{ "set_flag", "EVENT_GOT_POKEDEX" }, -- 10
|
||||
{ "jump", 30 }, -- 11
|
||||
{ "check_flag", "EVENT_GOT_STARTER" }, -- 12
|
||||
{ "jump_if_false", 27 }, -- 13
|
||||
{ "check_item", "POKE_BALL" }, -- 14
|
||||
{ "jump_if_true", 25 }, -- 15
|
||||
{ "check_flag", "EVENT_BEAT_ROUTE22_RIVAL_1ST_BATTLE" }, -- 16
|
||||
{ "jump_if_false", 29 }, -- 17
|
||||
{ "check_flag", "EVENT_GOT_POKEBALLS_FROM_OAK" }, -- 18
|
||||
{ "jump_if_true", 25 }, -- 19
|
||||
{ "set_flag", "EVENT_GOT_POKEBALLS_FROM_OAK" }, -- 20
|
||||
{ "give_item", "POKE_BALL", 5, false }, -- 21
|
||||
{ "show_text", "_OaksLabOak1ReceivedPokeballsText" }, -- 22
|
||||
{ "show_text", "_OaksLabGivePokeballsExplanationText" }, -- 23
|
||||
{ "jump", 30 }, -- 24
|
||||
{ "show_text", "_OaksLabOak1ComeSeeMeSometimesText" }, -- 25
|
||||
{ "jump", 30 }, -- 26
|
||||
{ "show_text", "_OaksLabOak1WhichPokemonDoYouWantText" }, -- 27
|
||||
{ "jump", 30 }, -- 28
|
||||
{ "show_text", "_OaksLabOak1RaiseYourYoungPokemonText" }, -- 29 (30 = end)
|
||||
-- the Pokédex swaps Viridian's two old men (OaksLab.asm:602-606:
|
||||
-- HideObject TOGGLE_LYING_OLD_MAN / ShowObject TOGGLE_OLD_MAN).
|
||||
-- Until this ran, the walking man at (17,5) -- who owns the coffee
|
||||
-- ask and the catch tutorial -- stayed OFF for the whole game
|
||||
-- (toggleable_objects.asm seeds him OFF, the sleeper ON).
|
||||
{ "hide_object", "VIRIDIAN_CITY", "VIRIDIANCITY_OLD_MAN_SLEEPY" }, -- 11
|
||||
{ "show_object", "VIRIDIAN_CITY", "VIRIDIANCITY_OLD_MAN" }, -- 12
|
||||
{ "jump", 32 }, -- 13
|
||||
{ "check_flag", "EVENT_GOT_STARTER" }, -- 14
|
||||
{ "jump_if_false", 29 }, -- 15
|
||||
{ "check_item", "POKE_BALL" }, -- 16
|
||||
{ "jump_if_true", 27 }, -- 17
|
||||
{ "check_flag", "EVENT_BEAT_ROUTE22_RIVAL_1ST_BATTLE" }, -- 18
|
||||
{ "jump_if_false", 31 }, -- 19
|
||||
{ "check_flag", "EVENT_GOT_POKEBALLS_FROM_OAK" }, -- 20
|
||||
{ "jump_if_true", 27 }, -- 21
|
||||
{ "set_flag", "EVENT_GOT_POKEBALLS_FROM_OAK" }, -- 22
|
||||
{ "give_item", "POKE_BALL", 5, false }, -- 23
|
||||
{ "show_text", "_OaksLabOak1ReceivedPokeballsText" }, -- 24
|
||||
{ "show_text", "_OaksLabGivePokeballsExplanationText" }, -- 25
|
||||
{ "jump", 32 }, -- 26
|
||||
{ "show_text", "_OaksLabOak1ComeSeeMeSometimesText" }, -- 27
|
||||
{ "jump", 32 }, -- 28
|
||||
{ "show_text", "_OaksLabOak1WhichPokemonDoYouWantText" }, -- 29
|
||||
{ "jump", 32 }, -- 30
|
||||
{ "show_text", "_OaksLabOak1RaiseYourYoungPokemonText" }, -- 31 (32 = end)
|
||||
},
|
||||
|
||||
TEXT_OAKSLAB_CHARMANDER_POKE_BALL =
|
||||
|
||||
@@ -10,6 +10,31 @@ local M = {}
|
||||
-- -------------------------------------------------------------------
|
||||
|
||||
M.VIRIDIAN_MART = {
|
||||
-- scripts/ViridianMart.asm: the parcel hand-off is the map's DEFAULT
|
||||
-- script, not a talk. Entering with a starter and no parcel runs
|
||||
-- ViridianMartDefaultScript -- the clerk calls out, then
|
||||
-- StartSimulatingJoypadStates walks the player to the counter
|
||||
-- (.PlayerMovement: PAD_LEFT 1, PAD_UP 2, door (3,7) -> counter (2,5))
|
||||
-- and ViridianMartOaksParcelScript hands the parcel over. The player
|
||||
-- never presses A. This matters beyond convenience: the parcel gates
|
||||
-- Oak's Pokedex and the old man clearing Route 2, so vanilla guarantees
|
||||
-- it on entry rather than letting you walk out without it.
|
||||
--
|
||||
-- The talk branch below is kept as the fallback for a save that reaches
|
||||
-- the counter without this having fired.
|
||||
onEnter = function(game, ow)
|
||||
local f = game.save.flags
|
||||
if f.EVENT_OAK_GOT_PARCEL or f.EVENT_GOT_OAKS_PARCEL then return end
|
||||
if not f.EVENT_GOT_STARTER then return end
|
||||
ow:queueScript({
|
||||
{ "show_text", "_ViridianMartClerkYouCameFromPalletTownText" },
|
||||
{ "move_player", "left", 1 },
|
||||
{ "move_player", "up", 2 },
|
||||
-- the quest text's last page is "{PLAYER} got\nOAK's PARCEL!"
|
||||
{ "give_item", "OAKS_PARCEL", 1, "_ViridianMartClerkParcelQuestText" },
|
||||
{ "set_flag", "EVENT_GOT_OAKS_PARCEL" },
|
||||
})
|
||||
end,
|
||||
talk = {
|
||||
TEXT_VIRIDIANMART_CLERK = {
|
||||
{ "check_flag", "EVENT_OAK_GOT_PARCEL" }, -- 1
|
||||
@@ -33,42 +58,48 @@ M.VIRIDIAN_MART = {
|
||||
|
||||
M.VIRIDIAN_CITY = {
|
||||
talk = {
|
||||
-- the old man napping on the north path (scripts/ViridianCity.asm)
|
||||
-- after his coffee, the old man offers the catch tutorial: not in a
|
||||
-- hurry -> he demos catching a wild mon (BATTLE_TYPE_OLD_MAN)
|
||||
-- The GAMBLER_ASLEEP at (18,9) (ViridianCityOldManSleepyText): he
|
||||
-- only ever grumbles and shoves you back down -- he never wakes,
|
||||
-- moves or hides. The coffee ask and the catch tutorial belong to
|
||||
-- the *other* old man, the walking SPRITE_GAMBLER at (17,5)
|
||||
-- (TEXT_VIRIDIANCITY_OLD_MAN, below). The two are swapped by the
|
||||
-- Pokédex in data/scripts/oaks_lab.lua, not by talking to either.
|
||||
TEXT_VIRIDIANCITY_OLD_MAN_SLEEPY = {
|
||||
{ "check_flag", "EVENT_OAK_GOT_PARCEL" }, -- 1
|
||||
{ "jump_if_true", 5 }, -- 2
|
||||
{ "show_text", "_ViridianCityOldManSleepyPrivatePropertyText" }, -- 3
|
||||
{ "jump", 14 }, -- 4
|
||||
{ "face_player" }, -- 5
|
||||
{ "ask", "_ViridianCityOldManHadMyCoffeeNowText" }, -- 6
|
||||
{ "jump_if_true", 12 }, -- 7 (in a hurry)
|
||||
{ "show_text", "_ViridianCityOldManKnowHowToCatchPokemonText" }, -- 8
|
||||
{ "show_text", "_ViridianCityOldManYouNeedToWeakenTheTargetText" }, -- 9
|
||||
{ "old_man_demo" }, -- 10
|
||||
{ "jump", 13 }, -- 11
|
||||
{ "show_text", "_ViridianCityOldManTimeIsMoneyText" }, -- 12
|
||||
{ "hide_object", "VIRIDIAN_CITY", "VIRIDIANCITY_OLD_MAN_SLEEPY" }, -- 13
|
||||
{ "show_text", "_ViridianCityOldManSleepyPrivatePropertyText" }, -- 1
|
||||
{ "move_player", "down", 1 }, -- 2
|
||||
},
|
||||
|
||||
-- The walking old man at (17,5), shown once the Pokédex swaps him in
|
||||
-- (ViridianCityOldManText). "Are you in a hurry?" -- YES brushes you
|
||||
-- off, NO leads into the catch tutorial: he explains, demos a catch
|
||||
-- on a wild WEEDLE (BATTLE_TYPE_OLD_MAN), then comments afterwards.
|
||||
-- pokered prints YouNeedToWeakenTheTarget *after* the demo battle
|
||||
-- (ViridianCityOldManEndCatchTrainingScript), not before it.
|
||||
TEXT_VIRIDIANCITY_OLD_MAN = {
|
||||
{ "face_player" }, -- 1
|
||||
{ "ask", "_ViridianCityOldManHadMyCoffeeNowText" }, -- 2
|
||||
{ "jump_if_true", 8 }, -- 3 (yes = in a hurry)
|
||||
{ "show_text", "_ViridianCityOldManKnowHowToCatchPokemonText" }, -- 4
|
||||
{ "old_man_demo" }, -- 5
|
||||
{ "show_text", "_ViridianCityOldManYouNeedToWeakenTheTargetText" },-- 6
|
||||
{ "jump", 9 }, -- 7
|
||||
{ "show_text", "_ViridianCityOldManTimeIsMoneyText" }, -- 8 (9 = end)
|
||||
},
|
||||
},
|
||||
-- the sleeping old man lies across the Route 22 path (18,9): until
|
||||
-- he moves you can't slip past on either side (scripts/ViridianCity
|
||||
-- blocks the whole corridor, not just his tile)
|
||||
-- ViridianCityCheckGotPokedexScript: the north corridor is gated on
|
||||
-- EVENT_GOT_POKEDEX, NOT on the sleeper being hidden, and it triggers
|
||||
-- on exactly one cell -- (19,9), the gap east of the sleeper (18,9)
|
||||
-- and the girl (17,9). With the Pokédex the check returns immediately
|
||||
-- and you simply walk past at x=19.
|
||||
onStep = function(game, ow, x, y)
|
||||
local gone = game.save.objectToggles and game.save.objectToggles.VIRIDIAN_CITY
|
||||
and game.save.objectToggles.VIRIDIAN_CITY.VIRIDIANCITY_OLD_MAN_SLEEPY == false
|
||||
if gone then return false end
|
||||
-- crossing north of his row through the 3-wide gap (x 17-19, y<=8)
|
||||
if y <= 8 and x >= 17 and x <= 19 then
|
||||
local TextBox = require("src.render.TextBox")
|
||||
game.stack:push(TextBox.new(game,
|
||||
game.data.text._ViridianCityOldManSleepyPrivatePropertyText
|
||||
or "You can't go\nthrough here!\fThis is private\nproperty!",
|
||||
function() ow:scriptMove(ow.player, "down", 1) end))
|
||||
return true
|
||||
end
|
||||
return false
|
||||
if game.save.flags and game.save.flags.EVENT_GOT_POKEDEX then return false end
|
||||
if x ~= 19 or y ~= 9 then return false end
|
||||
local TextBox = require("src.render.TextBox")
|
||||
game.stack:push(TextBox.new(game,
|
||||
game.data.text._ViridianCityOldManSleepyPrivatePropertyText
|
||||
or "You can't go\nthrough here!\fThis is private\nproperty!",
|
||||
function() ow:scriptMove(ow.player, "down", 1) end))
|
||||
return true
|
||||
end,
|
||||
}
|
||||
|
||||
@@ -104,7 +135,7 @@ M.BILLS_HOUSE = {
|
||||
talk = {
|
||||
TEXT_BILLSHOUSE_BILL_POKEMON = {
|
||||
{ "check_flag", "EVENT_GOT_SS_TICKET" }, -- 1
|
||||
{ "jump_if_true", 11 }, -- 2
|
||||
{ "jump_if_true", 13 }, -- 2
|
||||
{ "show_text", "_BillsHouseBillImNotAPokemonText" }, -- 3
|
||||
{ "show_text", "_BillsHouseBillNoYouGottaHelpText" }, -- 4
|
||||
-- the cell-separator PC throws its switch
|
||||
@@ -118,8 +149,21 @@ M.BILLS_HOUSE = {
|
||||
{ "give_item", "S_S_TICKET", 1, false }, -- 7
|
||||
{ "show_text", "_SSTicketReceivedText" }, -- 8
|
||||
{ "set_flag", "EVENT_GOT_SS_TICKET" }, -- 9
|
||||
{ "jump", 12 }, -- 10
|
||||
{ "show_text", "_BillsHouseBillCheckOutMyRarePokemonText" }, -- 11
|
||||
-- The two Cerulean guards are a SWAP PAIR, not scenery
|
||||
-- (BillsHouse.asm:174-178): handing over the ticket shows GUARD1 at
|
||||
-- (28,12) and hides GUARD2 at (27,12). This matters far more than it
|
||||
-- looks: (27,12) is the ONLY walkable neighbour of the trashed
|
||||
-- house's south door at (27,11), and that house is one of the two
|
||||
-- ways through the fence that splits Cerulean in half (the badge
|
||||
-- house is the other). Leaving GUARD2 up forever severs the city --
|
||||
-- the gym/mart half can never reach the Route 5 exit -- which is
|
||||
-- exactly what stranded the bot after it beat Misty.
|
||||
-- Same swap fires after the TM28 Rocket (CeruleanCity_2.asm
|
||||
-- CeruleanHideRocket), so either route opens the path.
|
||||
{ "show_object", "CERULEAN_CITY", "CERULEANCITY_GUARD1" }, -- 10
|
||||
{ "hide_object", "CERULEAN_CITY", "CERULEANCITY_GUARD2" }, -- 11
|
||||
{ "jump", 14 }, -- 12
|
||||
{ "show_text", "_BillsHouseBillCheckOutMyRarePokemonText" }, -- 13
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
@@ -278,6 +278,23 @@ M.PALLET_TOWN = {
|
||||
|
||||
local DRINKS = { "FRESH_WATER", "SODA_POP", "LEMONADE" }
|
||||
|
||||
-- Hand over the first drink in the bag, if any. Mirrors RemoveGuardDrink
|
||||
-- (engine/items/inventory.asm), which walks the same three item ids and
|
||||
-- removes ONE, and the caller's BIT_GAVE_SAFFRON_GUARDS_DRINK.
|
||||
local function takeGuardDrink(game)
|
||||
for _, drink in ipairs(DRINKS) do
|
||||
if (game.save.inventory[drink] or 0) > 0 then
|
||||
game.save.inventory[drink] = game.save.inventory[drink] - 1
|
||||
if game.save.inventory[drink] == 0 then
|
||||
game.save.inventory[drink] = nil
|
||||
end
|
||||
game.save.flags.EVENT_GAVE_GUARDS_DRINK = true
|
||||
return true
|
||||
end
|
||||
end
|
||||
return false
|
||||
end
|
||||
|
||||
local function saffronGate(guardText, triggers, horizontal)
|
||||
return {
|
||||
talk = {
|
||||
@@ -289,18 +306,11 @@ local function saffronGate(guardText, triggers, horizontal)
|
||||
t._SaffronGateGuardThanksForTheDrinkText or "Gee, that was\ntasty!", done))
|
||||
return
|
||||
end
|
||||
for _, drink in ipairs(DRINKS) do
|
||||
if (game.save.inventory[drink] or 0) > 0 then
|
||||
game.save.inventory[drink] = game.save.inventory[drink] - 1
|
||||
if game.save.inventory[drink] == 0 then
|
||||
game.save.inventory[drink] = nil
|
||||
end
|
||||
game.save.flags.EVENT_GAVE_GUARDS_DRINK = true
|
||||
game.stack:push(TextBox.new(game,
|
||||
(t._SaffronGateGuardYouCanGoOnThroughText or
|
||||
"Thanks! You can\ngo on through!"), done))
|
||||
return
|
||||
end
|
||||
if takeGuardDrink(game) then
|
||||
game.stack:push(TextBox.new(game,
|
||||
(t._SaffronGateGuardYouCanGoOnThroughText or
|
||||
"Thanks! You can\ngo on through!"), done))
|
||||
return
|
||||
end
|
||||
game.stack:push(TextBox.new(game,
|
||||
t._SaffronGateGuardGeeImThirstyText or "Gee, I'm thirsty\nthough!", done))
|
||||
@@ -317,6 +327,27 @@ local function saffronGate(guardText, triggers, horizontal)
|
||||
if game.save.flags.EVENT_GAVE_GUARDS_DRINK then return false end
|
||||
local TextBox = require("src.render.TextBox")
|
||||
local t = game.data.text
|
||||
-- Stepping on the trigger WITH a drink hands it over right here.
|
||||
--
|
||||
-- Route5GateDefaultScript (scripts/Route5Gate.asm) runs
|
||||
-- `farcall RemoveGuardDrink` before it decides anything: the coord
|
||||
-- trigger itself takes the drink and sets
|
||||
-- BIT_GAVE_SAFFRON_GUARDS_DRINK, and only a player carrying nothing
|
||||
-- gets the thirsty line and the walk-back. We had the removal on the
|
||||
-- guard's TALK handler only, so walking up with a FRESH_WATER in the
|
||||
-- bag was turned away and the four gates stayed shut unless you
|
||||
-- happened to talk to him -- which vanilla never requires.
|
||||
--
|
||||
-- Saffron is the middle of the map, so this sealed it: every route
|
||||
-- through the city (Celadon <-> Lavender, Vermilion <-> Cerulean the
|
||||
-- short way) was unreachable, and the bot could not get to Lavender
|
||||
-- for the POKE_FLUTE at all.
|
||||
if takeGuardDrink(game) then
|
||||
game.stack:push(TextBox.new(game,
|
||||
(t._SaffronGateGuardYouCanGoOnThroughText or
|
||||
"Thanks! You can\ngo on through!")))
|
||||
return true
|
||||
end
|
||||
local back
|
||||
if horizontal then
|
||||
back = ow.player.facing == "left" and "right" or "left"
|
||||
|
||||
@@ -63,7 +63,15 @@ M.ROUTE_12_SUPER_ROD_HOUSE.talk.TEXT_ROUTE12SUPERRODHOUSE_FISHING_GURU[9] =
|
||||
|
||||
-- -------------------------------------------------------------------
|
||||
-- The ghost Marowak (scripts/PokemonTower6F.asm): blocks the stairs at
|
||||
-- (10,16) until identified with the Silph Scope and defeated.
|
||||
-- (10,16) until defeated.
|
||||
--
|
||||
-- PokemonTower6FDefaultScript starts the RESTLESS SOUL battle with NO
|
||||
-- Silph Scope check at the trigger -- the scope only decides whether the
|
||||
-- battle is disguised (IsGhostBattle -> makeGhost: "too scared to move",
|
||||
-- balls dodged). An earlier version of this port turned the player back
|
||||
-- without the scope and never opened the battle, which made 6F
|
||||
-- impassable on any route that skips Rocket Hideout; vanilla lets the
|
||||
-- battle open and a POKE_DOLL end it (see wBattleResult below).
|
||||
-- -------------------------------------------------------------------
|
||||
|
||||
M.POKEMON_TOWER_6F = {
|
||||
@@ -71,24 +79,30 @@ M.POKEMON_TOWER_6F = {
|
||||
if game.save.flags.EVENT_BEAT_GHOST_MAROWAK then return false end
|
||||
if x ~= 10 or y ~= 16 then return false end
|
||||
local TextBox = require("src.render.TextBox")
|
||||
if not game.save.inventory.SILPH_SCOPE then
|
||||
game.stack:push(TextBox.new(game,
|
||||
"A GHOST blocks\nthe way...\fDarn! You can't\nidentify it!",
|
||||
function()
|
||||
local back = ow.player.facing == "up" and "down" or "up"
|
||||
ow:scriptMove(ow.player, back, 1)
|
||||
end))
|
||||
return true
|
||||
end
|
||||
local t = game.data.text
|
||||
game.stack:push(TextBox.new(game,
|
||||
"The GHOST was\nMAROWAK!\fThe restless soul\nattacks!", function()
|
||||
t._PokemonTower6FBeGoneText or "Be gone...\nIntruders...", function()
|
||||
local BattleState = require("src.battle.BattleState")
|
||||
local battle = BattleState.newWild(game, "MAROWAK", 30)
|
||||
if not game.save.inventory.SILPH_SCOPE then
|
||||
battle:makeGhost()
|
||||
end
|
||||
battle.onFinish = function(result)
|
||||
if result == "win" then
|
||||
-- wBattleResult parity (PokemonTower6FMarowakBattleScript's
|
||||
-- "and a / jr nz"): losing writes $1 and running writes $2, but
|
||||
-- ItemUsePokeDoll ends the battle WITHOUT touching it, so the
|
||||
-- script reads 0 -- defeated. That is the famous Poke Doll
|
||||
-- trick, and the speedrun route this bot follows depends on it.
|
||||
if result == "win" or battle.pokeDollEscape then
|
||||
game.save.flags.EVENT_BEAT_GHOST_MAROWAK = true
|
||||
game.stack:push(TextBox.new(game,
|
||||
"The restless soul\ncalmed down and\ndeparted!"))
|
||||
t._PokemonTower6FSoulWasCalmedText
|
||||
or "The mother's soul\nwas calmed.\012It departed to\nthe afterlife!"))
|
||||
elseif result ~= "lose" then
|
||||
-- .did_not_defeat: one simulated step right, off the trigger,
|
||||
-- so fleeing does not leave you standing on a cell that
|
||||
-- immediately re-fires.
|
||||
ow:scriptMove(ow.player, "right", 1)
|
||||
end
|
||||
ow:afterBattle(result)
|
||||
end
|
||||
|
||||
@@ -360,7 +360,7 @@ local rocketRows = {
|
||||
{ "jump_if_true", 9 }, -- 5
|
||||
{ "show_text", "_CeruleanCityRocketText" }, -- 6
|
||||
{ "start_battle", "trainer", "OPP_ROCKET", 5 }, -- 7
|
||||
{ "jump_if_false", 16 }, -- 8
|
||||
{ "jump_if_false", 18 }, -- 8
|
||||
{ "show_text", "_CeruleanCityRocketIllReturnTheTMText" }, -- 9
|
||||
{ "set_flag", "EVENT_BEAT_CERULEAN_ROCKET_THIEF" }, -- 10
|
||||
{ "give_item", "TM_DIG", 1, false }, -- 11 (row 13 prints)
|
||||
@@ -368,6 +368,15 @@ local rocketRows = {
|
||||
{ "show_text", "_CeruleanCityRocketReceivedTM28Text" }, -- 13
|
||||
{ "show_text", "_CeruleanCityRocketIBetterGetMovingText" }, -- 14
|
||||
{ "hide_object", "CERULEAN_CITY", "CERULEANCITY_ROCKET" }, -- 15
|
||||
-- CeruleanHideRocket (scripts/CeruleanCity_2.asm) does all three behind
|
||||
-- one fade: the ROCKET goes, GUARD1 (28,12) appears and GUARD2 (27,12)
|
||||
-- disappears. GUARD2 stands on the only walkable neighbour of the
|
||||
-- trashed house's south door, which is one of the two ways through the
|
||||
-- fence dividing Cerulean, so this swap is what reconnects the city.
|
||||
-- Bill's ticket performs the same swap (data/scripts/story.lua), and
|
||||
-- both are idempotent, so whichever the player reaches first opens it.
|
||||
{ "show_object", "CERULEAN_CITY", "CERULEANCITY_GUARD1" }, -- 16
|
||||
{ "hide_object", "CERULEAN_CITY", "CERULEANCITY_GUARD2" }, -- 17
|
||||
}
|
||||
|
||||
M.CERULEAN_CITY = {
|
||||
|
||||
@@ -1,58 +1,19 @@
|
||||
# Native modding
|
||||
|
||||
The game has a built-in Lua mod runtime. Mods are installed under the LÖVE
|
||||
save directory in `mods/<id>/` and are loaded after the verified ROM data has
|
||||
been imported but before the title screen is created.
|
||||
The modding book lives on the
|
||||
[project wiki](https://github.com/bryanthaboi/pokemon-gen1-recomp-project/wiki).
|
||||
|
||||
## Minimal mod
|
||||
- [Getting started](https://github.com/bryanthaboi/pokemon-gen1-recomp-project/wiki/Getting-Started)
|
||||
— install a mod, write a first one, enable and disable it.
|
||||
- [Tutorials](https://github.com/bryanthaboi/pokemon-gen1-recomp-project/wiki/Tutorials)
|
||||
— twelve dependency-ordered rungs, each a runnable mod.
|
||||
- [Cookbook](https://github.com/bryanthaboi/pokemon-gen1-recomp-project/wiki/Cookbook)
|
||||
— task-sized recipes.
|
||||
- [Registry reference](https://github.com/bryanthaboi/pokemon-gen1-recomp-project/wiki/Reference-Registries)
|
||||
— every registry, generated from `src/mods/Schemas.lua`.
|
||||
|
||||
```text
|
||||
mods/example_mod/
|
||||
├── manifest.json
|
||||
└── main.lua
|
||||
Regenerate the reference straight into a wiki checkout:
|
||||
|
||||
```sh
|
||||
luajit tools/gen_registry_docs.lua ../pokemon-gen1-recomp-project.wiki
|
||||
```
|
||||
|
||||
`manifest.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "example_mod",
|
||||
"name": "Example Mod",
|
||||
"version": "1.0.0",
|
||||
"entry": "main.lua",
|
||||
"priority": 0,
|
||||
"dependencies": [],
|
||||
"optional_dependencies": [],
|
||||
"conflicts": []
|
||||
}
|
||||
```
|
||||
|
||||
`main.lua`:
|
||||
|
||||
```lua
|
||||
return function(mod)
|
||||
mod.log:info("hello from a native mod")
|
||||
|
||||
mod.content.pokemon:override("PIKACHU", {
|
||||
name = "PIKACHU",
|
||||
types = { "ELECTRIC" },
|
||||
base_stats = { hp = 35, attack = 55, defense = 40, speed = 90, special = 50 },
|
||||
})
|
||||
|
||||
mod.events:on("battle.start", function(context)
|
||||
context.mod_message = "A native mod changed this battle."
|
||||
end)
|
||||
end
|
||||
```
|
||||
|
||||
Mods should use registries and events instead of requiring private engine
|
||||
modules. Registries currently cover Pokémon, moves, items, maps, tilesets,
|
||||
encounters, trainers, sprites, music, audio, text, scripts, and UI.
|
||||
|
||||
Enablement is stored in the normal persistent `options.lua` file alongside
|
||||
audio, display, and battle settings, so starting a new game does not disable
|
||||
the selected mods. Changes take effect after restarting the game.
|
||||
|
||||
The loader deliberately does not import or execute arbitrary ROM-hack patches.
|
||||
The supported content source remains the verified base Pokémon Red ROM plus
|
||||
native mods.
|
||||
|
||||
@@ -13,6 +13,19 @@ local driverCo -- optional frame-driver (POKEPORT_DRIVER=file.lua): a
|
||||
-- coroutine that receives `Game` and yields once per
|
||||
-- frame; used headless (xvfb) for scripted screenshots
|
||||
|
||||
-- --speed N / POKEPORT_SPEED=N: run the logic clock N times faster without
|
||||
-- touching audio (src/core/GameSpeed.lua). Overrides the saved option so a
|
||||
-- bot or screenshot run is not at the mercy of the player's last choice.
|
||||
local speedOverride = tonumber(os.getenv("POKEPORT_SPEED"))
|
||||
|
||||
-- How many times to run a scripted act+step loop per rendered frame. Only
|
||||
-- scripted runs use this; interactive play fast-forwards through
|
||||
-- Game.speedOverride / the GAME SPEED option instead.
|
||||
local function scriptedIterations()
|
||||
if not (autopilot or driverCo) then return 1 end
|
||||
return math.max(1, math.floor(require("src.core.GameSpeed").clamp(speedOverride)))
|
||||
end
|
||||
|
||||
local function bootGame()
|
||||
Game = require("src.core.Game")
|
||||
Game:load()
|
||||
@@ -24,6 +37,10 @@ local function bootGame()
|
||||
local fn = assert(loadfile(driverPath))()
|
||||
driverCo = coroutine.create(fn)
|
||||
end
|
||||
-- After the two above are known: a scripted run drives the multiplier
|
||||
-- from love.update's loop, so the in-engine one must stay at 1 or the
|
||||
-- two would compound (10x10 = 100 steps per observation).
|
||||
Game.speedOverride = (autopilot or driverCo) and 1 or speedOverride
|
||||
end
|
||||
|
||||
function love.load(args)
|
||||
@@ -33,6 +50,8 @@ function love.load(args)
|
||||
editorMode = true
|
||||
elseif a == "--save" and args[i + 1] and args[i + 1] ~= "" then
|
||||
savePath = args[i + 1]
|
||||
elseif a == "--speed" and tonumber(args[i + 1]) then
|
||||
speedOverride = tonumber(args[i + 1])
|
||||
end
|
||||
end
|
||||
love.graphics.setDefaultFilter("nearest", "nearest")
|
||||
@@ -67,23 +86,36 @@ function love.update(dt)
|
||||
if editorMode then return EditorApp.update(dt) end
|
||||
if Importer then return Importer:update(dt) end
|
||||
|
||||
-- Scripted runs (autopilot / POKEPORT_DRIVER) observe and act exactly
|
||||
-- once per Game:update, so they must keep a 1:1 relationship with the
|
||||
-- logic step. Fast-forwarding them by scaling the step inside
|
||||
-- Game:update would run N steps per observation: a held direction walks
|
||||
-- through all N, the player slides past the waypoint, and the script
|
||||
-- re-plans from an overshot cell. So iterate the whole act+step loop
|
||||
-- instead -- same script, just more of it per rendered frame.
|
||||
local iterations = scriptedIterations()
|
||||
|
||||
if autopilot then
|
||||
autopilot.update()
|
||||
Game:update(1 / 60) -- deterministic stepping for the autopilot
|
||||
for _ = 1, iterations do
|
||||
autopilot.update()
|
||||
Game:update(1 / 60) -- deterministic stepping for the autopilot
|
||||
end
|
||||
return
|
||||
end
|
||||
if driverCo then
|
||||
local ok, err = coroutine.resume(driverCo, Game)
|
||||
if not ok then
|
||||
print("driver error: " .. tostring(err))
|
||||
love.event.quit(1)
|
||||
return
|
||||
for _ = 1, iterations do
|
||||
local ok, err = coroutine.resume(driverCo, Game)
|
||||
if not ok then
|
||||
print("driver error: " .. tostring(err))
|
||||
love.event.quit(1)
|
||||
return
|
||||
end
|
||||
if coroutine.status(driverCo) == "dead" then
|
||||
love.event.quit()
|
||||
return
|
||||
end
|
||||
Game:update(1 / 60)
|
||||
end
|
||||
if coroutine.status(driverCo) == "dead" then
|
||||
love.event.quit()
|
||||
return
|
||||
end
|
||||
Game:update(1 / 60)
|
||||
return
|
||||
end
|
||||
Game:update(dt)
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
-- Sharing metadata (25-community-and-ecosystem.md 3.2). Read by tooling
|
||||
-- and the manager detail pane; never by the loader's merge.
|
||||
--
|
||||
-- Gallery entry #0, the legacy example. Its manifest stays api 1 with
|
||||
-- category GAMEPLAY on purpose: it is the compatibility proof that a mod
|
||||
-- written before manifest v2 keeps loading unchanged. The v2 examples
|
||||
-- live in mods/examples/.
|
||||
return {
|
||||
summary = "Oak's Charmander gift becomes a level 20 Mew with inverted sprites.",
|
||||
author = "Pokemon Gen 1 Recompilation Project",
|
||||
contact = "https://github.com/bryanthaboi/pokemon-gen1-recomp-project",
|
||||
tags = { "beginner", "cosmetic", "data-only", "legacy" },
|
||||
differences = {
|
||||
changed = {
|
||||
"the Oak's Lab starter gift becomes a level 20 Mew nicknamed HOGHEAD",
|
||||
"MEW's front and back battle sprites point at inverted copies",
|
||||
},
|
||||
added = {},
|
||||
known = {
|
||||
"assets/ is empty until tools/generate_example_mod_sprite.py runs "
|
||||
.. "against an imported cache",
|
||||
"api 1: uses override where a v2 mod would use patch",
|
||||
},
|
||||
},
|
||||
credits = {
|
||||
{ who = "pret/pokered", for_ = "the Mew sprites the local generator inverts" },
|
||||
},
|
||||
compat = { engine = ">=1.0.0 <2.0.0", modApi = 1 },
|
||||
}
|
||||
@@ -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")
|
||||
@@ -0,0 +1,194 @@
|
||||
#!/usr/bin/env bash
|
||||
# Unified test entry point (21-testing-and-ci §CI).
|
||||
#
|
||||
# Runs every tier that this checkout can run and exits non-zero if any of
|
||||
# them fails. The tier split is what makes that possible: T1/T2/T4 need
|
||||
# nothing but the committed fixture dataset, so they run anywhere --
|
||||
# including CI, which has no ROM. T3 asserts Pokemon Red facts and needs
|
||||
# data/generated/, so it is skipped automatically when the ROM has never
|
||||
# been imported rather than failing the run.
|
||||
#
|
||||
# scripts/test.sh every tier this checkout can run
|
||||
# scripts/test.sh --quick skip the slow content tier
|
||||
# scripts/test.sh --bless re-pin the fingerprint goldens
|
||||
# WITH_SHOTS=1 scripts/test.sh also capture and diff golden shots
|
||||
# (fails today -- see the T5 block below)
|
||||
#
|
||||
# LUA overrides the interpreter (luajit here; CI installs lua5.4 too, but
|
||||
# the engine targets LuaJIT/5.1 semantics so luajit is the default).
|
||||
|
||||
set -uo pipefail
|
||||
|
||||
cd "$(dirname "$0")/.."
|
||||
|
||||
LUA=${LUA:-luajit}
|
||||
BLESS=0
|
||||
QUICK=0
|
||||
SHOTS=${WITH_SHOTS:-0}
|
||||
|
||||
for arg in "$@"; do
|
||||
case "$arg" in
|
||||
--bless) BLESS=1 ;;
|
||||
--bless-shots) SHOTS=1; BLESS=1 ;;
|
||||
--quick) QUICK=1 ;;
|
||||
--help|-h) sed -n '2,20p' "$0" | sed 's/^# \{0,1\}//'; exit 0 ;;
|
||||
*) echo "unknown option: $arg" >&2; exit 2 ;;
|
||||
esac
|
||||
done
|
||||
|
||||
if ! command -v "$LUA" >/dev/null 2>&1; then
|
||||
echo "no lua interpreter '$LUA' on PATH (set LUA=...)" >&2
|
||||
exit 2
|
||||
fi
|
||||
|
||||
# The save-directory sandbox (conf.lua reads POKEPORT_IDENTITY) is scoped
|
||||
# to the shot tier, which is the only one that starts a real LOVE process
|
||||
# and could write into a developer's save folder. Exporting it for the
|
||||
# whole run instead would change what SaveIO.defaultPath() returns, and the
|
||||
# save-editor suite pins that to the default identity.
|
||||
SANDBOX_IDENTITY="ci-$$"
|
||||
|
||||
FAILED=()
|
||||
run_tier() {
|
||||
local label="$1"; shift
|
||||
echo ""
|
||||
echo "=============================================================="
|
||||
echo " $label"
|
||||
echo "=============================================================="
|
||||
if "$@"; then
|
||||
echo "-- $label: PASS"
|
||||
else
|
||||
echo "-- $label: FAIL"
|
||||
FAILED+=("$label")
|
||||
fi
|
||||
}
|
||||
|
||||
# ------- ROM-free tiers: these are what CI runs
|
||||
|
||||
run_tier "T1/T2 engine invariants + parity gates" "$LUA" tests/run_engine.lua
|
||||
run_tier "T4 mod-SDK" "$LUA" tests/run_modkit.lua
|
||||
|
||||
# The modded-link desync suite (symmetric mod, handshake fail-closed,
|
||||
# extra-bag round trip) is ROM-free and runs inside the T4 tier above, as
|
||||
# tests/modkit/cases/link_desync.lua.
|
||||
#
|
||||
# tests/run_link_tests.lua is a different matter: it calls Data:load() at
|
||||
# :27 and so needs data/generated/. It is grouped with the content tier
|
||||
# until that bootstrap can take an injected dataset.
|
||||
# ------- content tier: only meaningful with an imported ROM
|
||||
|
||||
|
||||
# tests/run_tests.lua is expected to be clean. It used to carry two stale
|
||||
# chip-audio assertions on the allowlist below (Pikachu cry WAV exists /
|
||||
# low-health alarm sfx extracted); both have since been fixed, so the
|
||||
# baseline is zero and any failure fails the tier. Keep the allowlist
|
||||
# mechanism rather than ignoring the exit code -- that would hide every
|
||||
# future content regression.
|
||||
KNOWN_CONTENT_FAILURES=0
|
||||
KNOWN_CONTENT_LINES=""
|
||||
|
||||
run_content_behavior() {
|
||||
local out
|
||||
out=$("$LUA" tests/run_tests.lua 2>&1)
|
||||
local count
|
||||
count=$(printf '%s\n' "$out" | grep -c '^FAIL ' || true)
|
||||
local lines
|
||||
lines=$(printf '%s\n' "$out" | grep '^FAIL ' | sort)
|
||||
|
||||
if [ "$count" -eq "$KNOWN_CONTENT_FAILURES" ] \
|
||||
&& [ "$lines" = "$(printf '%s\n' "$KNOWN_CONTENT_LINES" | sort)" ]; then
|
||||
printf '%s\n' "$out" | tail -3
|
||||
if [ "$KNOWN_CONTENT_FAILURES" -gt 0 ]; then
|
||||
echo "(the $KNOWN_CONTENT_FAILURES known stale assertions, unchanged)"
|
||||
fi
|
||||
return 0
|
||||
fi
|
||||
|
||||
printf '%s\n' "$out" | grep '^FAIL ' || true
|
||||
printf '%s\n' "$out" | tail -2
|
||||
echo "expected exactly $KNOWN_CONTENT_FAILURES known failures; got $count"
|
||||
return 1
|
||||
}
|
||||
|
||||
if [ -f data/generated/maps.lua ]; then
|
||||
if [ "$QUICK" = "1" ]; then
|
||||
echo ""
|
||||
echo "-- T3 content: skipped (--quick)"
|
||||
else
|
||||
run_tier "T3 content behavior (Red)" run_content_behavior
|
||||
run_tier "T3 save editor" "$LUA" tests/run_save_editor_tests.lua
|
||||
run_tier "T5 link (loopback lockstep)" "$LUA" tests/run_link_tests.lua
|
||||
fi
|
||||
else
|
||||
echo ""
|
||||
echo "-- T3 content + run_link_tests: skipped (no data/generated/ --"
|
||||
echo " import a ROM to run them; the modded-link cases ran in T4)"
|
||||
fi
|
||||
|
||||
# ------- golden screenshots: needs love + a display
|
||||
|
||||
if [ "$SHOTS" = "1" ]; then
|
||||
SHOT_DIR=${SHOT_DIR:-/tmp/pokeport-shots}
|
||||
export SHOT_DIR
|
||||
mkdir -p "$SHOT_DIR"
|
||||
SHOT_DRIVER=tests/drivers/shots_fixture.lua
|
||||
|
||||
# The fixture goldens are not capturable yet. A driver only ever runs
|
||||
# after main.lua's bootGame(), so it cannot redirect Data:load(), and
|
||||
# src/core/Data.lua has no POKEPORT_DATA_DIR branch -- 21-testing-and-ci
|
||||
# §"Engine changes" specifies one, but it is not implemented, so a LOVE
|
||||
# process has no way to boot tests/fixture_data. On a ROM-less checkout
|
||||
# main.lua does not even reach the game: RomImporter.isReady() is false
|
||||
# and it opens the importer instead.
|
||||
#
|
||||
# WITH_SHOTS is opt-in, so asking for a tier that cannot run is an error,
|
||||
# not a skip. Reporting "pass" here is what made the whole pipeline look
|
||||
# delivered while never diffing a single pixel.
|
||||
if [ ! -f "$SHOT_DRIVER" ]; then
|
||||
echo ""
|
||||
echo "-- T5 shots: NOT WIRED ($SHOT_DRIVER does not exist)."
|
||||
echo " Fixture capture needs the POKEPORT_DATA_DIR override in"
|
||||
echo " src/core/Data.lua so LOVE can boot tests/fixture_data."
|
||||
FAILED+=("T5 shots (requested but not wired)")
|
||||
elif ! command -v love >/dev/null 2>&1; then
|
||||
echo ""
|
||||
echo "-- T5 shots: love is not on PATH but WITH_SHOTS was requested"
|
||||
FAILED+=("T5 shots (love missing)")
|
||||
else
|
||||
RUNNER="love ."
|
||||
command -v xvfb-run >/dev/null 2>&1 && RUNNER="xvfb-run -a love ."
|
||||
run_tier "T5 shot capture" \
|
||||
env POKEPORT_IDENTITY="$SANDBOX_IDENTITY" POKEPORT_DRIVER="$SHOT_DRIVER" $RUNNER
|
||||
if [ "$BLESS" = "1" ]; then
|
||||
run_tier "T5 shot bless" \
|
||||
python3 tools/compare_shots.py tests/goldens/shots "$SHOT_DIR" --bless
|
||||
else
|
||||
run_tier "T5 shot diff" \
|
||||
python3 tools/compare_shots.py tests/goldens/shots "$SHOT_DIR"
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
|
||||
# ------- fingerprint blessing
|
||||
|
||||
if [ "$BLESS" = "1" ] && [ "$SHOTS" != "1" ]; then
|
||||
echo ""
|
||||
echo "re-pinning fingerprint goldens (deliberate parity change -- record it"
|
||||
echo "in docs/known-differences.md or docs/new-features.md)"
|
||||
"$LUA" tests/bless_fingerprints.lua || FAILED+=("fingerprint bless")
|
||||
fi
|
||||
|
||||
# ------- verdict
|
||||
|
||||
echo ""
|
||||
echo "=============================================================="
|
||||
if [ ${#FAILED[@]} -eq 0 ]; then
|
||||
echo " ALL TIERS PASSED"
|
||||
echo "=============================================================="
|
||||
exit 0
|
||||
fi
|
||||
|
||||
echo " ${#FAILED[@]} TIER(S) FAILED"
|
||||
for tier in "${FAILED[@]}"; do echo " - $tier"; done
|
||||
echo "=============================================================="
|
||||
exit 1
|
||||
@@ -0,0 +1,408 @@
|
||||
-- Authoring DSL for the channel bytecode ChipAudio's Channel:nextEvent
|
||||
-- decodes: note-event Lua tables in, a self-contained program blob out.
|
||||
-- The blob is mounted as pseudo-bank 0 by ChipAudio, so addresses are based
|
||||
-- at 0x4000 exactly like the ROM's own 0x4000-window programs.
|
||||
--
|
||||
-- Two passes: the first sizes every event and records where each label and
|
||||
-- each event index lands, the second emits bytes with call/loop targets
|
||||
-- resolved. Nothing here touches love.*, so mods assemble at load time and
|
||||
-- headless tools assemble without a graphics context.
|
||||
|
||||
local ChipAsm = {}
|
||||
|
||||
local BASE_ADDRESS = 0x4000
|
||||
local FRAME_TICKS = 256
|
||||
|
||||
local NOTES = {
|
||||
C = 0, ["C#"] = 1, Db = 1, D = 2, ["D#"] = 3, Eb = 3, E = 4,
|
||||
F = 5, ["F#"] = 6, Gb = 6, G = 7, ["G#"] = 8, Ab = 8, A = 9,
|
||||
["A#"] = 10, Bb = 10, B = 11,
|
||||
}
|
||||
|
||||
-- mirrors ChipAudio's snapTicks so authored drums land on the same sample
|
||||
-- grid as the ROM's own drum tables
|
||||
local function snapTicks(ticks)
|
||||
return math.floor((ticks * 735 + 256) / 512)
|
||||
end
|
||||
|
||||
-- ------- validation
|
||||
|
||||
local Cursor = {}
|
||||
Cursor.__index = Cursor
|
||||
|
||||
local function cursor(channel, scope)
|
||||
return setmetatable({ channel = channel, scope = scope, index = 0 }, Cursor)
|
||||
end
|
||||
|
||||
function Cursor:fail(message)
|
||||
error(("ChipAsm: channel %d %sevent %d: %s")
|
||||
:format(self.channel, self.scope, self.index, message), 0)
|
||||
end
|
||||
|
||||
function Cursor:int(value, low, high, what)
|
||||
if type(value) ~= "number" or value ~= math.floor(value) then
|
||||
self:fail(("%s must be an integer, got %s"):format(what, tostring(value)))
|
||||
end
|
||||
if value < low or value > high then
|
||||
self:fail(("%s out of range %d-%d: %d"):format(what, low, high, value))
|
||||
end
|
||||
return value
|
||||
end
|
||||
|
||||
function Cursor:length(value)
|
||||
return self:int(value or 1, 1, 16, "len")
|
||||
end
|
||||
|
||||
-- the fade nibble is signed: bit 3 set means a decay of the low three bits
|
||||
function Cursor:fade(value)
|
||||
value = self:int(value or 0, -7, 7, "fade")
|
||||
if value < 0 then return 8 - value end
|
||||
return value
|
||||
end
|
||||
|
||||
function Cursor:pitch(event)
|
||||
if event.pitch ~= nil then return self:int(event.pitch, 0, 11, "pitch") end
|
||||
local name = event.note
|
||||
if type(name) ~= "string" then
|
||||
self:fail("note must be a name or an explicit pitch")
|
||||
end
|
||||
local pitch = NOTES[name]
|
||||
if not pitch then self:fail(("unknown note %q"):format(name)) end
|
||||
return pitch
|
||||
end
|
||||
|
||||
-- ------- pass 1: events to sized chunks
|
||||
-- a chunk is a byte, or a two-byte reference to a label / event index that
|
||||
-- pass 2 resolves once every channel's size is known
|
||||
|
||||
local function reference(cur, target)
|
||||
return {
|
||||
label = target, offsets = cur.offsets, ref = cur, index = cur.index,
|
||||
}
|
||||
end
|
||||
|
||||
local function emitters(cur, hw)
|
||||
local E = {}
|
||||
|
||||
function E.label() end
|
||||
|
||||
function E.note(event, out)
|
||||
if hw == 4 then cur:fail("channel 4 plays drums, not notes") end
|
||||
out[#out + 1] = cur:pitch(event) * 16 + cur:length(event.len) - 1
|
||||
end
|
||||
|
||||
function E.rest(event, out)
|
||||
local len = event.rest == true and event.len or event.rest
|
||||
out[#out + 1] = 0xC0 + cur:length(len) - 1
|
||||
end
|
||||
|
||||
function E.drum(event, out)
|
||||
if hw ~= 4 then cur:fail("drums only play on channel 4") end
|
||||
local id = cur:int(event.drum, 0, 255, "drum")
|
||||
local len = cur:length(event.len) - 1
|
||||
-- ids from 11 up do not fit the note nibble and carry an extra byte
|
||||
if id >= 11 then
|
||||
out[#out + 1] = 0xB0 + len
|
||||
out[#out + 1] = id
|
||||
else
|
||||
out[#out + 1] = id * 16 + len
|
||||
end
|
||||
end
|
||||
|
||||
-- the interpreter reads the packed byte per hardware channel: none on
|
||||
-- noise, wave level plus instrument on channel 3, volume plus fade on the
|
||||
-- two square channels
|
||||
function E.notetype(event, out)
|
||||
local spec = event.notetype
|
||||
out[#out + 1] = 0xD0 + cur:int(spec.speed or 12, 0, 15, "speed")
|
||||
if hw == 4 then return end
|
||||
if hw == 3 then
|
||||
out[#out + 1] = cur:int(spec.waveLevel or 0, 0, 3, "waveLevel") * 16
|
||||
+ cur:int(spec.waveInstrument or 0, 0, 15, "waveInstrument")
|
||||
else
|
||||
out[#out + 1] = cur:int(spec.volume or 0, 0, 15, "volume") * 16
|
||||
+ cur:fade(spec.fade)
|
||||
end
|
||||
end
|
||||
|
||||
function E.octave(event, out)
|
||||
out[#out + 1] = 0xE0 + 8 - cur:int(event.octave, 1, 8, "octave")
|
||||
end
|
||||
|
||||
function E.perfectPitch(_, out)
|
||||
out[#out + 1] = 0xE8
|
||||
end
|
||||
|
||||
function E.vibrato(event, out)
|
||||
local spec = event.vibrato
|
||||
out[#out + 1] = 0xEA
|
||||
out[#out + 1] = cur:int(spec.delay or 0, 0, 255, "vibrato delay")
|
||||
out[#out + 1] = cur:int(spec.depth or 0, 0, 15, "vibrato depth") * 16
|
||||
+ cur:int(spec.rate or 0, 0, 15, "vibrato rate")
|
||||
end
|
||||
|
||||
function E.slide(event, out)
|
||||
local spec = event.slide
|
||||
out[#out + 1] = 0xEB
|
||||
out[#out + 1] = cur:int(spec.len or 0, 0, 255, "slide len")
|
||||
out[#out + 1] = (8 - cur:int(spec.octave or 4, 1, 8, "slide octave")) * 16
|
||||
+ cur:pitch(spec)
|
||||
end
|
||||
|
||||
function E.duty(event, out)
|
||||
out[#out + 1] = 0xEC
|
||||
out[#out + 1] = cur:int(event.duty, 0, 3, "duty")
|
||||
end
|
||||
|
||||
function E.dutyPattern(event, out)
|
||||
local packed = 0
|
||||
for slot = 1, 4 do
|
||||
packed = packed * 4 + cur:int(event.dutyPattern[slot], 0, 3, "dutyPattern")
|
||||
end
|
||||
out[#out + 1] = 0xFC
|
||||
out[#out + 1] = packed
|
||||
end
|
||||
|
||||
function E.tempo(event, out)
|
||||
local tempo = cur:int(event.tempo, 0, 0xFFFF, "tempo")
|
||||
out[#out + 1] = 0xED
|
||||
out[#out + 1] = math.floor(tempo / 0x100)
|
||||
out[#out + 1] = tempo % 0x100
|
||||
end
|
||||
|
||||
function E.pan(event, out)
|
||||
out[#out + 1] = 0xEE
|
||||
out[#out + 1] = cur:int(event.pan, 0, 255, "pan")
|
||||
end
|
||||
|
||||
function E.executeMusic(_, out)
|
||||
out[#out + 1] = 0xF8
|
||||
end
|
||||
|
||||
function E.call(event, out)
|
||||
out[#out + 1] = 0xFD
|
||||
out[#out + 1] = reference(cur, event.call)
|
||||
end
|
||||
|
||||
function E.ret(_, out)
|
||||
out[#out + 1] = 0xFF
|
||||
end
|
||||
|
||||
function E.loop(event, out)
|
||||
out[#out + 1] = 0xFE
|
||||
out[#out + 1] = cur:int(event.loop.count or 0, 0, 255, "loop count")
|
||||
out[#out + 1] = reference(cur, event.loop.to)
|
||||
end
|
||||
|
||||
-- sfx-only: the 0x20-0x2F note form carries its own volume and fade plus
|
||||
-- either a raw frequency register or a noise parameter
|
||||
function E.squareNote(event, out)
|
||||
local spec = event.squareNote
|
||||
local register = cur:int(spec.frequency or 0, 0, 0x7FF, "frequency")
|
||||
out[#out + 1] = 0x20 + cur:length(spec.len) - 1
|
||||
out[#out + 1] = cur:int(spec.volume or 0, 0, 15, "volume") * 16
|
||||
+ cur:fade(spec.fade)
|
||||
out[#out + 1] = register % 0x100
|
||||
out[#out + 1] = math.floor(register / 0x100)
|
||||
end
|
||||
|
||||
function E.noiseNote(event, out)
|
||||
local spec = event.noiseNote
|
||||
out[#out + 1] = 0x20 + cur:length(spec.len) - 1
|
||||
out[#out + 1] = cur:int(spec.volume or 0, 0, 15, "volume") * 16
|
||||
+ cur:fade(spec.fade)
|
||||
out[#out + 1] = cur:int(spec.parameter or 0, 0, 255, "parameter")
|
||||
end
|
||||
|
||||
function E.pitchSweep(event, out)
|
||||
local spec = event.pitchSweep
|
||||
out[#out + 1] = 0x10
|
||||
out[#out + 1] = cur:int(spec.pace or 0, 0, 7, "sweep pace") * 16
|
||||
+ (spec.subtract and 8 or 0)
|
||||
+ cur:int(spec.shift or 0, 0, 7, "sweep shift")
|
||||
end
|
||||
|
||||
return E
|
||||
end
|
||||
|
||||
-- the event key that names the command, checked in a fixed order so an
|
||||
-- event carrying `len` alongside `note` is still a note
|
||||
local KEYS = {
|
||||
"label", "note", "pitch", "rest", "drum", "notetype", "octave",
|
||||
"perfectPitch", "vibrato", "slide", "duty", "dutyPattern", "tempo", "pan",
|
||||
"executeMusic", "call", "ret", "loop", "squareNote", "noiseNote",
|
||||
"pitchSweep",
|
||||
}
|
||||
|
||||
-- an unresolved reference is one chunk but two bytes, so offsets are counted
|
||||
-- rather than read off the chunk list's length
|
||||
local function byteSize(chunks)
|
||||
local size = 0
|
||||
for _, chunk in ipairs(chunks) do
|
||||
size = size + (type(chunk) == "table" and 2 or 1)
|
||||
end
|
||||
return size
|
||||
end
|
||||
|
||||
local function assembleStream(program, cur, E, out, offsets, labels)
|
||||
cur.offsets = offsets
|
||||
for index, event in ipairs(program) do
|
||||
cur.index = index
|
||||
if type(event) ~= "table" then cur:fail("event must be a table") end
|
||||
local offset = byteSize(out)
|
||||
offsets[index] = offset
|
||||
local kind
|
||||
for _, key in ipairs(KEYS) do
|
||||
if event[key] ~= nil then kind = key break end
|
||||
end
|
||||
if not kind then cur:fail("no command in event") end
|
||||
if kind == "pitch" then kind = "note" end
|
||||
if kind == "label" then labels[event.label] = offset end
|
||||
E[kind](event, out)
|
||||
end
|
||||
end
|
||||
|
||||
-- a stream that cannot fall off its end needs no terminator; anything else
|
||||
-- gets the endchannel byte the interpreter stops on
|
||||
local function endsItself(program)
|
||||
local last = program[#program]
|
||||
if type(last) ~= "table" then return false end
|
||||
if last.ret then return true end
|
||||
return last.loop ~= nil and (last.loop.count or 0) == 0
|
||||
end
|
||||
|
||||
local function assembleChannel(spec, hw, number, prelude)
|
||||
local cur = cursor(number, "")
|
||||
local out, offsets, labels = {}, {}, {}
|
||||
for _, byte in ipairs(prelude or {}) do out[#out + 1] = byte end
|
||||
local program = spec.program or {}
|
||||
assembleStream(program, cur, emitters(cur, hw), out, offsets, labels)
|
||||
if not endsItself(program) then out[#out + 1] = 0xFF end
|
||||
-- subroutines follow the body so every call target is inside the blob
|
||||
local names = {}
|
||||
for name in pairs(spec.subroutines or {}) do names[#names + 1] = name end
|
||||
table.sort(names)
|
||||
for _, name in ipairs(names) do
|
||||
labels[name] = byteSize(out)
|
||||
local sub = spec.subroutines[name]
|
||||
local subCursor = cursor(number, ("subroutine %q "):format(name))
|
||||
assembleStream(sub, subCursor, emitters(subCursor, hw), out, {}, labels)
|
||||
if not endsItself(sub) then out[#out + 1] = 0xFF end
|
||||
end
|
||||
return { bytes = out, labels = labels }
|
||||
end
|
||||
|
||||
-- ------- pass 2: resolve the references and pack the blob
|
||||
|
||||
local function targetAddress(chunk, labels, base)
|
||||
local target = chunk.label
|
||||
local offset
|
||||
if type(target) == "number" then
|
||||
offset = chunk.offsets[target]
|
||||
if not offset then
|
||||
chunk.ref.index = chunk.index
|
||||
chunk.ref:fail(("loop target event %d does not exist"):format(target))
|
||||
end
|
||||
else
|
||||
offset = labels[target]
|
||||
if not offset then
|
||||
chunk.ref.index = chunk.index
|
||||
chunk.ref:fail(("unknown label %q"):format(tostring(target)))
|
||||
end
|
||||
end
|
||||
return offset + base
|
||||
end
|
||||
|
||||
local function pack(channels, blobBase)
|
||||
local pieces = {}
|
||||
for _, channel in ipairs(channels) do
|
||||
local base = channel.base + blobBase
|
||||
for _, byte in ipairs(channel.bytes) do
|
||||
if type(byte) == "table" then
|
||||
local address = targetAddress(byte, channel.labels, base)
|
||||
pieces[#pieces + 1] = string.char(address % 0x100)
|
||||
pieces[#pieces + 1] = string.char(math.floor(address / 0x100) % 0x100)
|
||||
else
|
||||
pieces[#pieces + 1] = string.char(byte % 0x100)
|
||||
end
|
||||
end
|
||||
end
|
||||
return table.concat(pieces)
|
||||
end
|
||||
|
||||
-- friendly drum rows to the segment lists Engine:noiseInstrument caches
|
||||
local function drumSegments(rows)
|
||||
local drums = {}
|
||||
for id, program in pairs(rows) do
|
||||
local segments, ticks = {}, 0
|
||||
for index, row in ipairs(program) do
|
||||
local length = row.len or 1
|
||||
if type(length) ~= "number" or length < 1 or length > 16 then
|
||||
error(("ChipAsm: drum %s row %d: len out of range 1-16")
|
||||
:format(tostring(id), index), 0)
|
||||
end
|
||||
local duration = length * FRAME_TICKS
|
||||
segments[#segments + 1] = {
|
||||
startSample = snapTicks(ticks),
|
||||
endSample = snapTicks(ticks + duration),
|
||||
volume = row.volume or 0,
|
||||
fade = row.fade or 0,
|
||||
parameter = row.parameter or 0,
|
||||
}
|
||||
ticks = ticks + duration
|
||||
end
|
||||
drums[id] = segments
|
||||
end
|
||||
return drums
|
||||
end
|
||||
|
||||
local function assemble(spec, sfx)
|
||||
local channels, size = {}, 0
|
||||
for index, channelSpec in ipairs(spec.channels or {}) do
|
||||
local hw = channelSpec.hw or index
|
||||
if type(hw) ~= "number" or hw < 1 or hw > 4 then
|
||||
error(("ChipAsm: channel %d: hw must be 1-4"):format(index), 0)
|
||||
end
|
||||
-- the global tempo rides on the first channel, the way the ROM's own
|
||||
-- songs write it
|
||||
local prelude
|
||||
if index == 1 and spec.tempo and not sfx then
|
||||
local tempo = cursor(index, ""):int(spec.tempo, 0, 0xFFFF, "tempo")
|
||||
prelude = { 0xED, math.floor(tempo / 0x100), tempo % 0x100 }
|
||||
end
|
||||
local built = assembleChannel(channelSpec, hw, index, prelude)
|
||||
built.base = size
|
||||
-- effect programs live on channels 5-8, which is how the interpreter
|
||||
-- tells an effect's command set from a song's
|
||||
built.number = sfx and hw + 4 or hw
|
||||
size = size + byteSize(built.bytes)
|
||||
channels[#channels + 1] = built
|
||||
end
|
||||
local blob = pack(channels, BASE_ADDRESS)
|
||||
local layout = {}
|
||||
for _, channel in ipairs(channels) do
|
||||
layout[#layout + 1] = {
|
||||
number = channel.number,
|
||||
address = BASE_ADDRESS + channel.base,
|
||||
}
|
||||
end
|
||||
return {
|
||||
chip = {
|
||||
blob = blob,
|
||||
channels = layout,
|
||||
waves = spec.waves,
|
||||
drums = spec.drums and drumSegments(spec.drums) or nil,
|
||||
engine = spec.engine or 1,
|
||||
},
|
||||
}
|
||||
end
|
||||
|
||||
function ChipAsm.song(spec)
|
||||
return assemble(spec, false)
|
||||
end
|
||||
|
||||
function ChipAsm.sfx(spec)
|
||||
return assemble(spec, true)
|
||||
end
|
||||
|
||||
return ChipAsm
|
||||
@@ -422,8 +422,16 @@ function AnimPlayer:start(moveId, attackerIsPlayer, opts)
|
||||
-- effect runs AFTER each block displays, so block 1 shows normal.
|
||||
-- PlayAnimation pushes rOBP0 around every subanimation row (:246-251
|
||||
-- / :259-262), so the ambient palette returns when the toss ends.
|
||||
local ballFlicker = opts
|
||||
and (opts.ball == "MASTER_BALL" or opts.ball == "ULTRA_BALL")
|
||||
-- opts.ballFlicker carries the ball record's flicker flag; the id
|
||||
-- check covers callers that only pass the ball item.
|
||||
local wantsFlicker
|
||||
if opts and opts.ballFlicker ~= nil then
|
||||
wantsFlicker = opts.ballFlicker
|
||||
else
|
||||
wantsFlicker = opts
|
||||
and (opts.ball == "MASTER_BALL" or opts.ball == "ULTRA_BALL")
|
||||
end
|
||||
local ballFlicker = wantsFlicker
|
||||
and (moveId == "TOSS_ANIM" or moveId == "GREATTOSS_ANIM"
|
||||
or moveId == "ULTRATOSS_ANIM")
|
||||
local obp0Flip = false
|
||||
|
||||
@@ -1,45 +1,64 @@
|
||||
-- Gen 1 catch algorithm (engine/items/item_effects.asm, ItemUseBall).
|
||||
|
||||
local Status = require("src.battle.Status")
|
||||
|
||||
local Catching = {}
|
||||
|
||||
local BALL_RAND_MAX = { MASTER_BALL = 0, POKE_BALL = 255, GREAT_BALL = 200,
|
||||
ULTRA_BALL = 150, SAFARI_BALL = 150 }
|
||||
local BALL_HP_FACTOR = { POKE_BALL = 12, GREAT_BALL = 8, ULTRA_BALL = 12,
|
||||
SAFARI_BALL = 12 }
|
||||
-- randMax is the ceiling of the catch roll, hpFactor the X of the HP term,
|
||||
-- wobbleFactor the ballFactor2 divisor of the wobble math. MASTER_BALL
|
||||
-- never rolls (autoCatch), so its factors are unused. tossAnim picks the
|
||||
-- TossBallAnimation arc and flicker the Master/Ultra OBJ-palette strobe
|
||||
-- (DoBallTossSpecialEffects).
|
||||
local BALLS = {
|
||||
MASTER_BALL = { randMax = 0, autoCatch = true,
|
||||
tossAnim = "ULTRATOSS_ANIM", flicker = true },
|
||||
POKE_BALL = { randMax = 255, hpFactor = 12, wobbleFactor = 255,
|
||||
tossAnim = "TOSS_ANIM" },
|
||||
GREAT_BALL = { randMax = 200, hpFactor = 8, wobbleFactor = 200,
|
||||
tossAnim = "GREATTOSS_ANIM" },
|
||||
ULTRA_BALL = { randMax = 150, hpFactor = 12, wobbleFactor = 150,
|
||||
tossAnim = "ULTRATOSS_ANIM", flicker = true },
|
||||
SAFARI_BALL = { randMax = 150, hpFactor = 12, wobbleFactor = 150,
|
||||
tossAnim = "ULTRATOSS_ANIM" },
|
||||
}
|
||||
Catching.BALLS = BALLS
|
||||
|
||||
-- Returns caught, shakes (0-3). rateOverride replaces the species catch
|
||||
-- rate (the Safari game's BAIT/ROCK-modified wEnemyMonActualCatchRate).
|
||||
--
|
||||
-- On failure the ball wobbles per the original's shake calculation:
|
||||
-- Y = rate*100/ballFactor2 (255/200/150), Z = X*Y/255 + status2 (5/10)
|
||||
-- where X is the HP factor; Z<10: 0 shakes, <30: 1, <70: 2, else 3.
|
||||
-- (We use the HP factor for X on both failure paths; the original reads
|
||||
-- a stale quotient when the first roll fails.)
|
||||
function Catching.attempt(ball, targetMon, targetDef, rng, rateOverride)
|
||||
rng = rng or love.math.random
|
||||
if ball == "MASTER_BALL" then return true, 3 end
|
||||
local randMax = BALL_RAND_MAX[ball] or 255
|
||||
-- an unknown ball falls back to POKE_BALL's roll and the 150 wobble
|
||||
-- divisor, which is what the old per-field `or` defaults resolved to
|
||||
local DEFAULT_BALL = { randMax = 255, hpFactor = 12, wobbleFactor = 150 }
|
||||
|
||||
function Catching.registerInto(registry, _, owner)
|
||||
for id, record in pairs(BALLS) do
|
||||
registry:register(id, record, owner)
|
||||
end
|
||||
end
|
||||
|
||||
-- The stock ItemUseBall math. On failure the ball wobbles per the
|
||||
-- original's shake calculation: Y = rate*100/ballFactor2 (255/200/150),
|
||||
-- Z = X*Y/255 + status2 (5/10) where X is the HP factor; Z<10: 0 shakes,
|
||||
-- <30: 1, <70: 2, else 3. (We use the HP factor for X on both failure
|
||||
-- paths; the original reads a stale quotient when the first roll fails.)
|
||||
local function stockAttempt(def, targetMon, targetDef, rng, rateOverride, statuses)
|
||||
if def.autoCatch then return true, 3 end
|
||||
local randMax = def.randMax
|
||||
local rate = rateOverride or targetDef.catchRate
|
||||
|
||||
local statusBonus = 0
|
||||
-- the status subtraction and the wobble bonus come off the merged
|
||||
-- status record (SLP/FRZ 25 and +10, the rest 12 and +5)
|
||||
local s = targetMon.status
|
||||
if s == "SLP" or s == "FRZ" then
|
||||
statusBonus = 25
|
||||
elseif s == "PSN" or s == "BRN" or s == "PAR" then
|
||||
statusBonus = 12
|
||||
end
|
||||
local record = Status.recordFor(statuses, s)
|
||||
local statusBonus = record and record.catchBonus or 0
|
||||
|
||||
-- HP factor (X)
|
||||
local maxhp = targetMon.stats.hp
|
||||
local hpQuarter = math.max(1, math.floor(targetMon.hp / 4))
|
||||
local factor = BALL_HP_FACTOR[ball] or 12
|
||||
local factor = def.hpFactor or DEFAULT_BALL.hpFactor
|
||||
-- the 255 cap applies only after BOTH divisions (ItemUseBall keeps
|
||||
-- the intermediate in 16 bits); capping early collapses the value
|
||||
local f = math.min(255, math.floor(math.floor(maxhp * 255 / factor) / hpQuarter))
|
||||
|
||||
local function shakes()
|
||||
local ballFactor2 = ball == "POKE_BALL" and 255
|
||||
or ball == "GREAT_BALL" and 200 or 150
|
||||
local ballFactor2 = def.wobbleFactor or DEFAULT_BALL.wobbleFactor
|
||||
local y = math.floor(rate * 100 / ballFactor2)
|
||||
local z
|
||||
if y > 255 then
|
||||
@@ -47,10 +66,8 @@ function Catching.attempt(ball, targetMon, targetDef, rng, rateOverride)
|
||||
else
|
||||
z = math.floor(f * y / 255)
|
||||
end
|
||||
if s == "SLP" or s == "FRZ" then
|
||||
z = z + 10
|
||||
elseif s then
|
||||
z = z + 5
|
||||
if s then
|
||||
z = z + ((record and record.shakeBonus) or 5)
|
||||
end
|
||||
if z < 10 then return 0 elseif z < 30 then return 1
|
||||
elseif z < 70 then return 2 else return 3 end
|
||||
@@ -63,4 +80,29 @@ function Catching.attempt(ball, targetMon, targetDef, rng, rateOverride)
|
||||
return false, shakes()
|
||||
end
|
||||
|
||||
-- Returns caught, shakes (0-3). rateOverride replaces the species catch
|
||||
-- rate (the Safari game's BAIT/ROCK-modified wEnemyMonActualCatchRate).
|
||||
-- opts (all optional): ballDef = the merged ball record, statuses = the
|
||||
-- merged statuses table, battle = the running battle. A ball record's
|
||||
-- attempt fn supersedes the whole formula; its ctx.vanillaAttempt() runs
|
||||
-- the stock math with the ctx's (possibly rewritten) rateOverride.
|
||||
function Catching.attempt(ball, targetMon, targetDef, rng, rateOverride, opts)
|
||||
rng = rng or love.math.random
|
||||
opts = opts or {}
|
||||
local def = opts.ballDef or BALLS[ball] or DEFAULT_BALL
|
||||
local statuses = opts.statuses
|
||||
if def.attempt then
|
||||
local ctx = {
|
||||
ballDef = def, targetMon = targetMon, targetDef = targetDef,
|
||||
rng = rng, rateOverride = rateOverride, battle = opts.battle,
|
||||
}
|
||||
ctx.vanillaAttempt = function()
|
||||
return stockAttempt(def, targetMon, targetDef, rng, ctx.rateOverride,
|
||||
statuses)
|
||||
end
|
||||
return def.attempt(ctx)
|
||||
end
|
||||
return stockAttempt(def, targetMon, targetDef, rng, rateOverride, statuses)
|
||||
end
|
||||
|
||||
return Catching
|
||||
|
||||
@@ -3,27 +3,68 @@
|
||||
--
|
||||
-- Battlers carry curStats/curTypes (Transform/Conversion can override the
|
||||
-- species values) plus reflect/lightScreen/focusEnergy volatile flags.
|
||||
-- Battlers built by makeBattler also carry the merged badgeBoosts rows and
|
||||
-- statuses records; hand-built battlers fall back to the vanilla tables.
|
||||
|
||||
local Logger = require("src.core.Logger")
|
||||
local Runtime = require("src.mods.Runtime")
|
||||
local Stats = require("src.pokemon.Stats")
|
||||
local Status = require("src.battle.Status")
|
||||
local TypeChart = require("src.battle.TypeChart")
|
||||
|
||||
local Damage = {}
|
||||
|
||||
-- Moves with a boosted critical-hit rate (engine/battle/core.asm
|
||||
-- CriticalHitTest checks these move ids explicitly).
|
||||
-- CriticalHitTest checks these move ids explicitly). The move-record
|
||||
-- highCrit field wins; this list covers pre-existing imported caches.
|
||||
local HIGH_CRIT = {
|
||||
KARATE_CHOP = true, RAZOR_LEAF = true, CRABHAMMER = true, SLASH = true,
|
||||
}
|
||||
|
||||
-- ApplyBadgeStatBoosts (engine/battle/core.asm): x9/8 per badge on the
|
||||
-- named battle stat. Data.constants.badgeBoosts replaces this via the
|
||||
-- battler's badgeBoosts field; these rows are the vanilla values.
|
||||
Damage.BADGE_BOOSTS = {
|
||||
{ badge = "BOULDERBADGE", stat = "attack", num = 9, den = 8 },
|
||||
{ badge = "THUNDERBADGE", stat = "defense", num = 9, den = 8 },
|
||||
{ badge = "SOULBADGE", stat = "speed", num = 9, den = 8 },
|
||||
{ badge = "VOLCANOBADGE", stat = "special", num = 9, den = 8 },
|
||||
}
|
||||
|
||||
-- the boost a battler's badge set applies to one battle stat, or nil
|
||||
local function badgeBoost(battler, stat)
|
||||
local badges = battler.badges
|
||||
if not badges then return nil end
|
||||
for _, row in ipairs(battler.badgeBoosts or Damage.BADGE_BOOSTS) do
|
||||
if row.stat == stat and badges[row.badge] then return row end
|
||||
end
|
||||
return nil
|
||||
end
|
||||
|
||||
-- the merged status record for a battler's persistent condition, or nil
|
||||
local function statusRecord(battler)
|
||||
return Status.recordFor(battler.statuses, battler.mon.status)
|
||||
end
|
||||
|
||||
-- Critical chance test, following CriticalHitTest's shift chain exactly
|
||||
-- (each left shift caps at 255): b = baseSpeed/2, then x2 (or /2 with
|
||||
-- (each left shift caps at 255): b = speed/2, then x2 (or /2 with
|
||||
-- Focus Energy's famous right-shift bug), then x4 for high-crit moves
|
||||
-- or /2 for normal ones. Net rates: normal = speed/512, high-crit =
|
||||
-- speed*4/256 (capped), Focus Energy bug = 1/4 the usual.
|
||||
function Damage.critRoll(ruleset, attacker, moveId, rng)
|
||||
-- critUsesBaseSpeed (default true, the Gen 1 rule) reads the species
|
||||
-- base speed; a ruleset that sets it false uses the current in-battle
|
||||
-- speed with stages applied.
|
||||
function Damage.critRoll(ruleset, attacker, moveId, rng, highCrit)
|
||||
rng = rng or love.math.random
|
||||
local function shl(x) return math.min(255, x * 2) end
|
||||
local b = math.floor(attacker.def.baseStats.speed / 2)
|
||||
local speed
|
||||
if ruleset.critUsesBaseSpeed == false then
|
||||
speed = Stats.applyStage(attacker.curStats.speed,
|
||||
attacker.stages and attacker.stages.speed or 0)
|
||||
else
|
||||
speed = attacker.def.baseStats.speed
|
||||
end
|
||||
local b = math.floor(speed / 2)
|
||||
if attacker.focusEnergy then
|
||||
if ruleset.focusEnergyBug then
|
||||
b = math.floor(b / 2) -- srl instead of sla
|
||||
@@ -33,7 +74,8 @@ function Damage.critRoll(ruleset, attacker, moveId, rng)
|
||||
else
|
||||
b = shl(b)
|
||||
end
|
||||
if HIGH_CRIT[moveId] then
|
||||
if highCrit == nil then highCrit = HIGH_CRIT[moveId] end
|
||||
if highCrit then
|
||||
b = shl(shl(b))
|
||||
else
|
||||
b = math.floor(b / 2)
|
||||
@@ -63,13 +105,27 @@ function Damage.accuracyRoll(ruleset, move, attacker, defender, rng)
|
||||
return rng(0, 255) < acc
|
||||
end
|
||||
|
||||
local function isSpecial(moveType)
|
||||
-- Gen 1: WATER/GRASS/FIRE/ICE/ELECTRIC/PSYCHIC/DRAGON are special
|
||||
return moveType == "WATER" or moveType == "GRASS" or moveType == "FIRE"
|
||||
or moveType == "ICE" or moveType == "ELECTRIC" or moveType == "PSYCHIC_TYPE"
|
||||
or moveType == "DRAGON"
|
||||
local warnedTypes = {}
|
||||
|
||||
-- Gen 1 splits physical from special by TYPE: the move's own category
|
||||
-- field wins, then the merged type record's, then physical (with one
|
||||
-- warning per unknown type).
|
||||
local function categoryOf(move)
|
||||
local category = move.category or TypeChart.category(move.type)
|
||||
if category == nil then
|
||||
if move.type ~= nil and not warnedTypes[move.type] then
|
||||
warnedTypes[move.type] = true
|
||||
Logger.warn("move type %s has no category; treated as physical",
|
||||
tostring(move.type))
|
||||
end
|
||||
category = "physical"
|
||||
end
|
||||
return category
|
||||
end
|
||||
|
||||
function Damage.isSpecial(moveType)
|
||||
return TypeChart.category(moveType) == "special"
|
||||
end
|
||||
Damage.isSpecial = isSpecial
|
||||
|
||||
-- Compute damage. attacker/defender are battler tables.
|
||||
-- opts: rng, forceCrit, explode (halves defense), typeless (confusion
|
||||
@@ -80,16 +136,23 @@ Damage.isSpecial = isSpecial
|
||||
function Damage.compute(ruleset, attacker, defender, move, opts)
|
||||
opts = opts or {}
|
||||
local rng = opts.rng or love.math.random
|
||||
if move.power == 0 then
|
||||
if move.power == 0 or move.category == "status" then
|
||||
return 0, { crit = false, typeMult = 10 }
|
||||
end
|
||||
|
||||
local crit = opts.forceCrit
|
||||
if crit == nil then
|
||||
crit = Damage.critRoll(ruleset, attacker, move.id, rng)
|
||||
if Runtime.wantsHook("battle.crit") then
|
||||
crit = Runtime.call("battle.crit", function(c)
|
||||
return Damage.critRoll(c.ruleset, c.attacker, c.moveId, c.rng, c.highCrit)
|
||||
end, { ruleset = ruleset, attacker = attacker, moveId = move.id,
|
||||
rng = rng, highCrit = move.highCrit })
|
||||
else
|
||||
crit = Damage.critRoll(ruleset, attacker, move.id, rng, move.highCrit)
|
||||
end
|
||||
end
|
||||
|
||||
local special = isSpecial(move.type)
|
||||
local special = categoryOf(move) == "special"
|
||||
local atkStat = special and "special" or "attack"
|
||||
local defStat = special and "special" or "defense"
|
||||
|
||||
@@ -105,28 +168,23 @@ function Damage.compute(ruleset, attacker, defender, move, opts)
|
||||
-- badge boosts (x9/8), engine/battle/core.asm ApplyBadgeStatBoosts:
|
||||
-- Boulder -> attack, Thunder -> defense, Soul -> speed (TurnOrder),
|
||||
-- Volcano -> special
|
||||
local badges = attacker.badges
|
||||
if badges then
|
||||
if not special and badges.BOULDERBADGE then
|
||||
atk = math.floor(atk * 9 / 8)
|
||||
elseif special and badges.VOLCANOBADGE then
|
||||
atk = math.floor(atk * 9 / 8)
|
||||
end
|
||||
local atkBoost = badgeBoost(attacker, atkStat)
|
||||
if atkBoost then
|
||||
atk = math.floor(atk * (atkBoost.num or 9) / (atkBoost.den or 8))
|
||||
end
|
||||
local dbadges = defender.badges
|
||||
if dbadges then
|
||||
if not special and dbadges.THUNDERBADGE then
|
||||
dfn = math.floor(dfn * 9 / 8)
|
||||
elseif special and dbadges.VOLCANOBADGE then
|
||||
dfn = math.floor(dfn * 9 / 8)
|
||||
end
|
||||
local defBoost = badgeBoost(defender, defStat)
|
||||
if defBoost then
|
||||
dfn = math.floor(dfn * (defBoost.num or 9) / (defBoost.den or 8))
|
||||
end
|
||||
-- burn halves physical attack (applied as part of the stat in Gen 1).
|
||||
-- burn halves physical attack (applied as part of the stat in Gen 1;
|
||||
-- the status record's statPenalty names the stat it cuts).
|
||||
-- hazeStatReset suppresses it: Haze (haze.asm ResetStats) copied the
|
||||
-- unmodified attack over the burn-halved battle stat, lifting the
|
||||
-- penalty until the next stat recompute.
|
||||
if not special and attacker.mon.status == "BRN" and not attacker.hazeStatReset then
|
||||
atk = math.max(1, math.floor(atk / 2))
|
||||
local record = statusRecord(attacker)
|
||||
local penalty = record and record.statPenalty
|
||||
if penalty and penalty.stat == atkStat and not attacker.hazeStatReset then
|
||||
atk = math.max(1, math.floor(atk / penalty.div))
|
||||
end
|
||||
-- screens double the effective defense (crits bypass them). The
|
||||
-- confusion self-hit is the quirk case: HandleSelfConfusionDamage
|
||||
|
||||
@@ -0,0 +1,258 @@
|
||||
-- The move-effect execution surface: the ctx facade handed to every
|
||||
-- move_effects record callback, and the staged damaging pipeline that
|
||||
-- performMove drives through the record's stage fields
|
||||
-- (gate/neverMiss/hitCount/beforeAccuracy/chooseDamage/onMiss/afterDamage
|
||||
-- plus the post-damage secondary run). The ctx is the only supported
|
||||
-- surface handlers receive; everything else is engine-internal.
|
||||
|
||||
local MoveEffects = require("src.battle.MoveEffects")
|
||||
local Runtime = require("src.mods.Runtime")
|
||||
local StatusRegistry = require("src.battle.StatusRegistry")
|
||||
|
||||
local EffectRegistry = {}
|
||||
|
||||
-- pokered's <USER>/<TARGET> text macros print "Enemy " before the enemy
|
||||
-- mon's nickname (home/text.asm PlaceMoveUsersName)
|
||||
local function displayName(b)
|
||||
return b.isPlayer and b.name or ("Enemy " .. b.name)
|
||||
end
|
||||
EffectRegistry.displayName = displayName
|
||||
|
||||
-- built once per performMove call; closes over the battle
|
||||
function EffectRegistry.makeCtx(battle, user, target, move, moveInst, isCalled)
|
||||
local ctx
|
||||
ctx = {
|
||||
battle = battle, data = battle.data, rng = battle.rng,
|
||||
ruleset = battle.ruleset,
|
||||
user = user, target = target, move = move, moveInst = moveInst,
|
||||
isCalled = isCalled or false,
|
||||
field = battle.field,
|
||||
displayName = displayName,
|
||||
say = function(text) battle:sayNext(text) end,
|
||||
sayNext = function(text) battle:sayNext(text) end,
|
||||
anim = function(animName, isPlayer)
|
||||
battle:animNext(animName, isPlayer == nil and user.isPlayer or isPlayer)
|
||||
end,
|
||||
drain = function() battle:drainNext() end,
|
||||
-- applyDamage plus the faint queue, like the crash/self-hit paths
|
||||
damage = function(who, amount)
|
||||
local dealt = battle:applyDamage(who, amount)
|
||||
if who.mon.hp <= 0 then battle:onFaint(who) end
|
||||
return dealt
|
||||
end,
|
||||
inflict = function(who, statusId, opts)
|
||||
return StatusRegistry.inflict(battle, who, statusId, opts)
|
||||
end,
|
||||
cure = function(who)
|
||||
who.mon.status = nil
|
||||
who.toxicCounter = nil
|
||||
end,
|
||||
changeStage = function(who, stat, delta, fromEnemy)
|
||||
return MoveEffects.changeStage(battle, who, stat, delta, fromEnemy)
|
||||
end,
|
||||
computeDamage = function(opts)
|
||||
return battle:computeDamage(user, target, move, opts)
|
||||
end,
|
||||
accuracyRoll = function()
|
||||
return battle:accuracyRoll(move, user, target)
|
||||
end,
|
||||
callMove = function(moveId)
|
||||
return battle:performMove(user, target, { id = moveId, pp = 1 }, true)
|
||||
end,
|
||||
side = function(who) return battle:sideOf(who) end,
|
||||
}
|
||||
return ctx
|
||||
end
|
||||
|
||||
-- multi-hit count: the record's hitCount wins, then the move's multiHit
|
||||
-- field, then a single hit
|
||||
local function hitCount(ctx, record)
|
||||
if record and record.hitCount then
|
||||
return record.hitCount(ctx) or 1
|
||||
end
|
||||
local dist = ctx.move.multiHit
|
||||
if dist == nil then return 1 end
|
||||
if type(dist) == "number" then return dist end
|
||||
local r = ctx.rng(0, #dist - 1)
|
||||
return dist[r + 1]
|
||||
end
|
||||
|
||||
-- The damaging pipeline, extracted from the performMove monolith: every
|
||||
-- stage keeps the original's exact check order and rng consumption
|
||||
-- (invulnerability -> gate -> hit count -> pre-accuracy -> accuracy ->
|
||||
-- damage choice -> hits -> messages -> after-damage -> secondary run).
|
||||
function EffectRegistry.runDamaging(battle, ctx, record)
|
||||
local user, target = ctx.user, ctx.target
|
||||
local move, moveInst = ctx.move, ctx.moveInst
|
||||
local neverMiss = record and record.neverMiss
|
||||
|
||||
-- Swift ignores semi-invulnerability (MoveHitTest returns hit for
|
||||
-- SWIFT_EFFECT before the INVULNERABLE check)
|
||||
if target.invulnerable and not neverMiss then
|
||||
battle:sayNext(("%s's\nattack missed!"):format(displayName(user)))
|
||||
return
|
||||
end
|
||||
|
||||
-- OHKO speed gate, Dream Eater sleep gate
|
||||
if record and record.gate then
|
||||
local ok, failMsg = record.gate(ctx)
|
||||
if not ok then
|
||||
if failMsg then battle:sayNext(failMsg) end
|
||||
return
|
||||
end
|
||||
end
|
||||
|
||||
local hits = hitCount(ctx, record)
|
||||
|
||||
if record and record.beforeAccuracy then record.beforeAccuracy(ctx) end
|
||||
|
||||
if not neverMiss then
|
||||
if not battle:accuracyRoll(move, user, target) then
|
||||
battle:sayNext(("%s's\nattack missed!"):format(displayName(user)))
|
||||
-- Jump Kick crash, Explode self-destruct
|
||||
if record and record.onMiss then record.onMiss(ctx, "accuracy") end
|
||||
user.trappingTurns = nil
|
||||
return
|
||||
end
|
||||
end
|
||||
|
||||
-- damage per hit
|
||||
local dmg, info
|
||||
if move.id == "COUNTER" then
|
||||
-- HandleCounterMove: 2x the last damage dealt in battle, only if
|
||||
-- the opponent's last move was counterable with >0 power (and not
|
||||
-- Counter itself); wDamage is shared, so any last damage counts.
|
||||
-- counterable defaults to the Normal/Fighting whitelist.
|
||||
local lastId = target.lastMove
|
||||
local lm = lastId and lastId ~= "COUNTER" and battle.data.moves[lastId]
|
||||
local counterable = false
|
||||
if lm and (lm.power or 0) > 0 then
|
||||
if lm.counterable ~= nil then
|
||||
counterable = lm.counterable
|
||||
else
|
||||
counterable = lm.type == "NORMAL" or lm.type == "FIGHTING"
|
||||
end
|
||||
end
|
||||
if not counterable or (battle.lastDamage or 0) == 0 then
|
||||
battle:sayNext(("%s's\nattack missed!"):format(displayName(user)))
|
||||
return
|
||||
end
|
||||
dmg = math.min(65535, battle.lastDamage * 2)
|
||||
info = { crit = false, typeMult = 10 }
|
||||
elseif record and record.chooseDamage then
|
||||
-- Counter/Super Fang/OHKO/fixed damage; (nil, msg) means the move
|
||||
-- failed with that text already chosen
|
||||
local chosen, extra = record.chooseDamage(ctx)
|
||||
if not chosen then
|
||||
if extra then battle:sayNext(extra) end
|
||||
return
|
||||
end
|
||||
dmg, info = chosen, extra or { crit = false, typeMult = 10 }
|
||||
else
|
||||
dmg, info = battle:computeDamage(user, target, move,
|
||||
{ rng = battle.rng, explode = (record and record.explode) or nil })
|
||||
end
|
||||
|
||||
if info.typeMult == 0 then
|
||||
battle:sayNext(("It doesn't affect\n%s!"):format(displayName(target)))
|
||||
if record and record.onMiss then record.onMiss(ctx, "immune") end
|
||||
return
|
||||
end
|
||||
if info.missed then
|
||||
-- 0.25x floored the damage to zero: the original registers a miss
|
||||
battle:sayNext(("%s's\nattack missed!"):format(displayName(user)))
|
||||
if record and record.onMiss then record.onMiss(ctx, "floored") end
|
||||
return
|
||||
end
|
||||
battle.lastDamage = dmg -- wDamage (shared by both sides, read by Counter)
|
||||
|
||||
-- the hit blink + damage sound ride the queue behind the animation:
|
||||
-- on the move's anim row when one was announced, else on a bare hit
|
||||
-- row (thrash/rage continuations), placed BEFORE the drain rows the
|
||||
-- hits loop inserts so the blink precedes the bar drain
|
||||
local hitRow = battle.moveAnimRow
|
||||
if not hitRow then
|
||||
battle.nextInsert = (battle.nextInsert or 0) + 1
|
||||
hitRow = { hitRow = true }
|
||||
table.insert(battle.queue, battle.nextInsert, hitRow)
|
||||
end
|
||||
|
||||
local totalDealt = 0
|
||||
local landed, brokeSub = 0, false
|
||||
for h = 1, hits do
|
||||
if target.mon.hp <= 0 then break end
|
||||
local hadSub = target.substituteHP ~= nil
|
||||
local dealt = battle:applyDamage(target, dmg)
|
||||
totalDealt = totalDealt + dealt
|
||||
landed = h
|
||||
if Runtime.wants("battle.damage_dealt") then
|
||||
Runtime.emit("battle.damage_dealt", {
|
||||
battle = battle, user = user, target = target, move = move,
|
||||
damage = dealt, crit = info.crit, typeMult = info.typeMult,
|
||||
})
|
||||
end
|
||||
if hadSub and not target.substituteHP then
|
||||
-- AttackSubstitute: breaking the substitute ends a multi-hit move
|
||||
brokeSub = true
|
||||
break
|
||||
end
|
||||
end
|
||||
hits = landed > 0 and landed or hits
|
||||
if totalDealt > 0 then
|
||||
-- the original's per-hit sound: normal / super / not-very-effective
|
||||
local hitSfx = info.typeMult > 10 and "Super_Effective"
|
||||
or info.typeMult < 10 and "Not_Very_Effective" or "Damage"
|
||||
hitRow.hit = { sfx = hitSfx,
|
||||
blink = battle:animationsOn() and target or nil }
|
||||
end
|
||||
-- PrintCriticalOHKOText prints "Critical hit!"/"One-hit KO!" right
|
||||
-- after the damage lands, BEFORE DisplayEffectiveness (core.asm
|
||||
-- .moveDidNotMiss); the multi-hit count follows the last hit
|
||||
if info.crit then battle:sayNext("Critical hit!") end
|
||||
if info.ohko then battle:sayNext("One-hit KO!") end
|
||||
if info.typeMult > 10 then
|
||||
battle:sayNext("It's super\neffective!")
|
||||
elseif info.typeMult < 10 then
|
||||
battle:sayNext("It's not very\neffective...")
|
||||
end
|
||||
if hits > 1 then
|
||||
-- player: _MultiHitText; enemy: _HitXTimesText (always plural)
|
||||
if user.isPlayer then
|
||||
battle:sayNext(("Hit the enemy\n%d times!"):format(hits))
|
||||
else
|
||||
battle:sayNext(("Hit %d times!"):format(hits))
|
||||
end
|
||||
end
|
||||
|
||||
-- post-damage effect bookkeeping (recoil/drain/trap/thrash/...)
|
||||
ctx.rawDamage, ctx.totalDealt = dmg, totalDealt
|
||||
ctx.brokeSub, ctx.hits = brokeSub, hits
|
||||
if record and record.afterDamage then
|
||||
record.afterDamage(ctx, totalDealt)
|
||||
elseif moveInst.struggle then
|
||||
-- struggle recoils even when its effect id resolves to no record
|
||||
local recoil = math.max(1, math.floor(dmg / 2))
|
||||
battle:sayNext(("%s's\nhit with recoil!"):format(displayName(user)))
|
||||
battle:applyDamage(user, recoil)
|
||||
end
|
||||
|
||||
-- secondary side effects (blocked by fainting)
|
||||
if record and record.run and record.kind ~= "primary"
|
||||
and target.mon.hp > 0 and totalDealt > 0 then
|
||||
for _, m in ipairs(record.run(ctx)) do
|
||||
battle:sayNext(m)
|
||||
end
|
||||
end
|
||||
if record == nil then
|
||||
MoveEffects.warnUnknown(move.effect)
|
||||
end
|
||||
|
||||
if target.mon.hp <= 0 then
|
||||
battle:onFaint(target)
|
||||
end
|
||||
if user.mon.hp <= 0 then
|
||||
battle:onFaint(user)
|
||||
end
|
||||
end
|
||||
|
||||
return EffectRegistry
|
||||
@@ -5,6 +5,7 @@
|
||||
-- participant's stat exp.
|
||||
|
||||
local Growth = require("src.pokemon.Growth")
|
||||
local Runtime = require("src.mods.Runtime")
|
||||
local Stats = require("src.pokemon.Stats")
|
||||
|
||||
local Experience = {}
|
||||
@@ -21,14 +22,26 @@ local Experience = {}
|
||||
-- Sequential floor divisions equal one floor division by the product,
|
||||
-- so callers pass numParticipants = 2*participants for the first pass
|
||||
-- and 2*participants*partyCount for the whole-party pass.
|
||||
function Experience.gainFor(defeatedDef, level, isTrainer, numParticipants, traded)
|
||||
--
|
||||
-- consts is Data.constants; a constants.exp record can retune the
|
||||
-- divisor and the traded/trainer multipliers, with the values above as
|
||||
-- the defaults.
|
||||
function Experience.gainFor(defeatedDef, level, isTrainer, numParticipants,
|
||||
traded, consts)
|
||||
local divisor, tradedMult, trainerMult = 7, nil, nil
|
||||
local tuning = consts and consts.exp
|
||||
if tuning then
|
||||
divisor = tuning.divisor or divisor
|
||||
tradedMult = tuning.tradedMult
|
||||
trainerMult = tuning.trainerMult
|
||||
end
|
||||
local base = math.floor(defeatedDef.baseExp / math.max(1, numParticipants or 1))
|
||||
local exp = math.floor(base * level / 7)
|
||||
local exp = math.floor(base * level / divisor)
|
||||
if traded then
|
||||
exp = math.floor(exp * 3 / 2)
|
||||
exp = math.floor(exp * (tradedMult or 1.5))
|
||||
end
|
||||
if isTrainer then
|
||||
exp = math.floor(exp * 3 / 2)
|
||||
exp = math.floor(exp * (trainerMult or 1.5))
|
||||
end
|
||||
return math.max(1, exp)
|
||||
end
|
||||
@@ -46,18 +59,36 @@ function Experience.apply(data, mon, defeatedDef, level, isTrainer,
|
||||
local gain = math.floor(defeatedDef.baseStats[key] / statShare)
|
||||
mon.statExp[key] = math.min(65535, (mon.statExp[key] or 0) + gain)
|
||||
end
|
||||
local gained = Experience.gainFor(defeatedDef, level, isTrainer,
|
||||
numParticipants, traded)
|
||||
local consts = data.constants
|
||||
local gained
|
||||
if Runtime.wantsHook("exp.gain") then
|
||||
gained = Runtime.call("exp.gain", function(c)
|
||||
return Experience.gainFor(c.defeatedDef, c.level, c.isTrainer,
|
||||
c.participants, c.traded, consts)
|
||||
end, { defeatedDef = defeatedDef, level = level, isTrainer = isTrainer,
|
||||
participants = numParticipants, traded = traded, mon = mon })
|
||||
else
|
||||
gained = Experience.gainFor(defeatedDef, level, isTrainer,
|
||||
numParticipants, traded, consts)
|
||||
end
|
||||
mon.exp = mon.exp + gained
|
||||
|
||||
local cap = consts and consts.levelCap or 100
|
||||
local levels = {}
|
||||
local newLevel = Growth.levelForExp(speciesDef.growthRate, mon.exp)
|
||||
while mon.level < math.min(newLevel, 100) do
|
||||
local newLevel = Growth.levelForExp(speciesDef.growthRate, mon.exp, cap,
|
||||
data.growth_rates)
|
||||
while mon.level < math.min(newLevel, cap) do
|
||||
mon.level = mon.level + 1
|
||||
local old = mon.stats
|
||||
mon.stats = Stats.calc(speciesDef, mon.level, mon.dvs, mon.statExp)
|
||||
mon.hp = math.min(mon.stats.hp, mon.hp + (mon.stats.hp - old.hp))
|
||||
table.insert(levels, mon.level)
|
||||
if Runtime.wants("pokemon.level_up") then
|
||||
Runtime.emit("pokemon.level_up", {
|
||||
mon = mon, level = mon.level, prevLevel = mon.level - 1,
|
||||
learnable = Experience.movesLearnedAt(speciesDef, mon.level),
|
||||
})
|
||||
end
|
||||
end
|
||||
return levels, gained
|
||||
end
|
||||
|
||||
@@ -5,8 +5,16 @@
|
||||
--
|
||||
-- Substitutes block status/stat effects and side effects aimed at their
|
||||
-- owner, like Gen 1.
|
||||
--
|
||||
-- The primary/secondary tables keep their v1 signatures; MoveEffects.full
|
||||
-- carries the stage callbacks the damaging pipeline consults, and RECORDS
|
||||
-- is the registry view of all three -- the merged Data.move_effects a
|
||||
-- battle dispatches on serves these same objects.
|
||||
|
||||
local Logger = require("src.core.Logger")
|
||||
local StatusRegistry = require("src.battle.StatusRegistry")
|
||||
local TurnOrder = require("src.battle.TurnOrder")
|
||||
local TypeChart = require("src.battle.TypeChart")
|
||||
|
||||
local MoveEffects = {}
|
||||
|
||||
@@ -53,6 +61,7 @@ local function changeStage(battle, who, stat, delta, fromEnemy)
|
||||
end
|
||||
return { ("%s's\n%s\ngreatly fell!"):format(displayName(who), STAT_LABEL[stat]) }
|
||||
end
|
||||
MoveEffects.changeStage = changeStage
|
||||
|
||||
local function statUp(stat, delta)
|
||||
return function(battle, user, target)
|
||||
@@ -70,54 +79,10 @@ end
|
||||
-- status
|
||||
-- ---------------------------------------------------------------------
|
||||
|
||||
local STATUS_LABEL = {
|
||||
SLP = "fell asleep", PSN = "was poisoned", BRN = "was burned",
|
||||
FRZ = "was frozen solid",
|
||||
}
|
||||
|
||||
-- opts: toxic (start the Toxic counter), moveType (for the type
|
||||
-- gates), secondary (side-effect of a damaging move).
|
||||
-- kept as the module's inflict entry: the registry-backed rules live in
|
||||
-- StatusRegistry (per-status canInflict/onInflict on the merged records)
|
||||
local function inflictStatus(battle, target, status, opts)
|
||||
opts = opts or {}
|
||||
if target.mon.status then return {} end
|
||||
-- Substitutes block poison (PoisonEffect calls CheckTargetSubstitute)
|
||||
-- and every secondary status, but NOT primary Sleep or Thunder Wave,
|
||||
-- their handlers never check the substitute in Gen 1.
|
||||
if target.substituteHP and (opts.secondary or status == "PSN") then
|
||||
return {}
|
||||
end
|
||||
for _, t in ipairs(target.curTypes) do
|
||||
-- can't poison Poison-types (primary or secondary)
|
||||
if status == "PSN" and t == "POISON" then return {} end
|
||||
-- ParalyzeEffect_: Electric-type moves can't paralyze Ground-types
|
||||
if status == "PAR" and opts.moveType == "ELECTRIC" and t == "GROUND" then
|
||||
return {}
|
||||
end
|
||||
-- FreezeBurnParalyzeEffect: a secondary status never lands when
|
||||
-- the move's type matches either of the target's types (Body Slam
|
||||
-- can't paralyze Normals, Fire can't burn Fire, Ice can't freeze Ice)
|
||||
if opts.secondary and status ~= "PSN" and opts.moveType == t then
|
||||
return {}
|
||||
end
|
||||
-- keep the canonical immunities for any non-secondary path
|
||||
if (status == "BRN" and t == "FIRE") or (status == "FRZ" and t == "ICE") then
|
||||
return {}
|
||||
end
|
||||
end
|
||||
target.mon.status = status
|
||||
if status == "SLP" then
|
||||
target.sleepTurns = battle.rng(1, 7)
|
||||
end
|
||||
if opts.toxic then
|
||||
target.toxicCounter = 1
|
||||
-- _BadlyPoisonedText
|
||||
return { ("%s's\nbadly poisoned!"):format(displayName(target)) }
|
||||
end
|
||||
if status == "PAR" then
|
||||
-- _ParalyzedMayNotAttackText (primary and secondary paralysis)
|
||||
return { ("%s's\nparalyzed! It may\nnot attack!"):format(displayName(target)) }
|
||||
end
|
||||
return { ("%s\n%s!"):format(displayName(target), STATUS_LABEL[status]) }
|
||||
return StatusRegistry.inflict(battle, target, status, opts)
|
||||
end
|
||||
|
||||
local function statusMove(status)
|
||||
@@ -131,6 +96,7 @@ local function statusMove(status)
|
||||
local msgs = inflictStatus(battle, target, status, {
|
||||
toxic = move and move.id == "TOXIC",
|
||||
moveType = move and move.type,
|
||||
source = move and move.id,
|
||||
})
|
||||
if #msgs == 0 then
|
||||
return { "But, it failed!" }
|
||||
@@ -151,6 +117,7 @@ local function statusSide(status, chance)
|
||||
return inflictStatus(battle, target, status, {
|
||||
moveType = move and move.type,
|
||||
secondary = true,
|
||||
source = move and move.id,
|
||||
})
|
||||
end
|
||||
end
|
||||
@@ -397,11 +364,326 @@ MoveEffects.secondary = {
|
||||
-- the second hit reroutes to PoisonEffect with POISON_SIDE_EFFECT1:
|
||||
-- 20 percent + 1 (52/256)
|
||||
if battle.rng(0, 255) >= 52 then return {} end
|
||||
return inflictStatus(battle, target, "PSN", { secondary = true })
|
||||
return inflictStatus(battle, target, "PSN",
|
||||
{ secondary = true, source = "TWINEEDLE" })
|
||||
end,
|
||||
}
|
||||
|
||||
-- effects fully handled inside BattleState's damage pipeline
|
||||
-- ---------------------------------------------------------------------
|
||||
-- full records: the damaging pipeline's stage callbacks
|
||||
-- ---------------------------------------------------------------------
|
||||
|
||||
-- Status-move effects whose pokered handlers call MoveHitTest (sleep/
|
||||
-- poison/paralyze/confusion/leech seed/disable and the primary
|
||||
-- stat-down moves). Everything else in MoveEffects.primary is
|
||||
-- self-targeting and never rolls accuracy. Mimic also hit-tests but
|
||||
-- runs its own mid-move flow (resolveMimic).
|
||||
local ACC_CHECKED = {
|
||||
SLEEP_EFFECT = true, POISON_EFFECT = true, PARALYZE_EFFECT = true,
|
||||
CONFUSION_EFFECT = true, LEECH_SEED_EFFECT = true, DISABLE_EFFECT = true,
|
||||
ATTACK_DOWN1_EFFECT = true, DEFENSE_DOWN1_EFFECT = true,
|
||||
DEFENSE_DOWN2_EFFECT = true, SPEED_DOWN1_EFFECT = true,
|
||||
ACCURACY_DOWN1_EFFECT = true,
|
||||
}
|
||||
|
||||
-- fixed-damage moves (engine/battle/core.asm SpecialDamage); the move
|
||||
-- field wins, previously imported caches fall back to the id table
|
||||
local FIXED_DAMAGE = {
|
||||
SONICBOOM = 20, DRAGON_RAGE = 40,
|
||||
SEISMIC_TOSS = "level", NIGHT_SHADE = "level", PSYWAVE = "half_level_rand",
|
||||
}
|
||||
MoveEffects.FIXED_DAMAGE = FIXED_DAMAGE
|
||||
|
||||
local function fixedDamageFor(ctx)
|
||||
local spec = ctx.move.fixedDamage
|
||||
if spec == nil then spec = FIXED_DAMAGE[ctx.move.id] end
|
||||
if type(spec) == "function" then return spec(ctx) end
|
||||
if spec == "level" then return ctx.user.mon.level end
|
||||
if spec == "half_level_rand" then
|
||||
-- PSYWAVE: rand(1, floor(level*3/2) - 1)
|
||||
local max = math.max(1, math.floor(ctx.user.mon.level * 3 / 2) - 1)
|
||||
return ctx.rng(1, max)
|
||||
end
|
||||
return spec
|
||||
end
|
||||
|
||||
local function plainInfo()
|
||||
return { crit = false, typeMult = 10 }
|
||||
end
|
||||
|
||||
-- multi-hit count: the move's multiHit field (a count or a distribution)
|
||||
-- with the effect's classic distribution as the fallback
|
||||
local function hitsFrom(dist, ctx)
|
||||
if type(dist) == "number" then return dist end
|
||||
local r = ctx.rng(0, #dist - 1)
|
||||
return dist[r + 1]
|
||||
end
|
||||
|
||||
-- drain_hp.asm halves the RAW wDamage IN PLACE (minimum 1) and heals
|
||||
-- that amount, so Counter would see the halved value
|
||||
local function drainHalf(text)
|
||||
return function(ctx)
|
||||
local heal = math.max(1, math.floor(ctx.rawDamage / 2))
|
||||
ctx.battle.lastDamage = heal
|
||||
local mon = ctx.user.mon
|
||||
mon.hp = math.min(mon.stats.hp, mon.hp + heal)
|
||||
ctx.drain()
|
||||
ctx.say(text:format(displayName(ctx.target)))
|
||||
end
|
||||
end
|
||||
|
||||
-- fixed damage still respects type immunity (AdjustDamageForMoveType
|
||||
-- flags the miss before the special-damage override)
|
||||
local function immuneMsg(ctx)
|
||||
if TypeChart.effectiveness(ctx.move.type, ctx.target.curTypes) == 0 then
|
||||
return ("It doesn't affect\n%s!"):format(displayName(ctx.target))
|
||||
end
|
||||
return nil
|
||||
end
|
||||
|
||||
MoveEffects.full = {
|
||||
NO_ADDITIONAL_EFFECT = {},
|
||||
|
||||
TWO_TO_FIVE_ATTACKS_EFFECT = {
|
||||
hitCount = function(ctx)
|
||||
return hitsFrom(ctx.move.multiHit or { 2, 2, 2, 3, 3, 3, 4, 5 }, ctx)
|
||||
end,
|
||||
},
|
||||
ATTACK_TWICE_EFFECT = {
|
||||
hitCount = function(ctx)
|
||||
return hitsFrom(ctx.move.multiHit or 2, ctx)
|
||||
end,
|
||||
},
|
||||
-- hits twice AND keeps its secondary poison run (registered below)
|
||||
TWINEEDLE_EFFECT = {
|
||||
hitCount = function(ctx)
|
||||
return hitsFrom(ctx.move.multiHit or 2, ctx)
|
||||
end,
|
||||
},
|
||||
|
||||
SPECIAL_DAMAGE_EFFECT = {
|
||||
chooseDamage = function(ctx)
|
||||
local blocked = immuneMsg(ctx)
|
||||
if blocked then return nil, blocked end
|
||||
local dmg = fixedDamageFor(ctx)
|
||||
if not dmg then return nil, "But, it failed!" end
|
||||
return dmg, plainInfo()
|
||||
end,
|
||||
},
|
||||
SUPER_FANG_EFFECT = {
|
||||
chooseDamage = function(ctx)
|
||||
local blocked = immuneMsg(ctx)
|
||||
if blocked then return nil, blocked end
|
||||
return math.max(1, math.floor(ctx.target.mon.hp / 2)), plainInfo()
|
||||
end,
|
||||
},
|
||||
OHKO_EFFECT = {
|
||||
-- fails against faster opponents (Gen 1 rule) and immune types
|
||||
gate = function(ctx)
|
||||
local blocked = immuneMsg(ctx)
|
||||
if blocked then return false, blocked end
|
||||
if TurnOrder.effectiveSpeed(ctx.user) < TurnOrder.effectiveSpeed(ctx.target) then
|
||||
return false, "But, it failed!"
|
||||
end
|
||||
return true
|
||||
end,
|
||||
chooseDamage = function()
|
||||
return 65535, { crit = false, typeMult = 10, ohko = true }
|
||||
end,
|
||||
},
|
||||
|
||||
RECOIL_EFFECT = {
|
||||
afterDamage = function(ctx)
|
||||
-- recoil.asm reads the RAW computed wDamage (not the HP actually
|
||||
-- removed): overkill and substitute hits recoil at full strength
|
||||
local recoil = math.max(1, math.floor(ctx.rawDamage
|
||||
/ (ctx.moveInst.struggle and 2 or 4)))
|
||||
ctx.say(("%s's\nhit with recoil!"):format(displayName(ctx.user)))
|
||||
ctx.battle:applyDamage(ctx.user, recoil)
|
||||
end,
|
||||
},
|
||||
DRAIN_HP_EFFECT = {
|
||||
afterDamage = drainHalf("Sucked health from\n%s!"),
|
||||
},
|
||||
DREAM_EATER_EFFECT = {
|
||||
-- only works on sleeping targets (checked before damage)
|
||||
gate = function(ctx)
|
||||
if ctx.target.mon.status ~= "SLP" then return false, "But, it failed!" end
|
||||
return true
|
||||
end,
|
||||
afterDamage = drainHalf("%s's\ndream was eaten!"),
|
||||
},
|
||||
|
||||
-- charge moves: first turn just charges; Fly AND Dig go
|
||||
-- semi-invulnerable (ChargeEffect sets INVULNERABLE for both)
|
||||
CHARGE_EFFECT = { charge = {} },
|
||||
FLY_EFFECT = { charge = { invulnerable = true } },
|
||||
|
||||
TRAPPING_EFFECT = {
|
||||
-- TrappingEffect runs BEFORE the hit test and clears the target's
|
||||
-- Hyper Beam recharge, even if the trapping move then misses
|
||||
-- (effects.asm:1091-1092 ClearHyperBeam)
|
||||
beforeAccuracy = function(ctx)
|
||||
if not ctx.user.trappingTurns then
|
||||
ctx.target.mustRecharge = nil
|
||||
end
|
||||
end,
|
||||
afterDamage = function(ctx)
|
||||
local user = ctx.user
|
||||
if not user.trappingTurns then
|
||||
-- TrappingEffect (effects.asm:1080-1103) rolls wNumAttacksLeft
|
||||
-- as 1-4 (weights 3/8 3/8 1/8 1/8): that many CONTINUATION
|
||||
-- attacks follow this first hit, 2-5 attacks total. The victim
|
||||
-- is held while the counter runs (live mirror in lockedAction).
|
||||
local r = ctx.rng(0, 7)
|
||||
user.trappingTurns = ({ 1, 1, 1, 2, 2, 2, 3, 4 })[r + 1]
|
||||
user.trapDamage = ctx.rawDamage
|
||||
-- remember the move so its animation can replay on each locked
|
||||
-- continuation (core.asm:3554-3566 -> GetPlayerAnimationType)
|
||||
user.trapMove = ctx.move.id
|
||||
end
|
||||
end,
|
||||
},
|
||||
THRASH_PETAL_DANCE_EFFECT = {
|
||||
afterDamage = function(ctx)
|
||||
local user = ctx.user
|
||||
if not user.thrashTurns then
|
||||
user.thrashTurns = ctx.rng(2, 3) -- 3-4 attacks total, then confusion
|
||||
user.thrashMove = ctx.moveInst
|
||||
user.thrashAnnounced = true
|
||||
else
|
||||
user.thrashTurns = user.thrashTurns - 1
|
||||
if user.thrashTurns <= 0 then
|
||||
user.thrashTurns, user.thrashMove, user.thrashAnnounced = nil, nil, nil
|
||||
if not user.confusedTurns then
|
||||
user.confusedTurns = ctx.rng(2, 5)
|
||||
ctx.say(("%s\nbecame confused!"):format(displayName(user)))
|
||||
end
|
||||
end
|
||||
end
|
||||
end,
|
||||
},
|
||||
JUMP_KICK_EFFECT = {
|
||||
onMiss = function(ctx, reason)
|
||||
if reason ~= "accuracy" then return end
|
||||
ctx.say(("%s\nkept going and\ncrashed!"):format(displayName(ctx.user)))
|
||||
ctx.damage(ctx.user, 1)
|
||||
end,
|
||||
},
|
||||
EXPLODE_EFFECT = {
|
||||
explode = true, -- Damage.compute halves the defense
|
||||
onMiss = function(ctx)
|
||||
ctx.battle:selfDestruct(ctx.user)
|
||||
end,
|
||||
afterDamage = function(ctx)
|
||||
ctx.battle:selfDestruct(ctx.user)
|
||||
end,
|
||||
},
|
||||
HYPER_BEAM_EFFECT = {
|
||||
afterDamage = function(ctx)
|
||||
-- no recharge when the target faints OR its substitute breaks
|
||||
if ctx.target.mon.hp > 0 and not ctx.brokeSub then
|
||||
ctx.user.mustRecharge = true
|
||||
end
|
||||
end,
|
||||
},
|
||||
PAY_DAY_EFFECT = {
|
||||
afterDamage = function(ctx)
|
||||
local battle = ctx.battle
|
||||
battle.payDay = (battle.payDay or 0) + 2 * ctx.user.mon.level
|
||||
ctx.say("Coins scattered\neverywhere!")
|
||||
end,
|
||||
},
|
||||
SWIFT_EFFECT = { neverMiss = true },
|
||||
RAGE_EFFECT = {
|
||||
afterDamage = function(ctx)
|
||||
ctx.user.rageMove = ctx.moveInst
|
||||
end,
|
||||
},
|
||||
|
||||
BIDE_EFFECT = {
|
||||
perform = function(ctx)
|
||||
local user = ctx.user
|
||||
user.bideTurns = ctx.rng(2, 3)
|
||||
user.bideDamage = 0
|
||||
ctx.say(("%s\nis storing energy!"):format(displayName(user)))
|
||||
end,
|
||||
},
|
||||
SWITCH_AND_TELEPORT_EFFECT = {
|
||||
-- SwitchAndTeleportEffect (effects.asm:810-909): in a wild battle
|
||||
-- it auto-succeeds when the user's level >= the opponent's;
|
||||
-- otherwise roll rand[0, userLevel+enemyLevel] and FAIL when the
|
||||
-- roll is below opponentLevel/4. Teleport's failure text is "But
|
||||
-- it failed!", Roar/Whirlwind's is DidntAffectText; in trainer
|
||||
-- battles Teleport fails and Roar/Whirlwind are "unaffected".
|
||||
perform = function(ctx)
|
||||
local battle, user, target, move = ctx.battle, ctx.user, ctx.target, ctx.move
|
||||
if battle.kind == "wild" then
|
||||
local uLvl, tLvl = user.mon.level, target.mon.level
|
||||
local ok = uLvl >= tLvl
|
||||
if not ok then
|
||||
ok = ctx.rng(0, uLvl + tLvl) >= math.floor(tLvl / 4)
|
||||
end
|
||||
if ok then
|
||||
if move.id == "ROAR" then
|
||||
ctx.say(("%s\nran away scared!"):format(displayName(target)))
|
||||
elseif move.id == "WHIRLWIND" then
|
||||
ctx.say(("%s\nwas blown away!"):format(displayName(target)))
|
||||
else
|
||||
ctx.say(("%s\nran from battle!"):format(displayName(user)))
|
||||
end
|
||||
battle.result = "run"
|
||||
battle.afterQueue = "finish"
|
||||
elseif move.id == "TELEPORT" then
|
||||
ctx.say("But, it failed!")
|
||||
else
|
||||
ctx.say(("It didn't affect\n%s!"):format(displayName(target)))
|
||||
end
|
||||
elseif move.id == "TELEPORT" then
|
||||
ctx.say("But, it failed!")
|
||||
else
|
||||
ctx.say(("%s\nis unaffected!"):format(displayName(target)))
|
||||
end
|
||||
end,
|
||||
},
|
||||
METRONOME_EFFECT = {
|
||||
callsMove = function(ctx)
|
||||
local order = ctx.data.constants.moveOrder
|
||||
local pick
|
||||
repeat
|
||||
pick = order[ctx.rng(1, #order)]
|
||||
until pick ~= "METRONOME" and pick ~= "STRUGGLE" and ctx.data.moves[pick]
|
||||
return pick
|
||||
end,
|
||||
},
|
||||
MIRROR_MOVE_EFFECT = {
|
||||
callsMove = function(ctx)
|
||||
local last = ctx.target.lastMove
|
||||
if not last then
|
||||
ctx.say("The MIRROR MOVE\nfailed!")
|
||||
return nil
|
||||
end
|
||||
return last
|
||||
end,
|
||||
},
|
||||
-- Mimic runs its own mid-move flow: hit test, then the copy menu
|
||||
-- (player) or a random roll (enemy / link), all on the queue.
|
||||
-- PlayCurrentMoveAnimation runs only after a successful copy
|
||||
-- (effects.asm:1268), never on a miss -- so no announcement anim row.
|
||||
MIMIC_EFFECT = {
|
||||
announceAnim = false,
|
||||
perform = function(ctx)
|
||||
ctx.battle:resolveMimic(ctx.user, ctx.target, ctx.move, ctx.moveInst)
|
||||
end,
|
||||
},
|
||||
}
|
||||
|
||||
-- ---------------------------------------------------------------------
|
||||
-- the registry view
|
||||
-- ---------------------------------------------------------------------
|
||||
|
||||
-- effects fully handled inside the damaging pipeline; kept as the v1
|
||||
-- compat set (BattleState dispatched on it before the records existed)
|
||||
MoveEffects.special = {
|
||||
NO_ADDITIONAL_EFFECT = true, TWO_TO_FIVE_ATTACKS_EFFECT = true,
|
||||
ATTACK_TWICE_EFFECT = true, SPECIAL_DAMAGE_EFFECT = true,
|
||||
@@ -415,6 +697,40 @@ MoveEffects.special = {
|
||||
TWINEEDLE_EFFECT = true, MIMIC_EFFECT = true,
|
||||
}
|
||||
|
||||
-- the (battle, user, target, move, moveInst) handlers adapted to the ctx
|
||||
-- facade the registry records expose
|
||||
local function shim(fn)
|
||||
return function(ctx)
|
||||
return fn(ctx.battle, ctx.user, ctx.target, ctx.move, ctx.moveInst)
|
||||
end
|
||||
end
|
||||
|
||||
local RECORDS = {}
|
||||
MoveEffects.RECORDS = RECORDS
|
||||
for id, fn in pairs(MoveEffects.primary) do
|
||||
RECORDS[id] = { kind = "primary", run = shim(fn),
|
||||
accuracyChecked = ACC_CHECKED[id] or nil }
|
||||
end
|
||||
for id, fn in pairs(MoveEffects.secondary) do
|
||||
RECORDS[id] = { kind = "secondary", run = shim(fn) }
|
||||
end
|
||||
for id, spec in pairs(MoveEffects.full) do
|
||||
local record = { kind = "full" }
|
||||
for key, value in pairs(spec) do record[key] = value end
|
||||
-- TWINEEDLE: full record with its secondary run honored post-damage
|
||||
local secondary = MoveEffects.secondary[id]
|
||||
if secondary then record.run = shim(secondary) end
|
||||
RECORDS[id] = record
|
||||
end
|
||||
|
||||
-- One record per effect, the same objects performMove dispatches on: the
|
||||
-- merged Data.move_effects and this table agree by construction.
|
||||
function MoveEffects.registerInto(registry, _, owner)
|
||||
for id, record in pairs(RECORDS) do
|
||||
registry:register(id, record, owner)
|
||||
end
|
||||
end
|
||||
|
||||
local warned = {}
|
||||
|
||||
function MoveEffects.warnUnknown(effect)
|
||||
|
||||
@@ -1,9 +1,151 @@
|
||||
-- Per-turn status/volatile condition handling (Gen 1 semantics).
|
||||
--
|
||||
-- The persistent conditions live in Status.RECORDS; a battle passes its
|
||||
-- merged Data.statuses so mod statuses join the same beforeMove gauntlet
|
||||
-- and residual sweep. Callers without a battle (pure-module tests) fall
|
||||
-- back to the vanilla records, which is bit-identical behavior.
|
||||
|
||||
local Status = {}
|
||||
|
||||
-- Returns canMove, messages, selfHit (true -> hurt itself in confusion)
|
||||
function Status.beforeMove(battler, rng)
|
||||
-- pokered's <USER>/<TARGET> text macros print "Enemy " before the enemy
|
||||
-- mon's nickname; these records only know the raw name -- BattleState
|
||||
-- splices the prefix in (prefixEnemy), same as always
|
||||
local function name(battler)
|
||||
return battler.name
|
||||
end
|
||||
|
||||
-- statuses with beforeMovePriority above this run before the engine's
|
||||
-- held/disable/confusion volatiles; at or below, after (sleep 40 and
|
||||
-- freeze 30 come first, paralysis 10 comes last, like the original
|
||||
-- CheckPlayerStatusConditions order)
|
||||
local VOLATILE_PRIORITY = 20
|
||||
|
||||
local function hasType(battler, wanted)
|
||||
for _, t in ipairs(battler.curTypes or {}) do
|
||||
if t == wanted then return true end
|
||||
end
|
||||
return false
|
||||
end
|
||||
|
||||
-- shared PSN/BRN residual: 1/16 max HP, multiplied (and advanced) by the
|
||||
-- Toxic counter (HandlePoisonBurnLeechSeed)
|
||||
local function damageOverTime(what)
|
||||
return function(battler)
|
||||
local mon = battler.mon
|
||||
local base = math.max(1, math.floor(mon.stats.hp / 16))
|
||||
local dmg = base
|
||||
if battler.toxicCounter then
|
||||
dmg = base * battler.toxicCounter
|
||||
battler.toxicCounter = battler.toxicCounter + 1
|
||||
end
|
||||
mon.hp = math.max(0, mon.hp - dmg)
|
||||
return { ("%s's\nhurt by %s!"):format(name(battler), what) }
|
||||
end
|
||||
end
|
||||
|
||||
-- The five persistent conditions as records: the beforeMove gauntlet, the
|
||||
-- residual sweep, the inflict text/immunities (StatusRegistry.inflict),
|
||||
-- the catch/wobble bonuses (Catching.attempt), the HUD label, and the
|
||||
-- burn/paralysis stat cut (Damage.compute, TurnOrder.effectiveSpeed) all
|
||||
-- read these fields, so a mod's sixth status plugs into every consumer.
|
||||
Status.RECORDS = {
|
||||
SLP = {
|
||||
id = "SLP", label = "SLP", hudLabel = "SLP",
|
||||
catchBonus = 25, shakeBonus = 10,
|
||||
beforeMovePriority = 40,
|
||||
beforeMove = function(battler)
|
||||
battler.sleepTurns = (battler.sleepTurns or 1) - 1
|
||||
if battler.sleepTurns <= 0 then
|
||||
battler.mon.status = nil
|
||||
return false, { name(battler) .. "\nwoke up!" } -- wakes but loses the turn
|
||||
end
|
||||
return false, { name(battler) .. "\nis fast asleep!" }
|
||||
end,
|
||||
onInflict = function(battle, target, opts, display)
|
||||
target.sleepTurns = battle.rng(1, 7)
|
||||
return { ("%s\nfell asleep!"):format(display) }
|
||||
end,
|
||||
},
|
||||
FRZ = {
|
||||
id = "FRZ", label = "FRZ", hudLabel = "FRZ",
|
||||
catchBonus = 25, shakeBonus = 10,
|
||||
beforeMovePriority = 30,
|
||||
beforeMove = function(battler)
|
||||
return false, { name(battler) .. "\nis frozen solid!" }
|
||||
end,
|
||||
canInflict = function(target) return not hasType(target, "ICE") end,
|
||||
onInflict = function(_, _, _, display)
|
||||
return { ("%s\nwas frozen solid!"):format(display) }
|
||||
end,
|
||||
},
|
||||
PSN = {
|
||||
id = "PSN", label = "PSN", hudLabel = "PSN",
|
||||
catchBonus = 12, shakeBonus = 5,
|
||||
residual = damageOverTime("poison"),
|
||||
canInflict = function(target) return not hasType(target, "POISON") end,
|
||||
onInflict = function(_, target, opts, display)
|
||||
if opts.toxic then
|
||||
target.toxicCounter = 1
|
||||
-- _BadlyPoisonedText
|
||||
return { ("%s's\nbadly poisoned!"):format(display) }
|
||||
end
|
||||
return { ("%s\nwas poisoned!"):format(display) }
|
||||
end,
|
||||
},
|
||||
BRN = {
|
||||
id = "BRN", label = "BRN", hudLabel = "BRN",
|
||||
catchBonus = 12, shakeBonus = 5,
|
||||
statPenalty = { stat = "attack", div = 2 },
|
||||
residual = damageOverTime("the burn"),
|
||||
canInflict = function(target) return not hasType(target, "FIRE") end,
|
||||
onInflict = function(_, _, _, display)
|
||||
return { ("%s\nwas burned!"):format(display) }
|
||||
end,
|
||||
},
|
||||
PAR = {
|
||||
id = "PAR", label = "PAR", hudLabel = "PAR",
|
||||
catchBonus = 12, shakeBonus = 5,
|
||||
statPenalty = { stat = "speed", div = 4 },
|
||||
beforeMovePriority = 10,
|
||||
beforeMove = function(battler, rng)
|
||||
-- cp 25 percent / jr nc: fully paralyzed on rand < 63 (63/256)
|
||||
if rng(0, 255) < 63 then
|
||||
return false, { name(battler) .. "'s\nfully paralyzed!" }
|
||||
end
|
||||
return true, {}
|
||||
end,
|
||||
canInflict = function(target, opts)
|
||||
-- ParalyzeEffect_: Electric-type moves can't paralyze Ground-types
|
||||
return not (opts.moveType == "ELECTRIC" and hasType(target, "GROUND"))
|
||||
end,
|
||||
onInflict = function(_, _, _, display)
|
||||
-- _ParalyzedMayNotAttackText (primary and secondary paralysis)
|
||||
return { ("%s's\nparalyzed! It may\nnot attack!"):format(display) }
|
||||
end,
|
||||
},
|
||||
}
|
||||
|
||||
function Status.registerInto(registry, _, owner)
|
||||
for id, record in pairs(Status.RECORDS) do
|
||||
registry:register(id, record, owner)
|
||||
end
|
||||
end
|
||||
|
||||
-- the merged view when a battle is on hand, the vanilla records otherwise
|
||||
function Status.recordFor(statuses, id)
|
||||
if id == nil then return nil end
|
||||
return (statuses or Status.RECORDS)[id]
|
||||
end
|
||||
|
||||
local function battleStatuses(battle)
|
||||
return battle and battle.data and battle.data.statuses
|
||||
end
|
||||
|
||||
-- Returns canMove, messages, selfHit (true -> hurt itself in confusion).
|
||||
-- The active status record's beforeMove runs at its priority slot: above
|
||||
-- VOLATILE_PRIORITY before the held/disable/confusion block (sleep,
|
||||
-- freeze), at or below after it (paralysis) -- the original's order.
|
||||
function Status.beforeMove(battler, rng, battle)
|
||||
local mon = battler.mon
|
||||
-- Haze curing this mon's sleep/freeze forfeits its pending move for
|
||||
-- the turn, silently (haze.asm writes $ff/CANNOT_MOVE to the selected
|
||||
@@ -14,71 +156,68 @@ function Status.beforeMove(battler, rng)
|
||||
end
|
||||
if battler.flinched then
|
||||
battler.flinched = false
|
||||
return false, { battler.name .. "\nflinched!" }
|
||||
return false, { name(battler) .. "\nflinched!" }
|
||||
end
|
||||
if mon.status == "SLP" then
|
||||
battler.sleepTurns = (battler.sleepTurns or 1) - 1
|
||||
if battler.sleepTurns <= 0 then
|
||||
mon.status = nil
|
||||
return false, { battler.name .. "\nwoke up!" } -- wakes but loses the turn
|
||||
end
|
||||
return false, { battler.name .. "\nis fast asleep!" }
|
||||
local record = Status.recordFor(battleStatuses(battle), mon.status)
|
||||
local handler = record and record.beforeMove
|
||||
local priority = handler and (record.beforeMovePriority or 0)
|
||||
local msgs = {}
|
||||
local function runStatus()
|
||||
local canMove, statusMsgs, selfHit = handler(battler, rng, battle)
|
||||
for _, m in ipairs(statusMsgs or {}) do msgs[#msgs + 1] = m end
|
||||
return canMove, selfHit
|
||||
end
|
||||
if mon.status == "FRZ" then
|
||||
return false, { battler.name .. "\nis frozen solid!" }
|
||||
if handler and priority > VOLATILE_PRIORITY then
|
||||
local canMove, selfHit = runStatus()
|
||||
if not canMove or selfHit then return canMove, msgs, selfHit end
|
||||
handler = nil
|
||||
end
|
||||
if battler.boundTurns and battler.boundTurns > 0 then
|
||||
battler.boundTurns = battler.boundTurns - 1
|
||||
return false, { battler.name .. "\ncan't move!" }
|
||||
msgs[#msgs + 1] = name(battler) .. "\ncan't move!"
|
||||
return false, msgs
|
||||
end
|
||||
local msgs = {}
|
||||
if battler.disabledTurns then
|
||||
battler.disabledTurns = battler.disabledTurns - 1
|
||||
if battler.disabledTurns <= 0 then
|
||||
battler.disabledTurns, battler.disabledSlot = nil, nil
|
||||
table.insert(msgs, battler.name .. "'s\ndisabled no more!")
|
||||
table.insert(msgs, name(battler) .. "'s\ndisabled no more!")
|
||||
end
|
||||
end
|
||||
if battler.confusedTurns then
|
||||
battler.confusedTurns = battler.confusedTurns - 1
|
||||
if battler.confusedTurns <= 0 then
|
||||
battler.confusedTurns = nil
|
||||
table.insert(msgs, battler.name .. "\nsnapped out of\nconfusion!")
|
||||
table.insert(msgs, name(battler) .. "\nsnapped out of\nconfusion!")
|
||||
else
|
||||
table.insert(msgs, battler.name .. "\nis confused!")
|
||||
table.insert(msgs, name(battler) .. "\nis confused!")
|
||||
-- cp 50 percent + 1 / jr c: hurt itself on rand >= 128 (128/256)
|
||||
if rng(0, 255) < 128 then
|
||||
return false, msgs, true -- hurt itself
|
||||
end
|
||||
end
|
||||
end
|
||||
-- cp 25 percent / jr nc: fully paralyzed on rand < 63 (63/256)
|
||||
if mon.status == "PAR" and rng(0, 255) < 63 then
|
||||
table.insert(msgs, battler.name .. "'s\nfully paralyzed!")
|
||||
return false, msgs
|
||||
if handler then
|
||||
local canMove, selfHit = runStatus()
|
||||
if not canMove or selfHit then return canMove, msgs, selfHit end
|
||||
end
|
||||
return true, msgs
|
||||
end
|
||||
|
||||
-- End-of-turn residual damage; opponent is needed for Leech Seed.
|
||||
-- Returns messages.
|
||||
function Status.residual(battler, opponent)
|
||||
function Status.residual(battler, opponent, battle)
|
||||
local msgs = {}
|
||||
local mon = battler.mon
|
||||
-- the Haze move-forfeit only covers the turn Haze was used; if this
|
||||
-- mon had already moved, drop the flag before it leaks into next turn
|
||||
battler.skipMove = nil
|
||||
if mon.hp <= 0 then return msgs end
|
||||
if mon.status == "PSN" or mon.status == "BRN" then
|
||||
local base = math.max(1, math.floor(mon.stats.hp / 16))
|
||||
local dmg = base
|
||||
if battler.toxicCounter then
|
||||
dmg = base * battler.toxicCounter
|
||||
battler.toxicCounter = battler.toxicCounter + 1
|
||||
local record = Status.recordFor(battleStatuses(battle), mon.status)
|
||||
if record and record.residual then
|
||||
for _, m in ipairs(record.residual(battler, opponent, battle) or {}) do
|
||||
msgs[#msgs + 1] = m
|
||||
end
|
||||
mon.hp = math.max(0, mon.hp - dmg)
|
||||
local what = mon.status == "PSN" and "poison" or "the burn"
|
||||
table.insert(msgs, ("%s's\nhurt by %s!"):format(battler.name, what))
|
||||
end
|
||||
if battler.leechSeeded and mon.hp > 0 and opponent.mon.hp > 0 then
|
||||
-- the shared Toxic counter multiplies (and advances on) the seed
|
||||
@@ -92,7 +231,7 @@ function Status.residual(battler, opponent)
|
||||
dmg = math.min(dmg, mon.hp)
|
||||
mon.hp = mon.hp - dmg
|
||||
opponent.mon.hp = math.min(opponent.mon.stats.hp, opponent.mon.hp + dmg)
|
||||
table.insert(msgs, ("LEECH SEED saps\n%s!"):format(battler.name))
|
||||
table.insert(msgs, ("LEECH SEED saps\n%s!"):format(name(battler)))
|
||||
end
|
||||
return msgs
|
||||
end
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
-- Status infliction against the merged statuses registry: the shared
|
||||
-- immunity rules stay here, the per-status ones live on the records
|
||||
-- (canInflict) and so does the landing text (onInflict), so a mod status
|
||||
-- inflicts through the same path as the vanilla five.
|
||||
|
||||
local Runtime = require("src.mods.Runtime")
|
||||
local Status = require("src.battle.Status")
|
||||
|
||||
local StatusRegistry = {}
|
||||
|
||||
-- pokered's <USER>/<TARGET> text macros (home/text.asm
|
||||
-- PlaceMoveUsersName): enemy-mon texts print "Enemy " before the name
|
||||
local function displayName(b)
|
||||
return b.isPlayer and b.name or ("Enemy " .. b.name)
|
||||
end
|
||||
|
||||
-- opts: toxic (start the Toxic counter), moveType (for the type gates),
|
||||
-- secondary (side-effect of a damaging move), source (inflicting move id).
|
||||
-- Returns messages; empty means the status did not land.
|
||||
function StatusRegistry.inflict(battle, target, status, opts)
|
||||
opts = opts or {}
|
||||
if target.mon.status then return {} end
|
||||
-- Substitutes block poison (PoisonEffect calls CheckTargetSubstitute)
|
||||
-- and every secondary status, but NOT primary Sleep or Thunder Wave,
|
||||
-- their handlers never check the substitute in Gen 1.
|
||||
if target.substituteHP and (opts.secondary or status == "PSN") then
|
||||
return {}
|
||||
end
|
||||
-- FreezeBurnParalyzeEffect: a secondary status never lands when the
|
||||
-- move's type matches either of the target's types (Body Slam can't
|
||||
-- paralyze Normals, Fire can't burn Fire, Ice can't freeze Ice)
|
||||
if opts.secondary and status ~= "PSN" then
|
||||
for _, t in ipairs(target.curTypes or {}) do
|
||||
if opts.moveType == t then return {} end
|
||||
end
|
||||
end
|
||||
local statuses = battle and battle.data and battle.data.statuses
|
||||
local record = Status.recordFor(statuses, status)
|
||||
if record and record.canInflict and not record.canInflict(target, opts) then
|
||||
return {}
|
||||
end
|
||||
target.mon.status = status
|
||||
local msgs
|
||||
local display = displayName(target)
|
||||
if record and record.onInflict then
|
||||
msgs = record.onInflict(battle, target, opts, display)
|
||||
else
|
||||
msgs = { ("%s\nwas afflicted\nby %s!"):format(display,
|
||||
record and record.label or tostring(status)) }
|
||||
end
|
||||
Runtime.emit("battle.status_inflicted", {
|
||||
battle = battle, target = target, status = status, source = opts.source,
|
||||
})
|
||||
return msgs
|
||||
end
|
||||
|
||||
return StatusRegistry
|
||||
@@ -24,13 +24,25 @@ local TrainerAI = {}
|
||||
local HEAL_AMOUNT = { POTION = 20, SUPER_POTION = 50, HYPER_POTION = 200 }
|
||||
local X_STAT = { X_ATTACK = "attack", X_DEFEND = "defense", X_SPEED = "speed" }
|
||||
|
||||
-- The trainer's ai_classes record from the merged registry; the direct
|
||||
-- require covers battles built without a loader. A trainer record's
|
||||
-- aiClass field picks a record other than its own id.
|
||||
function TrainerAI.classFor(battle)
|
||||
local trainer = battle and battle.trainer
|
||||
if not trainer then return nil end
|
||||
local id = trainer.aiClass or trainer.id
|
||||
local classes = battle.data and battle.data.ai_classes
|
||||
if classes then return classes[id] end
|
||||
return require("data.scripts.ai_classes")[id]
|
||||
end
|
||||
|
||||
-- Item use / switching per trainer class (engine/battle/trainer_ai.asm
|
||||
-- via data/scripts/ai_classes.lua). Runs before move choice each enemy
|
||||
-- via the ai_classes registry). Runs before move choice each enemy
|
||||
-- turn; returns an action { special = "aiItem"/"aiSwitch", ... } or nil.
|
||||
-- battle.aiUses is initialized per enemy Pokémon (wAICount).
|
||||
function TrainerAI.classAction(battle)
|
||||
if battle.kind ~= "trainer" or not battle.trainer then return nil end
|
||||
local class = require("data.scripts.ai_classes")[battle.trainer.id]
|
||||
local class = TrainerAI.classFor(battle)
|
||||
if not class then return nil end
|
||||
if (battle.aiUses or 0) <= 0 then return nil end
|
||||
local rng = battle.rng
|
||||
@@ -154,6 +166,50 @@ local function hasBetterMove(battler, judged, battle)
|
||||
return false
|
||||
end
|
||||
|
||||
-- The three vanilla passes as ai_classes layer records: vanilla is just the
|
||||
-- first registrant, so a mod patches one instead of reimplementing trainer
|
||||
-- AI. src/mods/Builtins.lua registers them; chooseMove dispatches through
|
||||
-- whatever the registry holds and falls back here when a battle was built
|
||||
-- without a loader. view.encourageTurn is wAILayer2Encouragement == 1.
|
||||
TrainerAI.LAYERS = {
|
||||
LAYER_1 = { kind = "layer", score = function(view, def, score)
|
||||
-- `add $5`: heavily discourage a zero-power status move that would
|
||||
-- fail because the player is already statused
|
||||
if def and view.target.mon.status and def.power == 0
|
||||
and STATUS_EFFECTS[def.effect] then
|
||||
return score + 5
|
||||
end
|
||||
return score
|
||||
end },
|
||||
LAYER_2 = { kind = "layer", score = function(view, def, score)
|
||||
if def and view.encourageTurn and ENCOURAGE_EFFECTS[def.effect] then
|
||||
return score - 1 -- `dec [hl]`: slightly encourage
|
||||
end
|
||||
return score
|
||||
end },
|
||||
-- AIGetTypeEffectiveness only reads the FIRST matching TypeEffects row for
|
||||
-- (move type vs either defender type) -- no dual-type product -- and runs
|
||||
-- for non-damaging moves too. The table holds no value-10 rows, so
|
||||
-- >10 / <10 reproduces the oracle's compare against $10.
|
||||
LAYER_3 = { kind = "layer", score = function(view, def, score)
|
||||
if not def then return score end
|
||||
local row = TypeChart.rows(def.type, view.target.curTypes)[1]
|
||||
if row and row > 10 then
|
||||
return score - 1 -- `dec [hl]`: encourage a super-effective move
|
||||
elseif row and row < 10 and hasBetterMove(view.user, def, view.battle) then
|
||||
return score + 1 -- `inc [hl]`: discourage when a better move is known
|
||||
end
|
||||
return score
|
||||
end },
|
||||
}
|
||||
|
||||
-- vanilla registrations, kept beside the other Builtins delegations
|
||||
function TrainerAI.registerInto(registry, _, owner)
|
||||
for id, record in pairs(TrainerAI.LAYERS) do
|
||||
registry:register(id, record, owner)
|
||||
end
|
||||
end
|
||||
|
||||
function TrainerAI.chooseMove(battler, rng, battle)
|
||||
rng = rng or love.math.random
|
||||
local usable = {}
|
||||
@@ -179,40 +235,36 @@ function TrainerAI.chooseMove(battler, rng, battle)
|
||||
return usable[rng(1, #usable)]
|
||||
end
|
||||
|
||||
-- aiMods entries may name registered ai_classes layer records; a number n
|
||||
-- resolves through "LAYER_<n>", which is how the vanilla three are keyed.
|
||||
-- A battle built without a loader has no merged registry, so the built-in
|
||||
-- records answer directly.
|
||||
local classes = battle.data and battle.data.ai_classes
|
||||
local layers, view = {}, nil
|
||||
for _, mod in ipairs(mods) do
|
||||
local id = type(mod) == "string" and mod or ("LAYER_" .. tostring(mod))
|
||||
local record = classes and classes[id]
|
||||
if not (record and record.score) then record = TrainerAI.LAYERS[id] end
|
||||
if record and record.score then
|
||||
layers[#layers + 1] = record.score
|
||||
view = view or { battle = battle, user = battler, target = battle.player,
|
||||
data = battle.data, rng = rng,
|
||||
encourageTurn = encourageTurn }
|
||||
end
|
||||
end
|
||||
|
||||
-- AIEnemyTrainerChooseMoves (engine/battle/trainer_ai.asm:3-257): every
|
||||
-- usable move starts at a base score of 10; the class's modification
|
||||
-- functions adjust it additively, then the MINIMUM-scored move is chosen
|
||||
-- with ties broken uniformly among the minima (core.asm:2971-3002 rolls a
|
||||
-- fresh byte among the value-1 slots). A non-minimal move is never
|
||||
-- selectable.
|
||||
local target = battle.player
|
||||
local scores = {}
|
||||
for i, mv in ipairs(usable) do
|
||||
local def = battle.data.moves[mv.id]
|
||||
local s = 10
|
||||
for _, mod in ipairs(mods) do
|
||||
if mod == 1 and def and target.mon.status
|
||||
and def.power == 0 and STATUS_EFFECTS[def.effect] then
|
||||
-- AIMoveChoiceModification1: `add $5` -- heavily discourage a
|
||||
-- zero-power status move that would fail (player already statused)
|
||||
s = s + 5
|
||||
elseif mod == 2 and def and encourageTurn
|
||||
and ENCOURAGE_EFFECTS[def.effect] then
|
||||
-- AIMoveChoiceModification2: `dec [hl]` -- slightly encourage
|
||||
s = s - 1
|
||||
elseif mod == 3 and def then
|
||||
-- AIMoveChoiceModification3 via AIGetTypeEffectiveness only reads
|
||||
-- the FIRST matching TypeEffects row for (move type vs either
|
||||
-- defender type) -- no dual-type product -- and runs for
|
||||
-- non-damaging moves too. The table holds no value-10 rows, so
|
||||
-- >10 / <10 reproduces the oracle's compare against $10.
|
||||
local row = TypeChart.rows(def.type, target.curTypes)[1]
|
||||
if row and row > 10 then
|
||||
s = s - 1 -- `dec [hl]`: encourage a super-effective move
|
||||
elseif row and row < 10 and hasBetterMove(battler, def, battle) then
|
||||
s = s + 1 -- `inc [hl]`: discourage when a better move is known
|
||||
end
|
||||
end
|
||||
for _, score in ipairs(layers) do
|
||||
s = score(view, def, s) or s
|
||||
end
|
||||
scores[i] = s
|
||||
end
|
||||
|
||||
@@ -1,31 +1,46 @@
|
||||
-- Turn order, from engine/battle/core.asm MainInBattleLoop: compare
|
||||
-- effective speed; ties are a coin flip. QUICK_ATTACK moves first and
|
||||
-- COUNTER last (Gen 1 has only these two priority moves, checked by id).
|
||||
-- effective speed; ties are a coin flip. Move priority reads the move
|
||||
-- record's priority field; the id table below covers pre-existing
|
||||
-- imported caches (Gen 1 has only QUICK_ATTACK first and COUNTER last).
|
||||
|
||||
local Damage = require("src.battle.Damage")
|
||||
local Stats = require("src.pokemon.Stats")
|
||||
local Status = require("src.battle.Status")
|
||||
|
||||
local TurnOrder = {}
|
||||
|
||||
local function effectiveSpeed(battler)
|
||||
local spd = Stats.applyStage(battler.curStats.speed,
|
||||
battler.stages and battler.stages.speed or 0)
|
||||
-- ApplyBadgeStatBoosts: the SOULBADGE (bit 4) boosts speed
|
||||
if battler.badges and battler.badges.SOULBADGE then
|
||||
spd = math.floor(spd * 9 / 8)
|
||||
-- ApplyBadgeStatBoosts: the SOULBADGE boosts speed; the rows come from
|
||||
-- the battler's merged badgeBoosts with the vanilla list as fallback
|
||||
local badges = battler.badges
|
||||
if badges then
|
||||
for _, row in ipairs(battler.badgeBoosts or Damage.BADGE_BOOSTS) do
|
||||
if row.stat == "speed" and badges[row.badge] then
|
||||
spd = math.floor(spd * (row.num or 9) / (row.den or 8))
|
||||
break
|
||||
end
|
||||
end
|
||||
end
|
||||
-- paralysis quarters speed; hazeStatReset suppresses it because Haze
|
||||
-- (haze.asm ResetStats) copied the unmodified speed over the quartered
|
||||
-- battle stat, lifting the penalty until the next stat recompute.
|
||||
if battler.mon.status == "PAR" and not battler.hazeStatReset then
|
||||
spd = math.max(1, math.floor(spd / 4))
|
||||
-- paralysis quarters speed (the status record's statPenalty);
|
||||
-- hazeStatReset suppresses it because Haze (haze.asm ResetStats)
|
||||
-- copied the unmodified speed over the quartered battle stat, lifting
|
||||
-- the penalty until the next stat recompute.
|
||||
local record = Status.recordFor(battler.statuses, battler.mon.status)
|
||||
local penalty = record and record.statPenalty
|
||||
if penalty and penalty.stat == "speed" and not battler.hazeStatReset then
|
||||
spd = math.max(1, math.floor(spd / penalty.div))
|
||||
end
|
||||
return spd
|
||||
end
|
||||
|
||||
local function priority(moveId)
|
||||
if moveId == "QUICK_ATTACK" then return 1 end
|
||||
if moveId == "COUNTER" then return -1 end
|
||||
return 0
|
||||
local PRIORITY = { QUICK_ATTACK = 1, COUNTER = -1 }
|
||||
|
||||
local function priority(move)
|
||||
if not move then return 0 end
|
||||
if move.priority then return move.priority end
|
||||
return PRIORITY[move.id] or 0
|
||||
end
|
||||
|
||||
-- Returns true when battler a moves before battler b. invertTie flips
|
||||
@@ -34,7 +49,7 @@ end
|
||||
-- who moves first.
|
||||
function TurnOrder.firstMover(a, aMove, b, bMove, rng, invertTie)
|
||||
rng = rng or love.math.random
|
||||
local pa, pb = priority(aMove and aMove.id), priority(bMove and bMove.id)
|
||||
local pa, pb = priority(aMove), priority(bMove)
|
||||
if pa ~= pb then return pa > pb end
|
||||
local sa, sb = effectiveSpeed(a), effectiveSpeed(b)
|
||||
if sa ~= sb then return sa > sb end
|
||||
|
||||
@@ -6,6 +6,7 @@ local TypeChart = {}
|
||||
|
||||
local index -- [atk][def] -> x10 multiplier
|
||||
local matchups -- ROM-ordered TypeEffects rows
|
||||
local types -- merged type records (physical/special category, display name)
|
||||
|
||||
function TypeChart.load(data)
|
||||
index = {}
|
||||
@@ -14,6 +15,21 @@ function TypeChart.load(data)
|
||||
index[m.attacker] = index[m.attacker] or {}
|
||||
index[m.attacker][m.defender] = m.multiplier
|
||||
end
|
||||
types = data.type_chart.types
|
||||
end
|
||||
|
||||
-- the merged type record's category; falls back to the vanilla records
|
||||
-- so pure-module callers need no load
|
||||
function TypeChart.category(typeId)
|
||||
local record = types and types[typeId] or TypeChart.TYPES[typeId]
|
||||
return record and record.category or nil
|
||||
end
|
||||
|
||||
-- display name for the move-select TYPE/ box (mod types render their
|
||||
-- name instead of their raw id)
|
||||
function TypeChart.displayName(typeId)
|
||||
local record = types and types[typeId] or TypeChart.TYPES[typeId]
|
||||
return record and record.name or typeId
|
||||
end
|
||||
|
||||
-- The x10 multipliers of every TypeEffects row that applies, in ROM
|
||||
@@ -52,4 +68,39 @@ function TypeChart.effectiveness(moveType, defenderTypes)
|
||||
return mult
|
||||
end
|
||||
|
||||
-- Gen 1 splits physical from special by TYPE, not by move: the seven types
|
||||
-- from FIRE up are special (engine/battle/effect_commands.asm compares the
|
||||
-- type id against SPECIAL). The list Damage.isSpecial carries is the same
|
||||
-- one, restated here as the type records the type_chart registry serves.
|
||||
TypeChart.TYPES = {
|
||||
NORMAL = { name = "NORMAL", category = "physical" },
|
||||
FIGHTING = { name = "FIGHTING", category = "physical" },
|
||||
FLYING = { name = "FLYING", category = "physical" },
|
||||
POISON = { name = "POISON", category = "physical" },
|
||||
GROUND = { name = "GROUND", category = "physical" },
|
||||
ROCK = { name = "ROCK", category = "physical" },
|
||||
BUG = { name = "BUG", category = "physical" },
|
||||
GHOST = { name = "GHOST", category = "physical" },
|
||||
FIRE = { name = "FIRE", category = "special" },
|
||||
WATER = { name = "WATER", category = "special" },
|
||||
GRASS = { name = "GRASS", category = "special" },
|
||||
ELECTRIC = { name = "ELECTRIC", category = "special" },
|
||||
PSYCHIC_TYPE = { name = "PSYCHIC", category = "special" },
|
||||
ICE = { name = "ICE", category = "special" },
|
||||
DRAGON = { name = "DRAGON", category = "special" },
|
||||
}
|
||||
|
||||
-- The matchup rows come from the generated chart, so a dataset with a
|
||||
-- different table registers a different world without touching this file.
|
||||
function TypeChart.registerInto(registry, data, owner)
|
||||
for id, record in pairs(TypeChart.TYPES) do
|
||||
registry:register(id, record, owner)
|
||||
end
|
||||
local chart = data and data.type_chart
|
||||
for _, row in ipairs(chart and chart.matchups or {}) do
|
||||
registry:register(row.attacker .. ">" .. row.defender,
|
||||
{ multiplier = row.multiplier }, owner)
|
||||
end
|
||||
end
|
||||
|
||||
return TypeChart
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
local bit = require("bit")
|
||||
local Assets = require("src.render.Assets")
|
||||
|
||||
local ChipAudio = {}
|
||||
|
||||
@@ -48,6 +49,22 @@ local function loadBanks(data)
|
||||
return banks
|
||||
end
|
||||
|
||||
-- A def-local program (ChipAsm output) is mounted as pseudo-bank 0 next to
|
||||
-- the ROM banks, so the 0x4000-window byte reader and every call/loop
|
||||
-- target work unchanged. The ROM's own cached bank table is never touched
|
||||
-- because bank 0 differs per def, and a blob that carries its own waves and
|
||||
-- drums renders even where programs.bin is unreadable.
|
||||
local function engineBanks(data, chip)
|
||||
if not chip then return loadBanks(data) end
|
||||
local banks = {}
|
||||
local ok, romBanks = pcall(loadBanks, data)
|
||||
if ok then
|
||||
for bank, bytes in pairs(romBanks) do banks[bank] = bytes end
|
||||
end
|
||||
banks[0] = chip.blob
|
||||
return banks
|
||||
end
|
||||
|
||||
local function romByte(banks, bank, address)
|
||||
local bytes = assert(banks[bank], "uncached audio bank " .. tostring(bank))
|
||||
local value = bytes:byte(address - 0x4000 + 1)
|
||||
@@ -497,6 +514,8 @@ function Channel:sample()
|
||||
if event.wave then
|
||||
local wave = self.engine.waves[
|
||||
math.min(event.waveInstrument + 1, #self.engine.waves)]
|
||||
-- a def-local program may omit its wave table entirely
|
||||
if not wave then return 0 end
|
||||
local index = math.min(32, math.floor(phase * 32) + 1)
|
||||
return wave[index] * event.waveLevel * 0.55
|
||||
end
|
||||
@@ -511,6 +530,9 @@ local Engine = {}
|
||||
Engine.__index = Engine
|
||||
|
||||
function Engine:noiseInstrument(number)
|
||||
-- a def-local drum wins over the ROM engine's table for that id
|
||||
local custom = self.customDrums and self.customDrums[number]
|
||||
if custom then return custom end
|
||||
local cached = self.noiseInstruments[number]
|
||||
if cached then return cached end
|
||||
|
||||
@@ -571,20 +593,55 @@ local function readWaves(banks, audio, engineNumber)
|
||||
return waves
|
||||
end
|
||||
|
||||
-- def-local waves are authored either as raw 0-15 nibbles (the ROM's own
|
||||
-- units) or as the -1..1 samples readWaves produces; the synth wants the
|
||||
-- latter
|
||||
local function normalizeWaves(source)
|
||||
local waves = {}
|
||||
for index, values in ipairs(source) do
|
||||
local nibbles = false
|
||||
for _, value in ipairs(values) do
|
||||
if value > 1 or value < -1 then nibbles = true break end
|
||||
end
|
||||
local wave = {}
|
||||
for position, value in ipairs(values) do
|
||||
wave[position] = nibbles and (value - 7.5) / 7.5 or value
|
||||
end
|
||||
waves[index] = wave
|
||||
end
|
||||
return waves
|
||||
end
|
||||
|
||||
function Engine.new(data, header, options)
|
||||
options = options or {}
|
||||
local banks = loadBanks(data)
|
||||
local audio = data.audio or {}
|
||||
-- shape dispatch: a def-local chip program supplies its own channels and
|
||||
-- may supply its own waves/drums, falling back to a ROM engine's tables
|
||||
local chip = header.chip
|
||||
local banks = engineBanks(data, chip)
|
||||
local engineNumber = chip and (chip.engine or 1) or header.engine
|
||||
local waves
|
||||
if chip and chip.waves then
|
||||
waves = normalizeWaves(chip.waves)
|
||||
elseif chip then
|
||||
local ok, romWaves = pcall(readWaves, banks, audio, engineNumber)
|
||||
waves = ok and romWaves or {}
|
||||
else
|
||||
waves = readWaves(banks, audio, engineNumber)
|
||||
end
|
||||
local engine = setmetatable({
|
||||
banks = banks,
|
||||
tempo = 0x100,
|
||||
pan = 0xFF,
|
||||
waves = readWaves(banks, data.audio, header.engine),
|
||||
noiseHeaders = data.audio.noiseHeaders
|
||||
and data.audio.noiseHeaders[tostring(header.engine)] or {},
|
||||
waves = waves,
|
||||
noiseHeaders = audio.noiseHeaders
|
||||
and audio.noiseHeaders[tostring(engineNumber)] or {},
|
||||
customDrums = chip and chip.drums or nil,
|
||||
noiseInstruments = {},
|
||||
channels = {},
|
||||
}, Engine)
|
||||
for _, spec in ipairs(headerChannels(banks, header)) do
|
||||
for _, spec in ipairs(chip and chip.channels
|
||||
or headerChannels(banks, header)) do
|
||||
local frameTicks = options.frameTicks
|
||||
local hardware = (spec.number - 1) % 4 + 1
|
||||
if hardware == 4 then
|
||||
@@ -593,7 +650,7 @@ function Engine.new(data, header, options)
|
||||
frameTicks = 0x80 + options.cryLength
|
||||
end
|
||||
engine.channels[#engine.channels + 1] = Channel.new(engine, spec, {
|
||||
bank = header.bank,
|
||||
bank = chip and 0 or header.bank,
|
||||
sfx = options.sfx,
|
||||
allowLoops = options.allowLoops,
|
||||
frequencyOffset = options.frequencyOffset,
|
||||
@@ -663,14 +720,14 @@ local function fillMusic()
|
||||
end
|
||||
|
||||
function ChipAudio.playMusic(data, header, allowLoops)
|
||||
ChipAudio.stopMusic()
|
||||
-- build before tearing down: a def that fails to compile (bad addresses,
|
||||
-- unreadable blob) must leave the outgoing song sounding
|
||||
local engine = Engine.new(data, header, { allowLoops = allowLoops })
|
||||
local ok, source = pcall(
|
||||
love.audio.newQueueableSource, SAMPLE_RATE, 16, 2, MUSIC_BUFFER_COUNT)
|
||||
if not ok then return nil, source end
|
||||
currentMusic = {
|
||||
source = source,
|
||||
engine = Engine.new(data, header, { allowLoops = allowLoops }),
|
||||
}
|
||||
ChipAudio.stopMusic()
|
||||
currentMusic = { source = source, engine = engine }
|
||||
fillMusic()
|
||||
source:play()
|
||||
return source
|
||||
@@ -700,6 +757,17 @@ function ChipAudio.stopMusic()
|
||||
currentMusic = nil
|
||||
end
|
||||
|
||||
-- hot reload: the next play re-reads programs.bin (a mod may have swapped
|
||||
-- the file out from under the single-slot bank cache)
|
||||
function ChipAudio.invalidate()
|
||||
ChipAudio.stopMusic()
|
||||
cachedProgramFile, cachedBanks = nil, nil
|
||||
end
|
||||
|
||||
-- a stale song must not keep sounding past the flush that replaced its
|
||||
-- program (20 §2 cache contract, chip music row)
|
||||
Assets.register(ChipAudio.invalidate)
|
||||
|
||||
local function renderEffect(data, header, options)
|
||||
if not header then return nil end
|
||||
options = options or {}
|
||||
@@ -796,10 +864,13 @@ function ChipAudio.newSfx(data, name, pitch, tempo, header)
|
||||
})
|
||||
end
|
||||
|
||||
function ChipAudio.newCry(data, species)
|
||||
local cry = data.audio.cries[species]
|
||||
-- `resolved` is a {header|chip, pitch, length} def the caller already worked
|
||||
-- out -- a derived cry borrowing another species' header with its own
|
||||
-- modifiers, which no registry lookup under `species` could find
|
||||
function ChipAudio.newCry(data, species, resolved)
|
||||
local cry = resolved or (data.audio.cries and data.audio.cries[species])
|
||||
if not cry then return nil end
|
||||
return renderEffect(data, cry.header, {
|
||||
return renderEffect(data, cry.chip and cry or cry.header, {
|
||||
frequencyOffset = cry.pitch,
|
||||
cryLength = cry.length,
|
||||
})
|
||||
|
||||
@@ -14,10 +14,113 @@ local MODULES = {
|
||||
-- Optional for compatibility with developer and stale caches.
|
||||
local OPTIONAL = { "audio", "palettes", "icons" }
|
||||
|
||||
-- The rules the engine still carries as literals. The constants registry
|
||||
-- deep-merges over these, so a value has to exist before a mod can patch
|
||||
-- it; each one is the number the engine hard-codes today, so seeding them
|
||||
-- changes nothing on a mod-free boot.
|
||||
local CONSTANT_DEFAULTS = {
|
||||
bagSize = 20, -- BAG_ITEM_CAPACITY (src/inventory/Bag.lua)
|
||||
partyMax = 6, -- PARTY_LENGTH (src/pokemon/Party.lua)
|
||||
boxCount = 12, boxSize = 20, -- Bill's PC (src/pokemon/Boxes.lua)
|
||||
moveMax = 4,
|
||||
levelCap = 100,
|
||||
coinCap = 9999, -- MAX_COINS (src/ui/SlotMachine.lua)
|
||||
-- move-slot repair when a scrub empties a mon (src/core/SaveData.lua);
|
||||
-- a total conversion without TACKLE patches this to its own floor
|
||||
fallbackMove = "TACKLE",
|
||||
hmMoves = { "CUT", "FLY", "SURF", "STRENGTH", "FLASH" }, -- IsMoveHM
|
||||
-- gym order (data/scripts/victories.lua); list position is the badge
|
||||
-- number the trainer card draws
|
||||
badges = {
|
||||
{ id = "BOULDERBADGE" }, { id = "CASCADEBADGE" }, { id = "THUNDERBADGE" },
|
||||
{ id = "RAINBOWBADGE" }, { id = "SOULBADGE" }, { id = "MARSHBADGE" },
|
||||
{ id = "VOLCANOBADGE" }, { id = "EARTHBADGE" },
|
||||
},
|
||||
}
|
||||
|
||||
-- field.boot is the total-conversion override point for the new game; the
|
||||
-- values match what SaveData.newGame and the Oak speech used to inline.
|
||||
local BOOT_DEFAULTS = {
|
||||
-- special_warps.asm NewGameWarp: REDS_HOUSE_2F, 3, 6 -- the bedroom, not
|
||||
-- the tile outside the house. lastHeal is deliberately absent: SaveData
|
||||
-- derives the vanilla blackout point, and seeding it here would leak into
|
||||
-- total conversions that patch the spawn without naming a heal point.
|
||||
startMap = "REDS_HOUSE_2F", startX = 3, startY = 6, startFacing = "down",
|
||||
playerName = "RED", rivalName = "BLUE",
|
||||
startMoney = 3000,
|
||||
screens = { splash = "IntroMovie", title = "TitleState", newGame = "OakSpeech" },
|
||||
}
|
||||
|
||||
local function copy(value)
|
||||
if type(value) ~= "table" then return value end
|
||||
local out = {}
|
||||
for k, v in pairs(value) do out[k] = copy(v) end
|
||||
return out
|
||||
end
|
||||
|
||||
-- Fills only what the cache is missing, so an importer that learns to
|
||||
-- stamp one of these keys silently takes over from the engine.
|
||||
function Data:seedDefaults()
|
||||
local constants = self.constants
|
||||
for key, value in pairs(CONSTANT_DEFAULTS) do
|
||||
if constants[key] == nil then constants[key] = copy(value) end
|
||||
end
|
||||
-- derived, not literal: a dataset with a different roster gets the right
|
||||
-- upper bound without 151 being written down anywhere
|
||||
if constants.dexSize == nil then
|
||||
local highest = 0
|
||||
for _, def in pairs(self.pokemon) do
|
||||
if def.dex and def.dex > highest then highest = def.dex end
|
||||
end
|
||||
constants.dexSize = highest
|
||||
end
|
||||
if constants.dexDigits == nil then
|
||||
constants.dexDigits = math.max(3, #tostring(constants.dexSize))
|
||||
end
|
||||
local boot = self.field.boot
|
||||
if boot == nil then
|
||||
boot = {}
|
||||
self.field.boot = boot
|
||||
end
|
||||
for key, value in pairs(BOOT_DEFAULTS) do
|
||||
if boot[key] == nil then boot[key] = copy(value) end
|
||||
end
|
||||
-- the naming screen presets the importer already extracts but nothing
|
||||
-- ever read (field.presetNames)
|
||||
if boot.namePresets == nil then
|
||||
local presets = self.field.presetNames or {}
|
||||
boot.namePresets = {
|
||||
player = copy(presets.player) or { "RED", "ASH", "JACK" },
|
||||
rival = copy(presets.rival) or { "BLUE", "GARY", "JOHN" },
|
||||
}
|
||||
end
|
||||
-- the overworld's Kanto literals, same fill-if-absent contract; required
|
||||
-- here rather than at the top so core keeps out of src/world at load time
|
||||
require("src.world.FieldDefaults").seed(self)
|
||||
end
|
||||
|
||||
-- POKEPORT_DATA_DIR points a test runner at another dataset root (the
|
||||
-- ROM-free fixture set, tests/fixture_data); unset -- every shipped build
|
||||
-- -- the generated modules load exactly as before. loadfile skips the
|
||||
-- require cache, so each overridden load hands back fresh tables.
|
||||
local function loadModule(dir, name)
|
||||
if dir then
|
||||
local chunk, err = loadfile(dir .. "/" .. name .. ".lua")
|
||||
if not chunk then return false, err end
|
||||
return pcall(chunk)
|
||||
end
|
||||
return pcall(require, "data.generated." .. name)
|
||||
end
|
||||
|
||||
function Data:load()
|
||||
local dir = os.getenv("POKEPORT_DATA_DIR")
|
||||
for _, name in ipairs(MODULES) do
|
||||
local ok, mod = pcall(require, "data.generated." .. name)
|
||||
local ok, mod = loadModule(dir, name)
|
||||
if not ok then
|
||||
if dir then
|
||||
error(("missing data module '%s/%s.lua' (POKEPORT_DATA_DIR).\n(%s)")
|
||||
:format(dir, name, mod))
|
||||
end
|
||||
error(("missing generated data module 'data/generated/%s.lua'.\n" ..
|
||||
"Import the ROM again or rebuild developer data.\n(%s)")
|
||||
:format(name, mod))
|
||||
@@ -25,18 +128,58 @@ function Data:load()
|
||||
self[name] = mod
|
||||
end
|
||||
for _, name in ipairs(OPTIONAL) do
|
||||
local ok, mod = pcall(require, "data.generated." .. name)
|
||||
local ok, mod = loadModule(dir, name)
|
||||
self[name] = ok and mod or nil
|
||||
if not ok then
|
||||
Logger.warn("optional data module '%s' missing (feature disabled)", name)
|
||||
end
|
||||
end
|
||||
-- before the mod loader runs: the deep registries fold over these
|
||||
self:seedDefaults()
|
||||
-- the top-level keys a pristine load leaves behind, so reloadGenerated can
|
||||
-- strip whatever a mod merge added since; kept on self (assigned before the
|
||||
-- scan so it counts itself) because tests load other tables through this
|
||||
-- method, and a shared upvalue would let them clobber the singleton's set
|
||||
local pristine = {}
|
||||
self._pristineKeys = pristine
|
||||
for key in pairs(self) do pristine[key] = true end
|
||||
Logger.info("generated data loaded (%d maps, %d species, %d moves)",
|
||||
(function() local n = 0 for _ in pairs(self.maps) do n = n + 1 end return n end)(),
|
||||
(function() local n = 0 for _ in pairs(self.pokemon) do n = n + 1 end return n end)(),
|
||||
(function() local n = 0 for _ in pairs(self.moves) do n = n + 1 end return n end)())
|
||||
end
|
||||
|
||||
-- dev-mode hot reload only (src/dev/HotReload.lua): drop every namespace the
|
||||
-- mod merge created, then re-require the generated modules so base records
|
||||
-- return to their on-disk values even where a mod edited them in place
|
||||
function Data:reloadGenerated()
|
||||
local pristine = self._pristineKeys
|
||||
if pristine then
|
||||
for key in pairs(self) do
|
||||
if not pristine[key] then self[key] = nil end
|
||||
end
|
||||
end
|
||||
for _, name in ipairs(MODULES) do
|
||||
package.loaded["data.generated." .. name] = nil
|
||||
end
|
||||
for _, name in ipairs(OPTIONAL) do
|
||||
package.loaded["data.generated." .. name] = nil
|
||||
end
|
||||
self:load()
|
||||
end
|
||||
|
||||
-- Resolve a dotted target path, creating empty tables on the way. Only the
|
||||
-- mod merge calls this; a vanilla boot never does, so an unmodded Data
|
||||
-- table is byte-identical to a pre-registry-v2 one.
|
||||
function Data.ensure(data, path)
|
||||
local node = data
|
||||
for key in path:gmatch("[^%.]+") do
|
||||
if node[key] == nil then node[key] = {} end
|
||||
node = node[key]
|
||||
end
|
||||
return node
|
||||
end
|
||||
|
||||
-- Resolve a TEXT_* constant on a map to a plain string (or nil if the text
|
||||
-- needs a hand-ported script; see data/scripts/).
|
||||
function Data:resolveText(mapLabel, textConst)
|
||||
|
||||
@@ -10,9 +10,22 @@ local SaveData = require("src.core.SaveData")
|
||||
local StateStack = require("src.core.StateStack")
|
||||
local TouchInput = require("src.core.TouchInput")
|
||||
local ModLoader = require("src.mods.Loader")
|
||||
local ModRuntime = require("src.mods.Runtime")
|
||||
local Screens = require("src.ui.Screens")
|
||||
|
||||
local Game = {}
|
||||
|
||||
-- dev-mode gate for the F5/backtick hotkeys; false keeps every src/dev
|
||||
-- module unloaded, so a player boot never touches a byte of dev code
|
||||
local devMode = os.getenv("POKEPORT_DEV") == "1"
|
||||
|
||||
-- the boot screen ids (field.boot.screens); a plain function so the
|
||||
-- headless harness can borrow makeTitleState onto a stub game
|
||||
local function bootScreens(game)
|
||||
local boot = game.data and game.data.field and game.data.field.boot
|
||||
return (boot and boot.screens) or {}
|
||||
end
|
||||
|
||||
function Game:load()
|
||||
self.data = Data
|
||||
Data:load()
|
||||
@@ -35,11 +48,17 @@ function Game:load()
|
||||
Renderer:init()
|
||||
|
||||
require("src.render.Font").load(Data)
|
||||
-- menu cursor/border/geometry constants; field.theme restyles them
|
||||
require("src.ui.Theme").load(Data)
|
||||
|
||||
self.stack = StateStack
|
||||
StateStack:init()
|
||||
|
||||
self.save = SaveData.newGame()
|
||||
self.save = SaveData.newGame(self:bootConfig())
|
||||
-- seed=true keeps what entry chunks wrote through mod.save before any
|
||||
-- save existed; the skeleton fires save.created exactly once
|
||||
self:adoptSave(self.save, true)
|
||||
ModRuntime.emit("save.created", { save = self.save })
|
||||
-- apply the persisted audio + display options before anything plays
|
||||
self:applyOptions(self.save.options)
|
||||
|
||||
@@ -49,6 +68,10 @@ function Game:load()
|
||||
local OverworldState = require("src.world.OverworldController")
|
||||
self.overworld = OverworldState
|
||||
|
||||
-- every service is up but nothing is on the stack yet; this payload is
|
||||
-- the sanctioned way for a mod to obtain the Game object
|
||||
ModRuntime.emit("game.ready", { game = self })
|
||||
|
||||
-- boot into the title screen (engine/movie/title.asm); NEW GAME runs
|
||||
-- the Oak speech + naming, CONTINUE restores the save. The headless
|
||||
-- autopilot skips straight into the overworld.
|
||||
@@ -58,37 +81,48 @@ function Game:load()
|
||||
else
|
||||
local titleState = self:makeTitleState()
|
||||
-- the copyright splash + Nidorino-vs-Gengar attract movie plays
|
||||
-- before the title (engine/movie/splash.asm + intro.asm)
|
||||
local IntroMovie = require("src.ui.IntroMovie")
|
||||
StateStack:push(IntroMovie.new(self, function()
|
||||
-- before the title (engine/movie/splash.asm + intro.asm); the ids come
|
||||
-- from field.boot.screens so a total conversion owns the whole boot
|
||||
Screens.push(self, bootScreens(self).splash or "IntroMovie", function()
|
||||
StateStack:push(titleState)
|
||||
end))
|
||||
end)
|
||||
end
|
||||
|
||||
Logger.info("game loaded")
|
||||
end
|
||||
|
||||
-- the merged field.boot: spawn, names, money and the naming presets a
|
||||
-- total conversion overrides. Threaded into SaveData so persistence stays
|
||||
-- free of a Data dependency.
|
||||
function Game:bootConfig()
|
||||
return self.data and self.data.field and self.data.field.boot
|
||||
end
|
||||
|
||||
-- the title screen with its NEW GAME / CONTINUE wiring; used at boot
|
||||
-- and by the START-menu QUIT confirmation
|
||||
function Game:makeTitleState()
|
||||
local TitleState = require("src.ui.TitleState")
|
||||
local OverworldState = require("src.world.OverworldController")
|
||||
return TitleState.new(self, {
|
||||
local factory = Screens.get(self, bootScreens(self).title or "TitleState")
|
||||
return factory.new(self, {
|
||||
onNewGame = function()
|
||||
while self.stack:top() do self.stack:pop() end
|
||||
-- New Game keeps the standalone options.lua preferences
|
||||
self.save = SaveData.newGame()
|
||||
self.save = SaveData.newGame(self:bootConfig())
|
||||
-- no bucket carry-over: mod state from an abandoned session must
|
||||
-- not leak into a fresh slot; mods seed via save.created instead
|
||||
self:adoptSave(self.save)
|
||||
ModRuntime.emit("save.created", { save = self.save })
|
||||
self:applyOptions(self.save.options)
|
||||
self.stack:push(OverworldState, self.save.player.map,
|
||||
self.save.player.x, self.save.player.y,
|
||||
self.save.player.facing)
|
||||
local OakSpeech = require("src.ui.OakSpeech")
|
||||
self.stack:push(OakSpeech.new(self, function() end))
|
||||
Screens.push(self, bootScreens(self).newGame or "OakSpeech",
|
||||
function() end)
|
||||
end,
|
||||
onContinue = function()
|
||||
local loaded = SaveData.load()
|
||||
local loaded, recovered = SaveData.load()
|
||||
if loaded then
|
||||
self:restoreSave(loaded)
|
||||
self:restoreSave(loaded, recovered)
|
||||
end
|
||||
end,
|
||||
})
|
||||
@@ -114,19 +148,47 @@ function Game:step(dt)
|
||||
self.stack:update(dt)
|
||||
-- play time for the trainer card / save screen
|
||||
self.save.playTime = (self.save.playTime or 0) + dt
|
||||
require("src.core.Music").update(Data)
|
||||
-- Music.update is NOT serviced here: it decrements fade counters and
|
||||
-- drives ChipAudio once per call, so running it inside the logic step
|
||||
-- would pitch music and sfx up under fast-forward. Game:update advances
|
||||
-- it on its own real-time 60Hz accumulator instead.
|
||||
end
|
||||
|
||||
-- The logic multiplier for this frame. Read live rather than cached so the
|
||||
-- Options row takes effect immediately; speedOverride is the --speed /
|
||||
-- POKEPORT_SPEED run argument, which wins over the saved option so a bot
|
||||
-- or screenshot run does not depend on whatever the player last chose.
|
||||
function Game:logicSpeed()
|
||||
local GameSpeed = require("src.core.GameSpeed")
|
||||
if self.speedOverride then return GameSpeed.clamp(self.speedOverride) end
|
||||
local opts = self.save and self.save.options
|
||||
return GameSpeed.clamp(opts and opts.speed or GameSpeed.DEFAULT)
|
||||
end
|
||||
|
||||
function Game:update(dt)
|
||||
-- Touch timers / prior-frame auto-releases before the fixed step so
|
||||
-- deferred A and edge pulses land in Input's press queue for this step.
|
||||
TouchInput:update(dt)
|
||||
FixedStep:update(dt)
|
||||
-- Fast-forward scales only the logic clock (see src/core/GameSpeed.lua).
|
||||
FixedStep:update(dt * self:logicSpeed())
|
||||
-- Audio runs off real time at a fixed 60Hz regardless of game speed or
|
||||
-- display refresh, so fades and chip synthesis keep their intended tempo
|
||||
-- whether we are at 1X, 10X, or running with vsync disabled.
|
||||
local step = FixedStep.STEP
|
||||
self.audioAccum = math.min((self.audioAccum or 0) + dt, 0.25)
|
||||
while self.audioAccum >= step do
|
||||
self.audioAccum = self.audioAccum - step
|
||||
require("src.core.Music").update(Data)
|
||||
end
|
||||
-- Overworld tilt toggle tween: presentational, so it runs on the real
|
||||
-- frame dt (not the fixed logic step) for a smooth ~0.25s glide.
|
||||
require("src.render.Tilt").update(dt)
|
||||
end
|
||||
|
||||
-- render.zones' identity default: unhooked, the zone list reaches the blit
|
||||
-- exactly as the owning state computed it
|
||||
local function sameZones(_, zones) return zones end
|
||||
|
||||
function Game:draw()
|
||||
-- the UI canvas clears transparent when the overworld's world pass
|
||||
-- shows through beneath it; opaque full-screen states get the classic
|
||||
@@ -146,6 +208,11 @@ function Game:draw()
|
||||
break
|
||||
end
|
||||
end
|
||||
-- 14's render.zones: weather/lighting overlays and custom colorization
|
||||
-- recolor or add zones before the blit
|
||||
if ModRuntime.wantsHook("render.zones") then
|
||||
zones = ModRuntime.call("render.zones", sameZones, self, zones)
|
||||
end
|
||||
if worldBelow and self.overworld.sgbWorldZones then
|
||||
worldZones = self.overworld:sgbWorldZones()
|
||||
end
|
||||
@@ -172,17 +239,31 @@ function Game:keypressed(key)
|
||||
self.stack:top():onKeyPressed(key)
|
||||
return
|
||||
end
|
||||
if devMode and key == "f5" then
|
||||
require("src.dev.HotReload").run(self)
|
||||
return
|
||||
end
|
||||
if devMode and key == "`" then
|
||||
self.stack:push(require("src.dev.Console").new(self))
|
||||
return
|
||||
end
|
||||
if key == "f10" then
|
||||
local ManagerState = require("src.mods.ManagerState")
|
||||
self.stack:push(ManagerState.new(self))
|
||||
-- toggle: the manager no longer swallows the keyboard, so a second
|
||||
-- press reaches this branch and closes it instead of stacking another
|
||||
local top = self.stack:top()
|
||||
if top and top.screenId == "ManagerState" then
|
||||
self.stack:pop()
|
||||
else
|
||||
Screens.push(self, "ManagerState")
|
||||
end
|
||||
return
|
||||
end
|
||||
if key == "f1" then
|
||||
self:writeSave()
|
||||
return
|
||||
elseif key == "f2" then
|
||||
local loaded = SaveData.load()
|
||||
if loaded then self:restoreSave(loaded) end
|
||||
local loaded, recovered = SaveData.load()
|
||||
if loaded then self:restoreSave(loaded, recovered) end
|
||||
return
|
||||
elseif key == "-" then
|
||||
self:zoomStep(-1)
|
||||
@@ -228,6 +309,12 @@ function Game:keyreleased(key)
|
||||
end
|
||||
|
||||
function Game:gamepadpressed(joystick, button)
|
||||
-- BindingsMenu's pad capture rides the same top-state routing as keys
|
||||
local top = self.stack and self.stack:top()
|
||||
if top and top.onGamepadPressed then
|
||||
top:onGamepadPressed(button)
|
||||
return
|
||||
end
|
||||
Input:gamepadpressed(joystick, button)
|
||||
end
|
||||
|
||||
@@ -251,12 +338,35 @@ function Game:touchreleased(id, x, y)
|
||||
TouchInput:touchreleased(id, x, y)
|
||||
end
|
||||
|
||||
-- Point the loader's mod.save backing at this save's modData so per-mod
|
||||
-- state persists with the slot. seedBuckets is boot-only: it keeps what
|
||||
-- entry chunks wrote before any save existed, while NEW GAME and
|
||||
-- CONTINUE replace the backing outright.
|
||||
function Game:adoptSave(save, seedBuckets)
|
||||
save.modData = save.modData or {}
|
||||
local loader = self.mods
|
||||
if not loader then return end
|
||||
if seedBuckets then
|
||||
for id, bucket in pairs(loader.modSave or {}) do
|
||||
if save.modData[id] == nil then save.modData[id] = bucket end
|
||||
end
|
||||
end
|
||||
loader.modSave = save.modData
|
||||
end
|
||||
|
||||
-- Capture the live world state into the save table and persist it.
|
||||
-- Options are flushed to options.lua as part of SaveData.save.
|
||||
function Game:writeSave()
|
||||
if self.overworld and self.overworld.captureSave then
|
||||
self.overworld:captureSave(self.save)
|
||||
end
|
||||
-- stamp here so the save.writing payload carries the exact meta the
|
||||
-- file gets; mods snapshot runtime state into their namespace now
|
||||
self.save.meta = SaveData.buildMeta(
|
||||
self.modStatus and self.modStatus.loaded, self.save.meta)
|
||||
if ModRuntime.wants("save.writing") then
|
||||
ModRuntime.emit("save.writing", { save = self.save, meta = self.save.meta })
|
||||
end
|
||||
SaveData.save(self.save)
|
||||
end
|
||||
|
||||
@@ -279,11 +389,25 @@ function Game:applyOptions(opts)
|
||||
require("src.render.GBCFX").applyOptions(opts)
|
||||
end
|
||||
|
||||
function Game:restoreSave(loaded)
|
||||
function Game:restoreSave(loaded, recovered)
|
||||
if ModRuntime.wants("save.loading") then
|
||||
ModRuntime.emit("save.loading", { raw = loaded })
|
||||
end
|
||||
-- mod chains replay before validation so a mod repairs its own data
|
||||
-- instead of watching it get quarantined; core steps already ran in
|
||||
-- SaveData.load and skip on the format guard
|
||||
local activeMods = self.modStatus and self.modStatus.loaded
|
||||
SaveData.runMigrations(loaded, self.mods and self.mods.migrations, activeMods)
|
||||
local modsDiff = SaveData.modsDiff(loaded, activeMods)
|
||||
local report = SaveData.validate(loaded, self.data)
|
||||
report.recovered = recovered
|
||||
report.modsDiff = modsDiff
|
||||
self.save = loaded
|
||||
self:adoptSave(loaded)
|
||||
-- SaveData.load already attached the standalone options.lua table
|
||||
self:applyOptions(loaded.options)
|
||||
-- saves from before OT/ID stamping: backfill with the player's
|
||||
-- saves from before OT/ID stamping: backfill with the player's (after
|
||||
-- the scrub, so every mon the stamp loop sees is known)
|
||||
local stamp = require("src.battle.BattleState").stampOT
|
||||
for _, mon in ipairs(loaded.party or {}) do stamp(loaded, mon) end
|
||||
for _, box in ipairs(loaded.boxes or {}) do
|
||||
@@ -293,6 +417,24 @@ function Game:restoreSave(loaded)
|
||||
while self.stack:top() do self.stack:pop() end
|
||||
self.stack:push(self.overworld, loaded.player.map,
|
||||
loaded.player.x, loaded.player.y, loaded.player.facing)
|
||||
self.saveReport = report
|
||||
if not SaveData.emptyReport(report) then
|
||||
-- the report screen is a Screens id so mods (or the ui milestone) own
|
||||
-- its looks; until one exists the log keeps a quarantine from being
|
||||
-- silent
|
||||
local ok = pcall(Screens.push, self, "QuarantineReport", report)
|
||||
if not ok then
|
||||
Logger.warn("load report: %d mons quarantined, %d items removed, %d maps remapped%s",
|
||||
#report.lostMons, #report.lostItems, #report.remappedMaps,
|
||||
recovered and (", recovered from " .. recovered) or "")
|
||||
local notice = SaveData.modsDiffNotice(modsDiff, loaded.meta)
|
||||
if notice then Logger.warn("%s", notice) end
|
||||
end
|
||||
end
|
||||
if ModRuntime.wants("save.loaded") then
|
||||
ModRuntime.emit("save.loaded",
|
||||
{ save = loaded, meta = loaded.meta, modsDiff = modsDiff })
|
||||
end
|
||||
end
|
||||
|
||||
return Game
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
-- Fast-forward multiplier for game logic.
|
||||
--
|
||||
-- Speeding up means running the 1/60 fixed step N times per real frame
|
||||
-- (Game:update), so everything driven by the step -- movement, text,
|
||||
-- battle timing, scripts -- advances N times faster while staying
|
||||
-- deterministic. Audio deliberately does NOT scale: Music.update drives
|
||||
-- fade counters and ChipAudio synthesis off its own real-time 60Hz
|
||||
-- accumulator in Game:update, so music and sfx play at normal pitch and
|
||||
-- tempo at every speed.
|
||||
--
|
||||
-- Vsync still caps how much work a real frame can do, so 10X is a target
|
||||
-- rather than a promise on a slow machine -- the logic simply runs as many
|
||||
-- steps as the frame budget allows.
|
||||
|
||||
local GameSpeed = {}
|
||||
|
||||
-- 20X exists for the bot runs (tests/drivers/route.lua): a full-route
|
||||
-- attempt is long enough that the iteration loop, not the engine, is the
|
||||
-- bottleneck. Vsync caps how much a real frame can do, so past 10X the
|
||||
-- multiplier is increasingly a ceiling rather than a rate.
|
||||
GameSpeed.LEVELS = { 1, 2, 4, 10, 20, 30, 50, 75 }
|
||||
GameSpeed.DEFAULT = 1
|
||||
|
||||
function GameSpeed.levelLabel(v)
|
||||
v = tonumber(v) or GameSpeed.DEFAULT
|
||||
if v == 1 then return "NORMAL" end
|
||||
return tostring(v) .. "X"
|
||||
end
|
||||
|
||||
-- nearest valid level for an arbitrary value (a hand-edited options.lua or
|
||||
-- a --speed argument), so a bad number degrades to something sane
|
||||
function GameSpeed.clamp(v)
|
||||
v = tonumber(v)
|
||||
if not v then return GameSpeed.DEFAULT end
|
||||
local best, bestDiff = GameSpeed.DEFAULT, math.huge
|
||||
for _, level in ipairs(GameSpeed.LEVELS) do
|
||||
local diff = math.abs(level - v)
|
||||
if diff < bestDiff then best, bestDiff = level, diff end
|
||||
end
|
||||
return best
|
||||
end
|
||||
|
||||
-- cycle to the next/previous level, wrapping (the options row idiom)
|
||||
function GameSpeed.cycle(v, dir)
|
||||
local levels = GameSpeed.LEVELS
|
||||
local cur = 1
|
||||
for i, level in ipairs(levels) do
|
||||
if level == GameSpeed.clamp(v) then cur = i break end
|
||||
end
|
||||
local nextIdx = (cur - 1 + (dir or 1)) % #levels + 1
|
||||
return levels[nextIdx]
|
||||
end
|
||||
|
||||
return GameSpeed
|
||||
@@ -1,11 +1,14 @@
|
||||
-- Music playback supports compact ROM channel programs synthesized live by
|
||||
-- ChipAudio and legacy pre-rendered WAV definitions. Songs with split WAVs
|
||||
-- chain def.file into def.loopFile in Music.update().
|
||||
-- ChipAudio, def-local chip programs (ChipAsm), and file definitions. The
|
||||
-- branch is chosen per song definition, never by a global import flag, so a
|
||||
-- file-backed song and a chip song coexist in one dataset. Songs with split
|
||||
-- files chain def.file into def.loopFile in Music.update().
|
||||
-- Map themes switch on map change; battles override with the battle
|
||||
-- theme and restore afterwards; riding the bike overrides outdoor map
|
||||
-- themes with Music_BikeRiding until dismount.
|
||||
-- themes with the bike song until dismount.
|
||||
|
||||
local Logger = require("src.core.Logger")
|
||||
local Runtime = require("src.mods.Runtime")
|
||||
|
||||
local Music = {}
|
||||
|
||||
@@ -37,8 +40,8 @@ local function applyFilter(src)
|
||||
end
|
||||
|
||||
local state = {
|
||||
enabled = true,
|
||||
current = nil, -- song label
|
||||
chip = false, -- the playing song is a synthesized channel program
|
||||
source = nil, -- currently playing source
|
||||
loopSource = nil, -- pre-loaded loop body waiting for the intro to end
|
||||
mapSong = nil, -- song to restore after a battle
|
||||
@@ -48,6 +51,7 @@ local state = {
|
||||
fanfare = nil, -- fanfare SFX source; the song pauses while it plays
|
||||
fanfareResume = false, -- start/resume state.source when the fanfare ends
|
||||
fade = nil, -- active volume-ramp fade-out (see Music.fadeOut)
|
||||
failed = {}, -- labels whose def could not be started; logged once
|
||||
}
|
||||
|
||||
-- Is a fanfare SFX (Sound.lua's FANFARES) still sounding?
|
||||
@@ -64,7 +68,7 @@ end
|
||||
-- channels on the Game Boy, so the current song halts and resumes when
|
||||
-- the jingle ends (see update()).
|
||||
function Music.duckForFanfare(src)
|
||||
if not state.enabled or not src then return end
|
||||
if not src then return end
|
||||
state.fanfare = src
|
||||
if state.source then
|
||||
local ok, playing = pcall(state.source.isPlaying, state.source)
|
||||
@@ -78,6 +82,8 @@ end
|
||||
-- Overworld themes where the bike can be ridden (outdoor maps plus the
|
||||
-- caves/dungeons where gen-1 allows cycling). Indoor themes such as
|
||||
-- Pokecenter/Gym/SilphCo never get replaced by the bike theme.
|
||||
-- data.audio.outdoorSongs supersedes this; the copy stays as the fallback
|
||||
-- for caches built before the importer wrote the table.
|
||||
local OUTDOOR = {
|
||||
Music_PalletTown = true,
|
||||
Music_Cities1 = true,
|
||||
@@ -97,10 +103,52 @@ local OUTDOOR = {
|
||||
Music_Dungeon3 = true,
|
||||
}
|
||||
|
||||
-- Scene themes the engine asks for by role rather than by label, so a total
|
||||
-- conversion can rename every song. data.audio.special supersedes this.
|
||||
local SPECIAL = {
|
||||
heal = "Music_PkmnHealed",
|
||||
title = "Music_TitleScreen",
|
||||
credits = "Music_Credits",
|
||||
hallOfFame = "Music_HallOfFame",
|
||||
introBattle = "Music_IntroBattle",
|
||||
oakRoute = "Music_Routes2",
|
||||
bike = "Music_BikeRiding",
|
||||
surf = "Music_Surfing",
|
||||
}
|
||||
|
||||
-- the label a scene role resolves to; call sites keep their own presence
|
||||
-- guard on the resolved label
|
||||
function Music.special(data, key)
|
||||
local special = data and data.audio and data.audio.special
|
||||
local label = special and special[key]
|
||||
if label ~= nil then return label end
|
||||
return SPECIAL[key]
|
||||
end
|
||||
|
||||
local function outdoorSongs(data)
|
||||
return data and data.audio and data.audio.outdoorSongs or OUTDOOR
|
||||
end
|
||||
|
||||
local function songDef(data, song)
|
||||
return data and data.audio and data.audio.songs and data.audio.songs[song]
|
||||
end
|
||||
|
||||
-- which mod put this label in the registry, for attributed failure logs
|
||||
local function songOwner(data, song)
|
||||
local owners = data and data.audio and data.audio._owners
|
||||
local songs = owners and owners.songs
|
||||
return songs and songs[song] or "base"
|
||||
end
|
||||
|
||||
-- one log line, plus an entry in the loader's error feed when a mod owns the
|
||||
-- def, so the manager's errors screen can flag that mod
|
||||
local function reportBadDef(data, song, err)
|
||||
local who = songOwner(data, song)
|
||||
Logger.warn("audio: bad song def %q (mod %s): %s", song, who, tostring(err))
|
||||
Runtime.reportError(who,
|
||||
("audio: bad song def %q: %s"):format(song, tostring(err)))
|
||||
end
|
||||
|
||||
local function stopSource(src)
|
||||
if src then pcall(src.stop, src) end
|
||||
end
|
||||
@@ -108,50 +156,73 @@ end
|
||||
local function newSource(file)
|
||||
local ok, src = pcall(love.audio.newSource, file, "stream")
|
||||
if ok and src then return src end
|
||||
Logger.warn("music: cannot load %s", tostring(file))
|
||||
return nil
|
||||
return nil, ok and "no source" or tostring(src)
|
||||
end
|
||||
|
||||
function Music.play(data, song, loop)
|
||||
if not state.enabled or not song or song == state.current then return end
|
||||
if not love.audio then -- headless test stub
|
||||
state.enabled = false
|
||||
-- Build the new song's sources; the caller only tears the old song down
|
||||
-- once this succeeded, so a broken def costs nothing but a log line.
|
||||
-- Returns src, loopSrc, isChip -- or nil plus the reason.
|
||||
local function startSong(data, def, wantLoop)
|
||||
if def.chip or (def.address and def.bank) then
|
||||
local ok, src = pcall(
|
||||
require("src.core.ChipAudio").playMusic, data, def, wantLoop)
|
||||
if ok and src then return src, nil, true end
|
||||
return nil, nil, nil, ok and "no source" or tostring(src)
|
||||
elseif def.file then
|
||||
local src, err = newSource(def.file)
|
||||
if not src then return nil, nil, nil, err end
|
||||
-- a missing loop body degrades to the intro file alone
|
||||
local loopSrc = def.loopFile and newSource(def.loopFile) or nil
|
||||
return src, loopSrc, false
|
||||
end
|
||||
return nil, nil, nil, "no chip program and no file"
|
||||
end
|
||||
|
||||
-- the single choke point every song choice passes through, so one hook
|
||||
-- covers map themes, battle themes, jingles and scene music
|
||||
local function selectSong(song, ctx)
|
||||
if not Runtime.wantsHook("music.select") then return song end
|
||||
return Runtime.call("music.select", function(chosen) return chosen end, song, {
|
||||
reason = ctx and ctx.reason or "direct",
|
||||
mapId = ctx and ctx.mapId,
|
||||
mapSong = state.mapSong,
|
||||
onBike = state.onBike,
|
||||
surfing = state.surfing,
|
||||
kind = ctx and ctx.kind,
|
||||
battleKind = ctx and ctx.kind,
|
||||
trainerId = ctx and ctx.trainerId,
|
||||
})
|
||||
end
|
||||
|
||||
function Music.play(data, song, loop, ctx)
|
||||
if not song then return end
|
||||
if not love.audio then return end -- headless test stub
|
||||
song = selectSong(song, ctx)
|
||||
-- a hook may silence the cue outright, or swap in a label the dedupe
|
||||
-- below has to compare against
|
||||
if not song or song == state.current then return end
|
||||
local def = songDef(data, song)
|
||||
if not def or state.failed[song] then return end
|
||||
local wantLoop = loop ~= false
|
||||
local src, loopSrc, isChip, err = startSong(data, def, wantLoop)
|
||||
if not src then
|
||||
state.failed[song] = true
|
||||
reportBadDef(data, song, err)
|
||||
return
|
||||
end
|
||||
local def = songDef(data, song)
|
||||
local runtime = data and data.audio and data.audio.runtime
|
||||
if not def or (not runtime and not def.file) then return end
|
||||
stopSource(state.source)
|
||||
stopSource(state.loopSource)
|
||||
if runtime then require("src.core.ChipAudio").stopMusic() end
|
||||
state.source, state.loopSource, state.fade = nil, nil, nil
|
||||
local wantLoop = loop ~= false
|
||||
local src
|
||||
if runtime then
|
||||
local ok, generated = pcall(
|
||||
require("src.core.ChipAudio").playMusic, data, def, wantLoop)
|
||||
if ok then src = generated end
|
||||
else
|
||||
src = newSource(def.file)
|
||||
end
|
||||
if not src then
|
||||
state.enabled = false
|
||||
state.current = nil
|
||||
return
|
||||
end
|
||||
if not runtime and def.loopFile then
|
||||
-- intro file plays once, then update() chains to the loop body
|
||||
-- a chip song holds the streaming source; ChipAudio.playMusic already
|
||||
-- swapped it when the new song is chip-backed too
|
||||
if state.chip and not isChip then require("src.core.ChipAudio").stopMusic() end
|
||||
state.fade = nil
|
||||
if loopSrc then
|
||||
-- intro plays once, then update() chains to the loop body
|
||||
-- (for one-shot jingles the body plays once and doesn't repeat)
|
||||
pcall(src.setLooping, src, false)
|
||||
local loopSrc = newSource(def.loopFile)
|
||||
if loopSrc then
|
||||
pcall(loopSrc.setLooping, loopSrc, wantLoop)
|
||||
applyVolume(loopSrc)
|
||||
applyFilter(loopSrc)
|
||||
state.loopSource = loopSrc
|
||||
else
|
||||
pcall(src.setLooping, src, wantLoop) -- degrade: intro file only
|
||||
end
|
||||
pcall(loopSrc.setLooping, loopSrc, wantLoop)
|
||||
applyVolume(loopSrc)
|
||||
applyFilter(loopSrc)
|
||||
else
|
||||
pcall(src.setLooping, src, wantLoop)
|
||||
end
|
||||
@@ -164,15 +235,34 @@ function Music.play(data, song, loop)
|
||||
else
|
||||
pcall(src.play, src)
|
||||
end
|
||||
state.source = src
|
||||
local previous = state.current
|
||||
state.source, state.loopSource, state.chip = src, loopSrc, isChip
|
||||
state.current = song
|
||||
if Runtime.wants("music.started") then
|
||||
Runtime.emit("music.started", {
|
||||
song = song, previous = previous, chip = isChip,
|
||||
reason = ctx and ctx.reason or "direct",
|
||||
})
|
||||
end
|
||||
end
|
||||
|
||||
function Music.stop()
|
||||
local previous = state.current
|
||||
stopSource(state.source)
|
||||
stopSource(state.loopSource)
|
||||
require("src.core.ChipAudio").stopMusic()
|
||||
state.current, state.source, state.loopSource, state.fade = nil, nil, nil, nil
|
||||
state.chip = false
|
||||
if previous and Runtime.wants("music.stopped") then
|
||||
Runtime.emit("music.stopped", { song = previous })
|
||||
end
|
||||
end
|
||||
|
||||
-- hot reload: forget the failed defs and the playing label so the next cue
|
||||
-- re-resolves against the freshly merged registries
|
||||
function Music.reload()
|
||||
state.failed = {}
|
||||
Music.stop()
|
||||
end
|
||||
|
||||
-- Ramp the current song's volume to silence, then stop it, mirroring the
|
||||
@@ -183,7 +273,6 @@ end
|
||||
-- writes (oak_speech.asm sets 10 at the shrink beat -> 7*10 = 70 frames
|
||||
-- to silence). Ticked once per frame from Music.update().
|
||||
function Music.fadeOut(control)
|
||||
if not state.enabled then return end
|
||||
if not state.source then Music.stop() return end
|
||||
control = math.max(1, control or 10)
|
||||
state.fade = {
|
||||
@@ -196,13 +285,14 @@ end
|
||||
|
||||
-- the song a map should currently play, honoring the bike/surf overrides
|
||||
local function effectiveMapSong(data, song)
|
||||
if state.onBike and song and OUTDOOR[song]
|
||||
and songDef(data, "Music_BikeRiding") then
|
||||
return "Music_BikeRiding"
|
||||
if not song or not outdoorSongs(data)[song] then return song end
|
||||
if state.onBike then
|
||||
local bike = Music.special(data, "bike")
|
||||
if bike and songDef(data, bike) then return bike end
|
||||
end
|
||||
if state.surfing and song and OUTDOOR[song]
|
||||
and songDef(data, "Music_Surfing") then
|
||||
return "Music_Surfing"
|
||||
if state.surfing then
|
||||
local surf = Music.special(data, "surf")
|
||||
if surf and songDef(data, surf) then return surf end
|
||||
end
|
||||
return song
|
||||
end
|
||||
@@ -216,20 +306,23 @@ function Music.playMap(data, mapId, onBike, surfing)
|
||||
state.onBike = not not onBike
|
||||
state.surfing = not not surfing
|
||||
local play = effectiveMapSong(data, song)
|
||||
if play then Music.play(data, play) end
|
||||
if play then Music.play(data, play, nil, { reason = "map", mapId = mapId }) end
|
||||
end
|
||||
|
||||
-- toggle the surf override mid-map (starting/ending a surf)
|
||||
function Music.setSurfing(data, surfing)
|
||||
state.surfing = not not surfing
|
||||
local play = effectiveMapSong(data, state.mapSong)
|
||||
if play then Music.play(data, play) end
|
||||
if play then Music.play(data, play, nil, { reason = "map" }) end
|
||||
end
|
||||
|
||||
-- battle themes; kind = "wild"|"trainer"|"gym"|"final"
|
||||
function Music.playBattle(data, kind)
|
||||
function Music.playBattle(data, kind, trainerId)
|
||||
local b = data.audio and data.audio.battle
|
||||
if b then Music.play(data, b[kind] or b.wild) end
|
||||
if b then
|
||||
Music.play(data, b[kind] or b.wild, nil,
|
||||
{ reason = "battle", kind = kind, trainerId = trainerId })
|
||||
end
|
||||
end
|
||||
|
||||
-- victory theme (Music_DefeatedWildMon/Trainer/GymLeader): starts the
|
||||
@@ -237,12 +330,12 @@ end
|
||||
-- (each Defeated* song ends in `sound_loop 0, .mainloop`); the battle's
|
||||
-- finish() restores the map theme, like the overworld reload's
|
||||
-- PlayDefaultMusicFadeOutCurrent. Returns true if the theme started.
|
||||
function Music.playVictory(data, kind)
|
||||
function Music.playVictory(data, kind, trainerId)
|
||||
local b = data.audio and data.audio.battle
|
||||
local jingle = b and b[kind .. "Win"]
|
||||
local def = jingle and songDef(data, jingle)
|
||||
if def and (def.file or (data.audio and data.audio.runtime)) then
|
||||
Music.play(data, jingle)
|
||||
if jingle and songDef(data, jingle) then
|
||||
Music.play(data, jingle, nil,
|
||||
{ reason = "victory", kind = kind, trainerId = trainerId })
|
||||
return true
|
||||
end
|
||||
return false
|
||||
@@ -251,11 +344,8 @@ end
|
||||
-- one-shot jingle (PkmnHealed, Jigglypuff's song): the map theme
|
||||
-- resumes when it ends, via update()
|
||||
function Music.playOnce(data, song)
|
||||
local def = songDef(data, song)
|
||||
if not (def and (def.file or (data.audio and data.audio.runtime))) then
|
||||
return false
|
||||
end
|
||||
Music.play(data, song, false)
|
||||
if not songDef(data, song) then return false end
|
||||
Music.play(data, song, false, { reason = "once" })
|
||||
state.pendingRestore = true
|
||||
return true
|
||||
end
|
||||
@@ -274,7 +364,7 @@ function Music.restoreMap(data)
|
||||
state.current = nil
|
||||
state.pendingRestore = nil
|
||||
local play = effectiveMapSong(data, state.mapSong)
|
||||
if play then Music.play(data, play) end
|
||||
if play then Music.play(data, play, nil, { reason = "map" }) end
|
||||
end
|
||||
|
||||
-- 0-7 music volume (0 mutes), applied to the playing song and the
|
||||
@@ -308,10 +398,7 @@ end
|
||||
-- call once per frame: chains a finished intro into its loop body and
|
||||
-- restores the map theme after a one-shot jingle
|
||||
function Music.update(data)
|
||||
if data and data.audio and data.audio.runtime then
|
||||
require("src.core.ChipAudio").update()
|
||||
end
|
||||
if not state.enabled then return end
|
||||
if state.chip then require("src.core.ChipAudio").update() end
|
||||
-- volume ramp (Music.fadeOut): hold the current level for `control`
|
||||
-- frames, then drop one level (FadeOutAudio decrements both rAUDVOL
|
||||
-- nibbles when its counter reaches 0); at level 0 the music stops.
|
||||
@@ -344,7 +431,7 @@ function Music.update(data)
|
||||
end
|
||||
state.fanfareResume = false
|
||||
end
|
||||
if data and data.audio and data.audio.runtime and not state.fanfare then
|
||||
if state.chip and not state.fanfare then
|
||||
require("src.core.ChipAudio").ensureMusicPlaying()
|
||||
end
|
||||
if state.loopSource and sourceStopped(state.source) then
|
||||
|
||||
@@ -2,14 +2,30 @@
|
||||
-- Options (audio, display, battle preferences) live in a separate
|
||||
-- options.lua so they survive New Game and aren't tied to a save slot.
|
||||
-- Both are plain Lua tables serialized as Lua source (deterministic
|
||||
-- key order).
|
||||
-- key order) and read back through SaveSerializer's data-only parser,
|
||||
-- so a save can never execute code.
|
||||
--
|
||||
-- The load pipeline is read -> parse -> migrate -> validate/quarantine
|
||||
-- -> restore; Game:restoreSave drives the last two phases with the
|
||||
-- merged Data threaded in, because this module must not reach into
|
||||
-- Data itself.
|
||||
|
||||
local Logger = require("src.core.Logger")
|
||||
local Version = require("src.core.Version")
|
||||
local SaveSerializer = require("src.core.SaveSerializer")
|
||||
local Runtime = require("src.mods.Runtime")
|
||||
local Semver = require("src.mods.Semver")
|
||||
local Boxes = require("src.pokemon.Boxes")
|
||||
local Bag = require("src.inventory.Bag")
|
||||
|
||||
local SaveData = {}
|
||||
|
||||
local FILENAME = "save.lua"
|
||||
local OPTIONS_FILENAME = "options.lua"
|
||||
-- one rolling backup plus the staged-write witness; load promotes either
|
||||
-- when the main file is missing or fails to parse
|
||||
local BACKUP_FILENAME = FILENAME .. ".bak"
|
||||
local TMP_FILENAME = FILENAME .. ".tmp"
|
||||
|
||||
-- Port + original Options menu defaults. Missing keys on load are filled
|
||||
-- from this table so old options.lua files stay compatible.
|
||||
@@ -25,6 +41,8 @@ function SaveData.defaultOptions()
|
||||
musicVol = 7,
|
||||
sfxVol = 7,
|
||||
musicFilter = 0,
|
||||
-- logic fast-forward multiplier; audio is unaffected (GameSpeed.lua)
|
||||
speed = 1,
|
||||
-- port display options (OptionsMenu / hotkeys 2/3/5)
|
||||
colors = "gbc",
|
||||
tilt = 0,
|
||||
@@ -47,153 +65,604 @@ function SaveData.mergeOptions(loaded)
|
||||
return opts
|
||||
end
|
||||
|
||||
local function serialize(v, indent)
|
||||
indent = indent or 0
|
||||
local pad = string.rep(" ", indent)
|
||||
local t = type(v)
|
||||
if t == "number" or t == "boolean" then
|
||||
return tostring(v)
|
||||
elseif t == "string" then
|
||||
return string.format("%q", v)
|
||||
elseif t == "table" then
|
||||
local keys = {}
|
||||
for k in pairs(v) do table.insert(keys, k) end
|
||||
table.sort(keys, function(a, b)
|
||||
local ta, tb = type(a), type(b)
|
||||
if ta ~= tb then return ta < tb end
|
||||
return a < b
|
||||
end)
|
||||
if next(v) == nil then return "{}" end
|
||||
local parts = {}
|
||||
for _, k in ipairs(keys) do
|
||||
local key
|
||||
if type(k) == "string" and k:match("^[%a_][%w_]*$") then
|
||||
key = k
|
||||
else
|
||||
key = "[" .. serialize(k) .. "]"
|
||||
end
|
||||
table.insert(parts, pad .. " " .. key .. " = " .. serialize(v[k], indent + 1))
|
||||
end
|
||||
return "{\n" .. table.concat(parts, ",\n") .. ",\n" .. pad .. "}"
|
||||
end
|
||||
error("cannot serialize " .. t)
|
||||
end
|
||||
|
||||
function SaveData.encode(data)
|
||||
return "return " .. serialize(data) .. "\n"
|
||||
return SaveSerializer.encode(data)
|
||||
end
|
||||
|
||||
function SaveData.decode(str)
|
||||
local loader = loadstring or load
|
||||
local chunk, err = loader(str, "@save.lua")
|
||||
if not chunk then return nil, err end
|
||||
local ok, data = pcall(chunk)
|
||||
if not ok then return nil, data end
|
||||
if type(data) ~= "table" then return nil, "save root must be a table" end
|
||||
return data
|
||||
return SaveSerializer.decode(str)
|
||||
end
|
||||
|
||||
function SaveData.saveOptions(opts)
|
||||
local function readTable(fs, name)
|
||||
if not fs.getInfo(name) then return nil, "no file: " .. name end
|
||||
local body = fs.read(name)
|
||||
if type(body) ~= "string" then return nil, "unreadable: " .. name end
|
||||
return SaveSerializer.decode(body)
|
||||
end
|
||||
|
||||
-- the stub filesystem some headless harnesses inject has no remove; a
|
||||
-- lingering tmp/bak there is harmless
|
||||
local function remove(fs, name)
|
||||
if fs.remove then fs.remove(name) end
|
||||
end
|
||||
|
||||
-- ------- options
|
||||
|
||||
-- Both take an optional fs (write/getInfo/read) defaulting to
|
||||
-- love.filesystem, so the mod loader's injected filesystem can carry the
|
||||
-- options round-trip headless (no love global).
|
||||
function SaveData.saveOptions(opts, fs)
|
||||
fs = fs or love.filesystem
|
||||
opts = SaveData.mergeOptions(opts)
|
||||
local ok, err = love.filesystem.write(OPTIONS_FILENAME, SaveData.encode(opts))
|
||||
-- modOptions is per-mod nested state: fold the on-disk sub-tree
|
||||
-- underneath (newest value winning per key) so one caller's partial
|
||||
-- write cannot clobber another mod's persisted keys. Every other
|
||||
-- option stays on the shallow path.
|
||||
local onDisk = readTable(fs, OPTIONS_FILENAME)
|
||||
if onDisk and type(onDisk.modOptions) == "table" then
|
||||
local merged = {}
|
||||
for modId, bucket in pairs(onDisk.modOptions) do
|
||||
merged[modId] = bucket
|
||||
end
|
||||
for modId, bucket in pairs(opts.modOptions or {}) do
|
||||
if type(bucket) == "table" and type(merged[modId]) == "table" then
|
||||
for k, v in pairs(bucket) do merged[modId][k] = v end
|
||||
else
|
||||
merged[modId] = bucket
|
||||
end
|
||||
end
|
||||
opts.modOptions = merged
|
||||
end
|
||||
local ok, err = fs.write(OPTIONS_FILENAME, SaveSerializer.encode(opts))
|
||||
if not ok then
|
||||
Logger.error("options save failed: %s", tostring(err))
|
||||
end
|
||||
return ok and opts or nil
|
||||
end
|
||||
|
||||
function SaveData.loadOptions()
|
||||
if not love.filesystem.getInfo(OPTIONS_FILENAME) then
|
||||
return SaveData.defaultOptions()
|
||||
end
|
||||
local chunk, err = love.filesystem.load(OPTIONS_FILENAME)
|
||||
if not chunk then
|
||||
Logger.error("options load failed: %s", tostring(err))
|
||||
return SaveData.defaultOptions()
|
||||
end
|
||||
local ok, data = pcall(chunk)
|
||||
if not ok or type(data) ~= "table" then
|
||||
Logger.error("options load failed: %s", tostring(data))
|
||||
function SaveData.loadOptions(fs)
|
||||
fs = fs or love.filesystem
|
||||
local data, err = readTable(fs, OPTIONS_FILENAME)
|
||||
if not data then
|
||||
if fs.getInfo(OPTIONS_FILENAME) then
|
||||
Logger.error("options load failed: %s", tostring(err))
|
||||
end
|
||||
return SaveData.defaultOptions()
|
||||
end
|
||||
return SaveData.mergeOptions(data)
|
||||
end
|
||||
|
||||
-- Game progress only; options are written separately via saveOptions.
|
||||
-- If `data.options` is present it is also flushed to options.lua so an
|
||||
-- F1 / in-game save keeps the live settings in sync, then stripped from
|
||||
-- the game file.
|
||||
function SaveData.save(data)
|
||||
if data.options then
|
||||
SaveData.saveOptions(data.options)
|
||||
end
|
||||
local gameOnly = {}
|
||||
for k, v in pairs(data) do
|
||||
if k ~= "options" then gameOnly[k] = v end
|
||||
end
|
||||
local ok, err = love.filesystem.write(FILENAME, SaveData.encode(gameOnly))
|
||||
if ok then
|
||||
Logger.info("saved game")
|
||||
-- ------- meta
|
||||
|
||||
-- the version/engine/mod-set stamp every v2 save carries; mods is the
|
||||
-- loaded list sorted by id and is the ground truth for the load-time
|
||||
-- mod-set diff. A nil mods list keeps the previous stamp's set so a
|
||||
-- headless writer (the save editor) never wipes it.
|
||||
function SaveData.buildMeta(mods, previous)
|
||||
local list
|
||||
if mods ~= nil then
|
||||
list = {}
|
||||
for _, mod in ipairs(mods) do
|
||||
list[#list + 1] = { id = mod.id, version = mod.version, api = mod.api }
|
||||
end
|
||||
table.sort(list, function(a, b) return a.id < b.id end)
|
||||
else
|
||||
Logger.error("save failed: %s", tostring(err))
|
||||
list = (type(previous) == "table" and previous.mods) or {}
|
||||
end
|
||||
return ok
|
||||
return {
|
||||
format = Version.saveFormat,
|
||||
engine = Version.engine,
|
||||
savedAt = os.time(),
|
||||
mods = list,
|
||||
}
|
||||
end
|
||||
|
||||
function SaveData.load()
|
||||
if not love.filesystem.getInfo(FILENAME) then
|
||||
return nil
|
||||
-- {added, removed, changed} between the set that wrote the save
|
||||
-- (meta.mods) and the active loaded set; all three empty on a vanilla
|
||||
-- load under vanilla
|
||||
function SaveData.modsDiff(save, activeMods)
|
||||
local stored = {}
|
||||
for _, entry in ipairs((save.meta and save.meta.mods) or {}) do
|
||||
if type(entry) == "table" and entry.id then
|
||||
stored[entry.id] = entry.version or ""
|
||||
end
|
||||
end
|
||||
local chunk, err = love.filesystem.load(FILENAME)
|
||||
if not chunk then
|
||||
Logger.error("load failed: %s", tostring(err))
|
||||
return nil
|
||||
local diff = { added = {}, removed = {}, changed = {} }
|
||||
for _, mod in ipairs(activeMods or {}) do
|
||||
local was = stored[mod.id]
|
||||
if was == nil then
|
||||
diff.added[#diff.added + 1] = mod.id
|
||||
elseif was ~= mod.version then
|
||||
diff.changed[#diff.changed + 1] = { id = mod.id, from = was, to = mod.version }
|
||||
end
|
||||
stored[mod.id] = nil
|
||||
end
|
||||
local ok, data = pcall(chunk)
|
||||
if not ok then
|
||||
Logger.error("load failed: %s", tostring(data))
|
||||
return nil
|
||||
for id in pairs(stored) do diff.removed[#diff.removed + 1] = id end
|
||||
table.sort(diff.added)
|
||||
table.sort(diff.removed)
|
||||
table.sort(diff.changed, function(a, b) return a.id < b.id end)
|
||||
return diff
|
||||
end
|
||||
|
||||
-- one-line load notice for a non-empty diff ("This save was made with
|
||||
-- 2 mods; 1 is no longer active"); nil when empty so a vanilla load
|
||||
-- stays silent
|
||||
function SaveData.modsDiffNotice(diff, meta)
|
||||
if type(diff) ~= "table" then return nil end
|
||||
local removed = #(diff.removed or {})
|
||||
local changed = #(diff.changed or {})
|
||||
local added = #(diff.added or {})
|
||||
if removed == 0 and changed == 0 and added == 0 then return nil end
|
||||
local wrote = #((type(meta) == "table" and meta.mods) or {})
|
||||
local parts = {}
|
||||
if removed > 0 then
|
||||
parts[#parts + 1] = removed .. (removed == 1 and " is" or " are") .. " no longer active"
|
||||
end
|
||||
-- saves from before the trainer ID existed: backfill once on load
|
||||
-- (like the OT backfill for old saves)
|
||||
if data.player and not data.player.id then
|
||||
data.player.id = math.random(0, 65535)
|
||||
if changed > 0 then
|
||||
parts[#parts + 1] = changed .. " changed version"
|
||||
end
|
||||
-- saves from before EVENT_BEAT_ROUTE12/16_SNORLAX existed: the object
|
||||
-- was already hidden (Snorlax beaten) but the flag was never added,
|
||||
-- and it can never be set again since the hidden object is
|
||||
-- unreachable -- backfill it from the toggle so it isn't stuck forever
|
||||
if data.objectToggles and data.flags then
|
||||
if added > 0 then
|
||||
parts[#parts + 1] = added .. " newly active"
|
||||
end
|
||||
return ("This save was made with %d mod%s; %s"):format(
|
||||
wrote, wrote == 1 and "" or "s", table.concat(parts, ", "))
|
||||
end
|
||||
|
||||
-- ------- migrations
|
||||
|
||||
-- Ordered engine steps keyed on meta.format, each reproducing the inline
|
||||
-- migration it replaced; a save already at the current format skips them
|
||||
-- all. Mod chains (recorded by Loader from mod.migrations:add) replay
|
||||
-- against the version stored in meta.mods, in semver order, before the
|
||||
-- validation pass -- so a mod repairs its own data instead of watching
|
||||
-- it get quarantined.
|
||||
local coreMigrations = {}
|
||||
|
||||
function SaveData.addCoreMigration(fromFormat, fn)
|
||||
coreMigrations[#coreMigrations + 1] =
|
||||
{ from = fromFormat, seq = #coreMigrations + 1, fn = fn }
|
||||
end
|
||||
|
||||
local function storedVersion(save, modId)
|
||||
for _, entry in ipairs((save.meta and save.meta.mods) or {}) do
|
||||
if type(entry) == "table" and entry.id == modId then
|
||||
return entry.version
|
||||
end
|
||||
end
|
||||
return nil
|
||||
end
|
||||
|
||||
local function semverLt(a, b)
|
||||
local order = Semver.compare(a, b)
|
||||
return order ~= nil and order < 0
|
||||
end
|
||||
|
||||
function SaveData.runMigrations(save, modChains, activeMods)
|
||||
table.sort(coreMigrations, function(a, b)
|
||||
if a.from ~= b.from then return a.from < b.from end
|
||||
return a.seq < b.seq
|
||||
end)
|
||||
-- every step whose from-format the save has not passed yet runs, in
|
||||
-- (from, registration) order; a save at the current format runs none
|
||||
local fmt = (save.meta and save.meta.format) or 1
|
||||
for _, m in ipairs(coreMigrations) do
|
||||
if m.from >= fmt then m.fn(save) end
|
||||
end
|
||||
-- a save that predates meta records an empty mod set: an old vanilla
|
||||
-- save becomes a v2 vanilla save
|
||||
save.meta = save.meta or { mods = {} }
|
||||
save.meta.format = Version.saveFormat
|
||||
for _, active in ipairs(activeMods or {}) do
|
||||
local modSave = save.modData and save.modData[active.id]
|
||||
local recorded = modChains and modChains[active.id]
|
||||
if modSave and recorded then
|
||||
local chain = {}
|
||||
for _, m in ipairs(recorded) do chain[#chain + 1] = m end
|
||||
table.sort(chain, function(a, b) return semverLt(a.since, b.since) end)
|
||||
local stored = storedVersion(save, active.id) or "0.0.0"
|
||||
for _, m in ipairs(chain) do
|
||||
if semverLt(stored, m.since) and not semverLt(active.version, m.since) then
|
||||
-- a throwing migration is skipped, not fatal: it would otherwise
|
||||
-- re-raise on every load and lock the player out of the save
|
||||
local ok, err = pcall(m.apply, modSave, save)
|
||||
if not ok then
|
||||
Logger.error("[%s] migration %s: %s -- skipped",
|
||||
active.id, tostring(m.since), tostring(err))
|
||||
break
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
return save
|
||||
end
|
||||
|
||||
-- saves from before the trainer ID existed: backfill once on load
|
||||
-- (like the OT backfill for old saves)
|
||||
SaveData.addCoreMigration(1, function(save)
|
||||
if save.player and not save.player.id then
|
||||
save.player.id = math.random(0, 65535)
|
||||
end
|
||||
end)
|
||||
|
||||
-- saves from before EVENT_BEAT_ROUTE12/16_SNORLAX existed: the object
|
||||
-- was already hidden (Snorlax beaten) but the flag was never added,
|
||||
-- and it can never be set again since the hidden object is
|
||||
-- unreachable -- backfill it from the toggle so it isn't stuck forever
|
||||
SaveData.addCoreMigration(1, function(save)
|
||||
if save.objectToggles and save.flags then
|
||||
local snorlaxRoutes = {
|
||||
{ map = "ROUTE_12", obj = "ROUTE12_SNORLAX", flag = "EVENT_BEAT_ROUTE12_SNORLAX" },
|
||||
{ map = "ROUTE_16", obj = "ROUTE16_SNORLAX", flag = "EVENT_BEAT_ROUTE16_SNORLAX" },
|
||||
}
|
||||
for _, r in ipairs(snorlaxRoutes) do
|
||||
local toggles = data.objectToggles[r.map]
|
||||
if toggles and toggles[r.obj] == false and not data.flags[r.flag] then
|
||||
data.flags[r.flag] = true
|
||||
local toggles = save.objectToggles[r.map]
|
||||
if toggles and toggles[r.obj] == false and not save.flags[r.flag] then
|
||||
save.flags[r.flag] = true
|
||||
end
|
||||
end
|
||||
end
|
||||
-- Migrate options that still live inside an old save.lua into the
|
||||
-- standalone options file (once), then always prefer options.lua.
|
||||
if type(data.options) == "table" and not love.filesystem.getInfo(OPTIONS_FILENAME) then
|
||||
end)
|
||||
|
||||
-- Migrate options that still live inside an old save.lua into the
|
||||
-- standalone options file (once); load always re-attaches options.lua
|
||||
-- afterwards either way
|
||||
SaveData.addCoreMigration(1, function(save)
|
||||
if type(save.options) == "table"
|
||||
and not love.filesystem.getInfo(OPTIONS_FILENAME) then
|
||||
SaveData.saveOptions(save.options)
|
||||
end
|
||||
end)
|
||||
|
||||
-- settle the box shape (single `box` list -> 12 boxes) before the
|
||||
-- validation pass walks it; Boxes keeps the lazy ensure for play paths
|
||||
SaveData.addCoreMigration(1, function(save)
|
||||
Boxes.ensure(save)
|
||||
end)
|
||||
|
||||
-- ------- write
|
||||
|
||||
-- Game progress only; options are written separately via saveOptions.
|
||||
-- If `data.options` is present it is also flushed to options.lua so an
|
||||
-- F1 / in-game save keeps the live settings in sync, then stripped from
|
||||
-- the game file. mods (when given) refreshes the meta stamp; the write
|
||||
-- itself rolls the last good save into .bak and stages the new bytes as
|
||||
-- a .tmp witness before the swap, so a crash mid-write is recoverable.
|
||||
function SaveData.save(data, mods)
|
||||
if data.options then
|
||||
SaveData.saveOptions(data.options)
|
||||
end
|
||||
data.options = SaveData.loadOptions()
|
||||
Logger.info("loaded save")
|
||||
return data
|
||||
if mods ~= nil or data.meta == nil then
|
||||
data.meta = SaveData.buildMeta(mods, data.meta)
|
||||
end
|
||||
local gameOnly = {}
|
||||
for k, v in pairs(data) do
|
||||
if k ~= "options" then gameOnly[k] = v end
|
||||
end
|
||||
local encoded = SaveSerializer.encode(gameOnly)
|
||||
local fs = love.filesystem
|
||||
if fs.getInfo(FILENAME) then
|
||||
local prev = fs.read(FILENAME)
|
||||
if prev then fs.write(BACKUP_FILENAME, prev) end
|
||||
end
|
||||
local ok, err = fs.write(TMP_FILENAME, encoded)
|
||||
if not ok then
|
||||
Logger.error("save failed: %s", tostring(err))
|
||||
return false
|
||||
end
|
||||
-- love.filesystem has no atomic rename: remove + rewrite, with the
|
||||
-- .tmp copy as the recovery witness in between
|
||||
remove(fs, FILENAME)
|
||||
ok, err = fs.write(FILENAME, encoded)
|
||||
if not ok then
|
||||
Logger.error("save failed: %s", tostring(err))
|
||||
return false
|
||||
end
|
||||
remove(fs, TMP_FILENAME)
|
||||
Logger.info("saved game")
|
||||
return true
|
||||
end
|
||||
|
||||
function SaveData.newGame()
|
||||
return {
|
||||
-- ------- read
|
||||
|
||||
-- returns the parsed save plus "tmp"/"bak" when the main file was gone
|
||||
-- or corrupt and a staged/backup copy was promoted; Game surfaces the
|
||||
-- recovery on the load report
|
||||
function SaveData.load()
|
||||
local fs = love.filesystem
|
||||
local data, err = readTable(fs, FILENAME)
|
||||
local recovered
|
||||
if not data then
|
||||
local tmp = readTable(fs, TMP_FILENAME)
|
||||
if tmp then
|
||||
data, recovered = tmp, "tmp"
|
||||
else
|
||||
local bak = readTable(fs, BACKUP_FILENAME)
|
||||
if bak then data, recovered = bak, "bak" end
|
||||
end
|
||||
if data then
|
||||
Logger.warn("save.lua %s; recovered from %s copy",
|
||||
fs.getInfo(FILENAME) and "corrupt" or "missing", recovered)
|
||||
fs.write(FILENAME, SaveSerializer.encode(data))
|
||||
end
|
||||
end
|
||||
if not data then
|
||||
if fs.getInfo(FILENAME) then
|
||||
Logger.error("load failed: %s", tostring(err))
|
||||
end
|
||||
return nil
|
||||
end
|
||||
SaveData.runMigrations(data)
|
||||
data.options = SaveData.loadOptions()
|
||||
Logger.info("loaded save")
|
||||
return data, recovered
|
||||
end
|
||||
|
||||
-- ------- validation and quarantine
|
||||
|
||||
local function known(tbl, id)
|
||||
return id ~= nil and type(tbl) == "table" and tbl[id] ~= nil
|
||||
end
|
||||
|
||||
-- only out-of-range values move; a vanilla save passes through untouched
|
||||
local function clamp(n, lo, hi, fallback)
|
||||
if type(n) ~= "number" then return fallback end
|
||||
if n < lo then return lo end
|
||||
if n > hi then return hi end
|
||||
return n
|
||||
end
|
||||
|
||||
local function ensureOrphaned(save)
|
||||
if not save.orphaned then
|
||||
save.orphaned = { mons = {}, items = {} }
|
||||
end
|
||||
save.orphaned.mons = save.orphaned.mons or {}
|
||||
save.orphaned.items = save.orphaned.items or {}
|
||||
return save.orphaned
|
||||
end
|
||||
|
||||
-- quarantined ids whose content reappeared (mod re-enabled) go home
|
||||
-- again: mons through the PC deposit, items through the bag with the PC
|
||||
-- as overflow
|
||||
local function reclaim(save, data, report)
|
||||
local orphaned = save.orphaned
|
||||
if not orphaned then return end
|
||||
for i = #(orphaned.mons or {}), 1, -1 do
|
||||
local mon = orphaned.mons[i]
|
||||
if type(mon) == "table" and known(data.pokemon, mon.species) then
|
||||
table.remove(orphaned.mons, i)
|
||||
local box = Boxes.deposit(save, mon)
|
||||
if box then
|
||||
report.restoredMons[#report.restoredMons + 1] =
|
||||
{ species = mon.species, box = box }
|
||||
else
|
||||
-- every box full: stays quarantined rather than vanishing
|
||||
table.insert(orphaned.mons, i, mon)
|
||||
end
|
||||
end
|
||||
end
|
||||
for i = #(orphaned.items or {}), 1, -1 do
|
||||
local entry = orphaned.items[i]
|
||||
if type(entry) == "table" and known(data.items, entry.id) then
|
||||
table.remove(orphaned.items, i)
|
||||
if entry.from == "pcItems" or type(save.inventory) ~= "table"
|
||||
or not Bag.add(save, entry.id, entry.count or 1) then
|
||||
save.pcItems = save.pcItems or {}
|
||||
save.pcItems[entry.id] = (save.pcItems[entry.id] or 0) + (entry.count or 1)
|
||||
end
|
||||
report.restoredItems[#report.restoredItems + 1] =
|
||||
{ id = entry.id, count = entry.count or 1 }
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
-- mirrors Protocol.unpackMon's clamp discipline for the fields play
|
||||
-- indexes; the level floor widens to 1 because a freshly caught level-1
|
||||
-- mon can legitimately sit in a save
|
||||
local function scrubKnownMon(mon, data)
|
||||
if type(mon.dvs) == "table" then
|
||||
for stat, v in pairs(mon.dvs) do mon.dvs[stat] = clamp(v, 0, 15, 0) end
|
||||
end
|
||||
if type(mon.statExp) == "table" then
|
||||
for stat, v in pairs(mon.statExp) do mon.statExp[stat] = clamp(v, 0, 65535, 0) end
|
||||
end
|
||||
mon.level = clamp(mon.level, 1, 100, 1)
|
||||
local moves = mon.moves
|
||||
if type(moves) ~= "table" then return end
|
||||
local hadMoves = #moves > 0
|
||||
for j = #moves, 1, -1 do
|
||||
local slot = moves[j]
|
||||
local id = type(slot) == "table" and slot.id or slot
|
||||
if not known(data.moves, id) then table.remove(moves, j) end
|
||||
end
|
||||
while #moves > 4 do table.remove(moves) end
|
||||
if hadMoves and #moves == 0 then
|
||||
-- data-driven repair so a total conversion without TACKLE still heals
|
||||
local fallback = (data.constants and data.constants.fallbackMove) or "TACKLE"
|
||||
local def = data.moves and data.moves[fallback]
|
||||
if def then
|
||||
moves[1] = { id = fallback, pp = def.pp }
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
local function scrubMonList(list, where, save, data, report)
|
||||
if type(list) ~= "table" then return end
|
||||
for i = #list, 1, -1 do
|
||||
local mon = list[i]
|
||||
if type(mon) ~= "table" or not known(data.pokemon, mon.species) then
|
||||
table.remove(list, i)
|
||||
ensureOrphaned(save)
|
||||
save.orphaned.mons[#save.orphaned.mons + 1] = mon
|
||||
report.lostMons[#report.lostMons + 1] =
|
||||
{ species = type(mon) == "table" and mon.species or nil, from = where }
|
||||
else
|
||||
scrubKnownMon(mon, data)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
local function scrubItemMap(map, where, save, data, report)
|
||||
if type(map) ~= "table" then return end
|
||||
for id, count in pairs(map) do
|
||||
if not known(data.items, id) then
|
||||
map[id] = nil
|
||||
ensureOrphaned(save)
|
||||
save.orphaned.items[#save.orphaned.items + 1] =
|
||||
{ id = id, count = count, from = where }
|
||||
report.lostItems[#report.lostItems + 1] =
|
||||
{ id = id, count = count, from = where }
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
local function scrubMaps(save, data, report)
|
||||
local boot = (data.field and data.field.boot) or {}
|
||||
local spawn = { map = boot.startMap or "REDS_HOUSE_2F",
|
||||
x = boot.startX or 3, y = boot.startY or 6 }
|
||||
-- heal point first, so the player fallback below always lands somewhere
|
||||
-- valid; boot's heal cell (threaded from field.boot) is the last resort
|
||||
if save.lastHeal and not known(data.maps, save.lastHeal.map) then
|
||||
local heal = boot.lastHeal or spawn
|
||||
report.remappedMaps[#report.remappedMaps + 1] =
|
||||
{ id = save.lastHeal.map, to = heal.map, field = "lastHeal" }
|
||||
save.lastHeal = { map = heal.map, x = heal.x, y = heal.y }
|
||||
end
|
||||
if save.player and not known(data.maps, save.player.map) then
|
||||
local heal = save.lastHeal or spawn
|
||||
report.remappedMaps[#report.remappedMaps + 1] =
|
||||
{ id = save.player.map, to = heal.map, field = "player" }
|
||||
save.player.map, save.player.x, save.player.y = heal.map, heal.x, heal.y
|
||||
end
|
||||
if save.lastOutdoor and not known(data.maps, save.lastOutdoor.id) then
|
||||
report.remappedMaps[#report.remappedMaps + 1] =
|
||||
{ id = save.lastOutdoor.id, field = "lastOutdoor" }
|
||||
save.lastOutdoor = nil
|
||||
end
|
||||
if save.lastHeal and type(save.lastHeal.outdoor) == "table"
|
||||
and not known(data.maps, save.lastHeal.outdoor.id) then
|
||||
save.lastHeal.outdoor = nil
|
||||
end
|
||||
end
|
||||
|
||||
-- Walks every content id the save references against the merged data and
|
||||
-- quarantines unknowns instead of letting them nil-index later: mons move
|
||||
-- to save.orphaned (the LOST box), items are removed with a report row,
|
||||
-- locations fall back to the heal point. Reclaims quarantined content
|
||||
-- whose id reappeared first. On a mod-free save every membership test
|
||||
-- passes and the save comes back untouched.
|
||||
function SaveData.validate(save, data)
|
||||
local report = { lostMons = {}, lostItems = {}, remappedMaps = {},
|
||||
restoredMons = {}, restoredItems = {} }
|
||||
reclaim(save, data, report)
|
||||
scrubMonList(save.party, "party", save, data, report)
|
||||
for b, box in ipairs(save.boxes or {}) do
|
||||
scrubMonList(box, "box " .. b, save, data, report)
|
||||
end
|
||||
local daycare = save.daycare
|
||||
if type(daycare) == "table" and type(daycare.mon) == "table" then
|
||||
if not known(data.pokemon, daycare.mon.species) then
|
||||
ensureOrphaned(save)
|
||||
save.orphaned.mons[#save.orphaned.mons + 1] = daycare.mon
|
||||
report.lostMons[#report.lostMons + 1] =
|
||||
{ species = daycare.mon.species, from = "daycare" }
|
||||
daycare.mon = nil
|
||||
else
|
||||
scrubKnownMon(daycare.mon, data)
|
||||
end
|
||||
end
|
||||
scrubItemMap(save.inventory, "inventory", save, data, report)
|
||||
scrubItemMap(save.pcItems, "pcItems", save, data, report)
|
||||
if type(save.bagOrder) == "table" then
|
||||
for i = #save.bagOrder, 1, -1 do
|
||||
if not known(data.items, save.bagOrder[i]) then
|
||||
table.remove(save.bagOrder, i)
|
||||
end
|
||||
end
|
||||
end
|
||||
scrubMaps(save, data, report)
|
||||
local dex = save.pokedex
|
||||
if type(dex) == "table" then
|
||||
for _, key in ipairs({ "seen", "owned" }) do
|
||||
if type(dex[key]) == "table" then
|
||||
for id in pairs(dex[key]) do
|
||||
if not known(data.pokemon, id) then dex[key][id] = nil end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
-- hall of fame rosters keep their shape: an unknown species blanks in
|
||||
-- place so the team stays the size it won at, with the rest of the mon
|
||||
-- (level etc.) intact for display
|
||||
for _, entry in ipairs(save.hallOfFame or {}) do
|
||||
if type(entry) == "table" then
|
||||
for i = 1, #entry do
|
||||
local mon = entry[i]
|
||||
if type(mon) == "table" and mon.species ~= nil
|
||||
and not known(data.pokemon, mon.species) then
|
||||
mon.species = nil
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
-- an empty quarantine leaves no residue, so a vanilla save re-encodes
|
||||
-- byte-identically
|
||||
local orphaned = save.orphaned
|
||||
if orphaned and #(orphaned.mons or {}) == 0 and #(orphaned.items or {}) == 0 then
|
||||
save.orphaned = nil
|
||||
end
|
||||
return report
|
||||
end
|
||||
|
||||
function SaveData.emptyReport(report)
|
||||
-- a bare validate report (the save editor's probe) carries no modsDiff;
|
||||
-- restoreSave attaches one so a version bump alone still surfaces
|
||||
local diff = report.modsDiff
|
||||
return #report.lostMons == 0 and #report.lostItems == 0
|
||||
and #report.remappedMaps == 0 and #report.restoredMons == 0
|
||||
and #report.restoredItems == 0 and not report.recovered
|
||||
and (not diff or (#diff.added == 0 and #diff.removed == 0 and #diff.changed == 0))
|
||||
end
|
||||
|
||||
-- ------- new game
|
||||
|
||||
-- boot is Data.field.boot, threaded in by Game: this module must not reach
|
||||
-- into Data itself. Every read falls back to the Red literal it replaced,
|
||||
-- so an absent or partial config still produces the vanilla new game.
|
||||
-- Where blackouts and ESCAPE ROPE return to for a given boot config.
|
||||
--
|
||||
-- In vanilla this is NOT the spawn. wLastBlackoutMap is zero-filled at new
|
||||
-- game and PALLET_TOWN is map 0, so the player starts in the bedroom
|
||||
-- (special_warps.asm NewGameWarp) but blacks out to Pallet Town's fly_warp
|
||||
-- cell (5, 6). A world that moves the spawn without naming a heal point
|
||||
-- keeps the two together -- it may have no Pallet Town at all.
|
||||
--
|
||||
-- Shared with the Hall of Fame reset, which pokered writes as a literal
|
||||
-- (HallOfFameResetEventsAndSaveScript: wLastBlackoutMap := PALLET_TOWN)
|
||||
-- rather than deriving from the spawn.
|
||||
function SaveData.defaultHeal(boot)
|
||||
boot = type(boot) == "table" and boot or {}
|
||||
local h = boot.lastHeal
|
||||
if h then return { map = h.map, x = h.x, y = h.y } end
|
||||
local map = boot.startMap or "REDS_HOUSE_2F"
|
||||
if map == "REDS_HOUSE_2F" then return { map = "PALLET_TOWN", x = 5, y = 6 } end
|
||||
return { map = map, x = boot.startX or 3, y = boot.startY or 6 }
|
||||
end
|
||||
|
||||
function SaveData.newGame(boot)
|
||||
boot = type(boot) == "table" and boot or {}
|
||||
local map = boot.startMap or "REDS_HOUSE_2F"
|
||||
local x, y = boot.startX or 3, boot.startY or 6
|
||||
local heal = SaveData.defaultHeal(boot)
|
||||
local save = {
|
||||
meta = { format = Version.saveFormat, mods = {} },
|
||||
player = {
|
||||
map = "PALLET_TOWN",
|
||||
x = 5,
|
||||
y = 6,
|
||||
facing = "down",
|
||||
name = "RED",
|
||||
rival = "BLUE",
|
||||
map = map,
|
||||
x = x,
|
||||
y = y,
|
||||
facing = boot.startFacing or "down",
|
||||
name = boot.playerName or "RED",
|
||||
rival = boot.rivalName or "BLUE",
|
||||
-- 16-bit trainer ID rolled at new game (wPlayerID, filled from
|
||||
-- hRandomAdd in OakSpeech)
|
||||
id = math.random(0, 65535),
|
||||
@@ -202,16 +671,28 @@ function SaveData.newGame()
|
||||
inventory = {},
|
||||
party = {},
|
||||
box = {},
|
||||
money = 3000,
|
||||
money = boot.startMoney or 3000,
|
||||
defeatedTrainers = {},
|
||||
pokedex = { seen = {}, owned = {} },
|
||||
-- where blackouts and ESCAPE ROPE return to (updated by nurses)
|
||||
lastHeal = { map = "PALLET_TOWN", x = 5, y = 6 },
|
||||
-- where blackouts and ESCAPE ROPE return to (updated by nurses);
|
||||
-- copied, never aliased, so a save never writes back into Data
|
||||
lastHeal = { map = heal.map or map, x = heal.x or x, y = heal.y or y },
|
||||
-- Interiors inherit the SGB palette of the last outdoor map. wLastMap
|
||||
-- is zero-filled at new game and PALLET_TOWN is map 0, so before the
|
||||
-- player has ever been outdoors that palette is Pallet Town's -- which
|
||||
-- matters because the vanilla spawn (REDS_HOUSE_2F) is itself indoors.
|
||||
-- Without this the palette falls through to the ROUTE default.
|
||||
lastOutdoor = { id = heal.map or map, x = heal.x or x, y = heal.y or y },
|
||||
repelSteps = 0,
|
||||
-- per-mod persistence (mod.save) lives under here, keyed by mod id
|
||||
modData = {},
|
||||
-- Live options from options.lua (or defaults); New Game keeps the
|
||||
-- player's audio/display/battle preferences.
|
||||
options = SaveData.loadOptions(),
|
||||
}
|
||||
-- a total conversion reshapes the skeleton (spawn, party, money)
|
||||
-- before anything reads it; unhooked this returns save unchanged
|
||||
return Runtime.call("save.new_game", function(s) return s end, save)
|
||||
end
|
||||
|
||||
return SaveData
|
||||
|
||||
@@ -0,0 +1,219 @@
|
||||
-- Save-file serialization: the deterministic Lua-source writer (moved
|
||||
-- verbatim from SaveData so output stays byte-identical) and a
|
||||
-- restricted-grammar reader that replaces load() on save bytes. The
|
||||
-- writer is the grammar's specification -- literals, %q strings and keyed
|
||||
-- tables only -- so a hand-tampered or malicious save fails to parse
|
||||
-- instead of executing.
|
||||
|
||||
local SaveSerializer = {}
|
||||
|
||||
-- ------- writer
|
||||
|
||||
local function serialize(v, indent)
|
||||
indent = indent or 0
|
||||
local pad = string.rep(" ", indent)
|
||||
local t = type(v)
|
||||
if t == "number" or t == "boolean" then
|
||||
return tostring(v)
|
||||
elseif t == "string" then
|
||||
return string.format("%q", v)
|
||||
elseif t == "table" then
|
||||
local keys = {}
|
||||
for k in pairs(v) do table.insert(keys, k) end
|
||||
table.sort(keys, function(a, b)
|
||||
local ta, tb = type(a), type(b)
|
||||
if ta ~= tb then return ta < tb end
|
||||
return a < b
|
||||
end)
|
||||
if next(v) == nil then return "{}" end
|
||||
local parts = {}
|
||||
for _, k in ipairs(keys) do
|
||||
local key
|
||||
if type(k) == "string" and k:match("^[%a_][%w_]*$") then
|
||||
key = k
|
||||
else
|
||||
key = "[" .. serialize(k) .. "]"
|
||||
end
|
||||
table.insert(parts, pad .. " " .. key .. " = " .. serialize(v[k], indent + 1))
|
||||
end
|
||||
return "{\n" .. table.concat(parts, ",\n") .. ",\n" .. pad .. "}"
|
||||
end
|
||||
error("cannot serialize " .. t)
|
||||
end
|
||||
|
||||
function SaveSerializer.encode(data)
|
||||
return "return " .. serialize(data) .. "\n"
|
||||
end
|
||||
|
||||
-- ------- reader
|
||||
|
||||
-- letter escapes %q has emitted across the Lua 5.x family; LuaJIT writes
|
||||
-- control characters as \ddd decimal escapes, handled separately below
|
||||
local ESCAPES = {
|
||||
['"'] = '"', ["\\"] = "\\", ["n"] = "\n", ["r"] = "\r", ["t"] = "\t",
|
||||
["a"] = "\a", ["b"] = "\b", ["f"] = "\f", ["v"] = "\v",
|
||||
["\n"] = "\n", ["\r"] = "\n",
|
||||
}
|
||||
|
||||
-- recursion cap: a crafted file nesting thousands of braces must fail
|
||||
-- closed, not blow the interpreter stack
|
||||
local MAX_DEPTH = 128
|
||||
|
||||
local function fail(state, why)
|
||||
error(("parse error at byte %d: %s"):format(state.pos, why), 0)
|
||||
end
|
||||
|
||||
local function skip(state)
|
||||
local _, last = state.src:find("^[ \t\r\n]*", state.pos)
|
||||
state.pos = last + 1
|
||||
end
|
||||
|
||||
local function peek(state)
|
||||
return state.src:sub(state.pos, state.pos)
|
||||
end
|
||||
|
||||
local function readString(state)
|
||||
local src = state.src
|
||||
local out = {}
|
||||
local i = state.pos + 1
|
||||
while true do
|
||||
local c = src:sub(i, i)
|
||||
if c == "" then
|
||||
state.pos = i
|
||||
fail(state, "unterminated string")
|
||||
elseif c == '"' then
|
||||
state.pos = i + 1
|
||||
return table.concat(out)
|
||||
elseif c == "\\" then
|
||||
local nxt = src:sub(i + 1, i + 1)
|
||||
if nxt:match("%d") then
|
||||
local digits = src:match("^%d%d?%d?", i + 1)
|
||||
local code = tonumber(digits)
|
||||
if code > 255 then
|
||||
state.pos = i
|
||||
fail(state, "escape out of range")
|
||||
end
|
||||
out[#out + 1] = string.char(code)
|
||||
i = i + 1 + #digits
|
||||
elseif ESCAPES[nxt] then
|
||||
out[#out + 1] = ESCAPES[nxt]
|
||||
i = i + 2
|
||||
else
|
||||
state.pos = i
|
||||
fail(state, "bad string escape")
|
||||
end
|
||||
else
|
||||
out[#out + 1] = c
|
||||
i = i + 1
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
-- a number runs to the next delimiter; tonumber is the judge of what the
|
||||
-- writer's tostring could have produced ("0.1", "-2", "1e+300")
|
||||
local function readNumber(state)
|
||||
local token = state.src:match("^[^,%]}%s]+", state.pos)
|
||||
local value = token and tonumber(token)
|
||||
if value == nil then fail(state, "malformed number") end
|
||||
state.pos = state.pos + #token
|
||||
return value
|
||||
end
|
||||
|
||||
local function readIdent(state)
|
||||
local ident = state.src:match("^[%a_][%w_]*", state.pos)
|
||||
if not ident then fail(state, "expected name") end
|
||||
state.pos = state.pos + #ident
|
||||
return ident
|
||||
end
|
||||
|
||||
local readValue
|
||||
|
||||
local function readTable(state)
|
||||
state.depth = state.depth + 1
|
||||
if state.depth > MAX_DEPTH then fail(state, "table nesting too deep") end
|
||||
state.pos = state.pos + 1
|
||||
local out = {}
|
||||
skip(state)
|
||||
if peek(state) == "}" then
|
||||
state.pos = state.pos + 1
|
||||
state.depth = state.depth - 1
|
||||
return out
|
||||
end
|
||||
while true do
|
||||
skip(state)
|
||||
local key
|
||||
local c = peek(state)
|
||||
if c == "[" then
|
||||
state.pos = state.pos + 1
|
||||
key = readValue(state)
|
||||
skip(state)
|
||||
if peek(state) ~= "]" then fail(state, "expected ]") end
|
||||
state.pos = state.pos + 1
|
||||
elseif c:match("[%a_]") then
|
||||
key = readIdent(state)
|
||||
else
|
||||
fail(state, "expected key")
|
||||
end
|
||||
skip(state)
|
||||
if peek(state) ~= "=" then fail(state, "expected =") end
|
||||
state.pos = state.pos + 1
|
||||
out[key] = readValue(state)
|
||||
skip(state)
|
||||
local sep = peek(state)
|
||||
if sep == "," then
|
||||
state.pos = state.pos + 1
|
||||
skip(state)
|
||||
if peek(state) == "}" then
|
||||
state.pos = state.pos + 1
|
||||
break
|
||||
end
|
||||
elseif sep == "}" then
|
||||
state.pos = state.pos + 1
|
||||
break
|
||||
else
|
||||
fail(state, "expected , or }")
|
||||
end
|
||||
end
|
||||
state.depth = state.depth - 1
|
||||
return out
|
||||
end
|
||||
|
||||
readValue = function(state)
|
||||
skip(state)
|
||||
local c = peek(state)
|
||||
if c == '"' then
|
||||
return readString(state)
|
||||
elseif c == "{" then
|
||||
return readTable(state)
|
||||
elseif c:match("[%a_]") then
|
||||
-- the only bare words in the grammar are the boolean literals
|
||||
local word = readIdent(state)
|
||||
if word == "true" then return true end
|
||||
if word == "false" then return false end
|
||||
state.pos = state.pos - #word
|
||||
fail(state, "unexpected name '" .. word .. "'")
|
||||
elseif c:match("[%-%d%.]") then
|
||||
return readNumber(state)
|
||||
end
|
||||
fail(state, c == "" and "unexpected end of input" or "unexpected character")
|
||||
end
|
||||
|
||||
function SaveSerializer.decode(str)
|
||||
if type(str) ~= "string" then return nil, "save must be a string" end
|
||||
local state = { src = str, pos = 1, depth = 0 }
|
||||
local ok, result = pcall(function()
|
||||
skip(state)
|
||||
local word = state.src:match("^[%a_][%w_]*", state.pos)
|
||||
if word ~= "return" then fail(state, "expected return") end
|
||||
state.pos = state.pos + #word
|
||||
local value = readValue(state)
|
||||
skip(state)
|
||||
if state.pos <= #state.src then fail(state, "trailing content") end
|
||||
return value
|
||||
end)
|
||||
if not ok then return nil, result end
|
||||
if type(result) ~= "table" then return nil, "save root must be a table" end
|
||||
return result
|
||||
end
|
||||
|
||||
return SaveSerializer
|
||||
@@ -1,11 +1,17 @@
|
||||
-- Sound effects and cries synthesized from compact ROM channel programs or
|
||||
-- loaded from legacy static audio definitions. Sources are cached; headless
|
||||
-- Sound effects and cries synthesized from compact ROM channel programs,
|
||||
-- from def-local chip programs (ChipAsm), or loaded from file definitions --
|
||||
-- the branch is chosen per definition, not by a global import flag. Sources
|
||||
-- are cached; a definition that fails to load caches as `false` so it is
|
||||
-- logged once and skipped, never disabling the rest of the audio. Headless
|
||||
-- use is a safe no-op.
|
||||
|
||||
local Assets = require("src.render.Assets")
|
||||
local Logger = require("src.core.Logger")
|
||||
local Runtime = require("src.mods.Runtime")
|
||||
|
||||
local Sound = {}
|
||||
|
||||
local cache = {}
|
||||
local enabled = true
|
||||
-- port addition: 0-7 SFX volume from save.options.sfxVol (OptionsMenu),
|
||||
-- scaling the 0.8 base every source gets
|
||||
local BASE_VOLUME = 0.8
|
||||
@@ -19,6 +25,9 @@ local volumeScale = 1
|
||||
-- (engine/items/item_effects.asm). Music.lua pauses the current song
|
||||
-- while one of these plays and resumes it afterwards. Ordinary short
|
||||
-- SFX (menu beeps, hits, cries) stay overlaid.
|
||||
-- data.audio.fanfares supersedes this; the copy stays as the fallback for
|
||||
-- caches built before the importer wrote the table, and a def may claim the
|
||||
-- behavior for itself with fanfare = true.
|
||||
local FANFARES = {
|
||||
Level_Up = true,
|
||||
Caught_Mon = true,
|
||||
@@ -30,20 +39,56 @@ local FANFARES = {
|
||||
Pokeflute = true,
|
||||
}
|
||||
|
||||
local function playPath(data, key, path, pitch, tempo)
|
||||
if not enabled or not love.audio or not path then return nil end
|
||||
-- which mod put this key in the registry, for attributed failure logs
|
||||
local function owner(data, kind, key)
|
||||
local owners = data and data.audio and data.audio._owners
|
||||
local map = owners and owners[kind]
|
||||
return map and map[key] or "base"
|
||||
end
|
||||
|
||||
-- one log line, plus an entry in the loader's error feed when a mod owns the
|
||||
-- def, so the manager's errors screen can flag that mod
|
||||
local function reportBadDef(kind, key, who, err)
|
||||
Logger.warn("audio: bad %s def %q (mod %s): %s", kind, key, who, tostring(err))
|
||||
Runtime.reportError(who,
|
||||
("audio: bad %s def %q: %s"):format(kind, key, tostring(err)))
|
||||
end
|
||||
|
||||
local function isChipDef(def)
|
||||
return type(def) == "table" and (def.chip ~= nil or def.address ~= nil)
|
||||
end
|
||||
|
||||
-- a file def carries an optional playback rate; a bare string is shorthand
|
||||
-- for { file = <string> }
|
||||
local function newFileSource(def)
|
||||
local file = type(def) == "table" and def.file or def
|
||||
if type(file) ~= "string" then return nil, "no chip program and no file" end
|
||||
local ok, s = pcall(love.audio.newSource, file, "static")
|
||||
if not ok or not s then return nil, ok and "no source" or tostring(s) end
|
||||
if type(def) == "table" and def.pitch then pcall(s.setPitch, s, def.pitch) end
|
||||
return s
|
||||
end
|
||||
|
||||
local function newSfxSource(data, key, def, pitch, tempo)
|
||||
if isChipDef(def) then
|
||||
local ok, s = pcall(require("src.core.ChipAudio").newSfx,
|
||||
data, key:match("^([^@]+)") or key, pitch, tempo, def)
|
||||
if not ok then return nil, tostring(s) end
|
||||
if not s then return nil, "no source" end
|
||||
return s
|
||||
end
|
||||
return newFileSource(def)
|
||||
end
|
||||
|
||||
local function playPath(data, key, def, pitch, tempo)
|
||||
if not love.audio or not def then return nil end
|
||||
local src = cache[key]
|
||||
if src == false then return nil end -- known bad, already logged
|
||||
if not src then
|
||||
local ok, s
|
||||
if data.audio and data.audio.runtime and type(path) == "table" then
|
||||
ok, s = pcall(
|
||||
require("src.core.ChipAudio").newSfx,
|
||||
data, key:match("^([^@]+)") or key, pitch, tempo, path)
|
||||
else
|
||||
ok, s = pcall(love.audio.newSource, path, "static")
|
||||
end
|
||||
if not ok or not s then
|
||||
enabled = false
|
||||
local s, err = newSfxSource(data, key, def, pitch, tempo)
|
||||
if not s then
|
||||
cache[key] = false
|
||||
reportBadDef("sfx", key, owner(data, "sfx", key), err)
|
||||
return nil
|
||||
end
|
||||
s:setVolume(BASE_VOLUME * volumeScale)
|
||||
@@ -55,12 +100,26 @@ local function playPath(data, key, path, pitch, tempo)
|
||||
return src
|
||||
end
|
||||
|
||||
local function ducks(data, name, def)
|
||||
if type(def) == "table" and def.fanfare then return true end
|
||||
local fanfares = data.audio and data.audio.fanfares or FANFARES
|
||||
return fanfares[name] and true or false
|
||||
end
|
||||
|
||||
local function played(kind, name, species)
|
||||
if not Runtime.wants("sound.played") then return end
|
||||
Runtime.emit("sound.played", { kind = kind, name = name, species = species })
|
||||
end
|
||||
|
||||
function Sound.play(data, name)
|
||||
local sfx = data.audio and data.audio.sfx
|
||||
local src = playPath(data, name, sfx and sfx[name])
|
||||
if src and FANFARES[name] then
|
||||
local def = sfx and sfx[name]
|
||||
local src = playPath(data, name, def)
|
||||
if not src then return end
|
||||
if ducks(data, name, def) then
|
||||
require("src.core.Music").duckForFanfare(src)
|
||||
end
|
||||
played("sfx", name)
|
||||
end
|
||||
|
||||
-- Play a move's sound with its MoveSoundTable pitch/tempo modifiers
|
||||
@@ -78,42 +137,87 @@ function Sound.playMove(data, anim)
|
||||
if not sfx then return end
|
||||
local name = anim.sound
|
||||
local pitch, tempo = anim.pitch or 0, anim.tempo or 0x80
|
||||
if data.audio.runtime and sfx[name] then
|
||||
playPath(data, ("%s@%02x%02x"):format(name, pitch, tempo),
|
||||
sfx[name], pitch, tempo)
|
||||
-- a chip program synthesizes the modified variant on demand; a file def
|
||||
-- can only reach for a pre-rendered one
|
||||
if isChipDef(sfx[name]) then
|
||||
if playPath(data, ("%s@%02x%02x"):format(name, pitch, tempo),
|
||||
sfx[name], pitch, tempo) then
|
||||
played("move", name)
|
||||
end
|
||||
return
|
||||
end
|
||||
if pitch ~= 0 or tempo ~= 0x80 then
|
||||
local key = ("%s@%02x%02x"):format(name, pitch, tempo)
|
||||
if sfx[key] then
|
||||
playPath(data, key, sfx[key])
|
||||
if playPath(data, key, sfx[key]) then played("move", name) end
|
||||
return
|
||||
end
|
||||
end
|
||||
playPath(data, name, sfx[name])
|
||||
if playPath(data, name, sfx[name]) then played("move", name) end
|
||||
end
|
||||
|
||||
function Sound.playCry(data, species)
|
||||
-- A derived cry ({ base = "RHYDON", pitch, length }) borrows another
|
||||
-- species' program and applies its own modifiers, so a new species needs no
|
||||
-- assets at all. Chains are followed; the modifiers nearest the caller win.
|
||||
local function resolveCry(data, def, depth)
|
||||
if type(def) ~= "table" or not def.base then return def end
|
||||
if depth > 8 then return nil, "cry base chain too deep" end
|
||||
local cries = data.audio and data.audio.cries
|
||||
-- returns the source (nil headless) so callers that block on the cry
|
||||
-- like the original's PlayCry -> WaitForSoundToFinish can poll it
|
||||
local definition = cries and cries[species]
|
||||
if data.audio and data.audio.runtime and definition then
|
||||
local key = "cry:" .. tostring(species)
|
||||
local src = cache[key]
|
||||
if not src then
|
||||
local ok, generated = pcall(
|
||||
require("src.core.ChipAudio").newCry, data, species)
|
||||
if not ok or not generated then return nil end
|
||||
generated:setVolume(BASE_VOLUME * volumeScale)
|
||||
cache[key] = generated
|
||||
src = generated
|
||||
end
|
||||
src:stop()
|
||||
src:play()
|
||||
return src
|
||||
local baseDef = cries and cries[def.base]
|
||||
if not baseDef then
|
||||
return nil, "unknown base cry " .. tostring(def.base)
|
||||
end
|
||||
return playPath(data, "cry:" .. tostring(species), definition)
|
||||
local resolved, err = resolveCry(data, baseDef, depth + 1)
|
||||
if not resolved then return nil, err end
|
||||
if type(resolved) ~= "table" or not (resolved.header or resolved.chip) then
|
||||
return nil, "base cry " .. tostring(def.base) .. " is not a chip program"
|
||||
end
|
||||
return {
|
||||
header = resolved.header, chip = resolved.chip,
|
||||
pitch = def.pitch or resolved.pitch,
|
||||
length = def.length or resolved.length,
|
||||
}
|
||||
end
|
||||
|
||||
local function newCrySource(data, species, def)
|
||||
local resolved, err = resolveCry(data, def, 0)
|
||||
if not resolved then return nil, err end
|
||||
if type(resolved) == "table" and (resolved.header or resolved.chip) then
|
||||
local ok, s = pcall(
|
||||
require("src.core.ChipAudio").newCry, data, species, resolved)
|
||||
if not ok then return nil, tostring(s) end
|
||||
if not s then return nil, "no source" end
|
||||
return s
|
||||
end
|
||||
return newFileSource(resolved)
|
||||
end
|
||||
|
||||
-- returns the source (nil headless) so callers that block on the cry
|
||||
-- like the original's PlayCry -> WaitForSoundToFinish can poll it
|
||||
function Sound.playCry(data, species)
|
||||
if not love.audio then return nil end
|
||||
local cries = data.audio and data.audio.cries
|
||||
local def = cries and cries[species]
|
||||
if not def then return nil end
|
||||
local key = "cry:" .. tostring(species)
|
||||
local src = cache[key]
|
||||
if src == false then return nil end
|
||||
if not src then
|
||||
local s, err = newCrySource(data, species, def)
|
||||
if not s then
|
||||
cache[key] = false
|
||||
reportBadDef("cry", tostring(species),
|
||||
owner(data, "cries", species), err)
|
||||
return nil
|
||||
end
|
||||
s:setVolume(BASE_VOLUME * volumeScale)
|
||||
cache[key] = s
|
||||
src = s
|
||||
end
|
||||
src:stop()
|
||||
src:play()
|
||||
played("cry", species, species)
|
||||
return src
|
||||
end
|
||||
|
||||
-- GROWL/ROAR are the only two moves that play a cry (IsCryMove checks
|
||||
@@ -159,25 +263,28 @@ local looping = {}
|
||||
|
||||
function Sound.startLoop(data, name)
|
||||
if looping[name] then return end
|
||||
if not love.audio then return end
|
||||
local sfx = data.audio and data.audio.sfx
|
||||
local path = sfx and sfx[name]
|
||||
local runtimeAlarm = data.audio and data.audio.runtime
|
||||
and name == "Low_Health_Alarm"
|
||||
if not enabled or not love.audio or (not path and not runtimeAlarm) then
|
||||
return
|
||||
end
|
||||
local def = sfx and sfx[name]
|
||||
local alarm = not def and name == "Low_Health_Alarm"
|
||||
if not def and not alarm then return end
|
||||
local src = loopCache[name]
|
||||
if src == false then return end
|
||||
if not src then
|
||||
local ok, s
|
||||
if data.audio.runtime and name == "Low_Health_Alarm" then
|
||||
ok, s = pcall(require("src.core.ChipAudio").newLowHealthAlarm)
|
||||
elseif data.audio.runtime and type(path) == "table" then
|
||||
ok, s = pcall(
|
||||
require("src.core.ChipAudio").newSfx, data, name)
|
||||
local s, err
|
||||
if alarm then
|
||||
-- the synthesized siren is the default, not the rule: a registered
|
||||
-- Low_Health_Alarm def of any shape replaces it
|
||||
local ok, generated = pcall(require("src.core.ChipAudio").newLowHealthAlarm)
|
||||
if ok then s = generated else err = tostring(generated) end
|
||||
else
|
||||
ok, s = pcall(love.audio.newSource, path, "static")
|
||||
s, err = newSfxSource(data, name, def)
|
||||
end
|
||||
if not s then
|
||||
loopCache[name] = false
|
||||
reportBadDef("sfx", name, owner(data, "sfx", name), err or "no source")
|
||||
return
|
||||
end
|
||||
if not ok then return end
|
||||
s:setLooping(true)
|
||||
s:setVolume(BASE_VOLUME * volumeScale)
|
||||
loopCache[name] = s
|
||||
@@ -206,13 +313,40 @@ end
|
||||
function Sound.setVolumeLevel(level)
|
||||
volumeScale = math.max(0, math.min(7, level or 7)) / 7
|
||||
for _, src in pairs(cache) do
|
||||
pcall(src.setVolume, src, BASE_VOLUME * volumeScale)
|
||||
if src then pcall(src.setVolume, src, BASE_VOLUME * volumeScale) end
|
||||
end
|
||||
for _, src in pairs(loopCache) do
|
||||
pcall(src.setVolume, src, BASE_VOLUME * volumeScale)
|
||||
if src then pcall(src.setVolume, src, BASE_VOLUME * volumeScale) end
|
||||
end
|
||||
end
|
||||
|
||||
-- hot reload / jukebox A-B: drop one key's sources (its pitch-tempo
|
||||
-- variants included) or all of them, so the next play re-resolves the def
|
||||
function Sound.invalidate(name)
|
||||
local function evict(store, key)
|
||||
local src = store[key]
|
||||
if src then pcall(src.stop, src) end
|
||||
store[key] = nil
|
||||
end
|
||||
for _, store in ipairs({ cache, loopCache }) do
|
||||
for key in pairs(store) do
|
||||
if not name or key == name or key:sub(1, #name + 1) == name .. "@" then
|
||||
evict(store, key)
|
||||
end
|
||||
end
|
||||
end
|
||||
for key, src in pairs(looping) do
|
||||
if not name or key == name then
|
||||
pcall(src.stop, src)
|
||||
looping[key] = nil
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
-- the flush fan-out calls with no key, dropping everything, so an edited
|
||||
-- def is re-resolved on the next play (20 §2 cache contract, audio row)
|
||||
Assets.register(Sound.invalidate)
|
||||
|
||||
-- re-apply persisted audio options (Game calls this on boot and after
|
||||
-- loading a save)
|
||||
function Sound.applyOptions(opts)
|
||||
|
||||
@@ -2,20 +2,31 @@
|
||||
-- (so a text box can overlay the overworld, a battle replaces it, etc).
|
||||
-- States are tables with optional enter/exit/update/draw/isOpaque.
|
||||
|
||||
local Runtime = require("src.mods.Runtime")
|
||||
|
||||
local StateStack = {}
|
||||
|
||||
function StateStack:init()
|
||||
self.states = {}
|
||||
end
|
||||
|
||||
-- screen.pushed/popped fire after enter/exit so listeners observe the
|
||||
-- settled state; the wants guard keeps the no-listener path allocation-free
|
||||
|
||||
function StateStack:push(state, ...)
|
||||
table.insert(self.states, state)
|
||||
if state.enter then state:enter(...) end
|
||||
if Runtime.wants("screen.pushed") then
|
||||
Runtime.emit("screen.pushed", { state = state })
|
||||
end
|
||||
end
|
||||
|
||||
function StateStack:pop()
|
||||
local state = table.remove(self.states)
|
||||
if state and state.exit then state:exit() end
|
||||
if state and Runtime.wants("screen.popped") then
|
||||
Runtime.emit("screen.popped", { state = state })
|
||||
end
|
||||
return state
|
||||
end
|
||||
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
-- Single source of every compatibility-relevant number: engine release,
|
||||
-- mod API major, link protocol, save format and ROM cache generation. Zero
|
||||
-- requires so it loads during love.conf and under plain Lua for tools and
|
||||
-- tests.
|
||||
|
||||
local Version = {
|
||||
engine = "1.0.0", -- game/engine release (semver triple)
|
||||
modApi = 2, -- mod API major (manifest `api`)
|
||||
linkProtocol = 2, -- link handshake wire version (Handshake.PROTOCOL)
|
||||
saveFormat = 2, -- save.meta.format
|
||||
cache = "rom-cache-v5", -- ROM import cache generation (RomImporter marker)
|
||||
}
|
||||
|
||||
-- "Pokemon Red (Gen 1 Recompilation Project) v1.0.0"
|
||||
function Version.title(base)
|
||||
return (base or "Pokemon Red (Gen 1 Recompilation Project)")
|
||||
.. " v" .. Version.engine
|
||||
end
|
||||
|
||||
return Version
|
||||
@@ -0,0 +1,398 @@
|
||||
-- Dev console overlay (POKEPORT_DEV=1, backtick): a Lua REPL with the live
|
||||
-- game, data and mod list in scope, built-in verbs (warp / give / flag /
|
||||
-- party / mods / reload) and an event/hook tracer driven off the Runtime
|
||||
-- buses. It rides the state stack, so while it is open it owns the
|
||||
-- keyboard (Game:keypressed routes to onKeyPressed) and the world below
|
||||
-- does not update. Never required on a player boot.
|
||||
|
||||
local Font = require("src.render.Font")
|
||||
|
||||
local Console = {}
|
||||
Console.__index = Console
|
||||
|
||||
local ROWS = 15 -- scrollback rows drawn above the input line
|
||||
local COLS = 19 -- 160px canvas minus the border
|
||||
local HISTORY_MAX = 64
|
||||
local SCROLLBACK_MAX = 200
|
||||
|
||||
-- keypressed names -> characters; the console types from key events because
|
||||
-- love.textinput never reaches Game. Shift reads the live keyboard.
|
||||
local KEY_CHARS = {
|
||||
space = " ", ["1"] = "1!", ["2"] = "2@", ["3"] = "3#", ["4"] = "4$",
|
||||
["5"] = "5%", ["6"] = "6^", ["7"] = "7&", ["8"] = "8*", ["9"] = "9(",
|
||||
["0"] = "0)", ["-"] = "-_", ["="] = "=+", ["["] = "[{", ["]"] = "]}",
|
||||
["\\"] = "\\|", [";"] = ";:", ["'"] = "'\"", [","] = ",<", ["."] = ".>",
|
||||
["/"] = "/?",
|
||||
}
|
||||
|
||||
local function shiftDown()
|
||||
return love and love.keyboard and love.keyboard.isDown
|
||||
and (love.keyboard.isDown("lshift") or love.keyboard.isDown("rshift"))
|
||||
end
|
||||
|
||||
-- one-line pretty printer with a depth fuse, for expression results
|
||||
local function pp(value, depth)
|
||||
depth = depth or 0
|
||||
local kind = type(value)
|
||||
if kind == "string" then return string.format("%q", value) end
|
||||
if kind ~= "table" then return tostring(value) end
|
||||
if depth >= 2 then return "{...}" end
|
||||
local parts, n = {}, 0
|
||||
for k, v in pairs(value) do
|
||||
n = n + 1
|
||||
if n > 8 then parts[#parts + 1] = "..." break end
|
||||
parts[#parts + 1] = tostring(k) .. "=" .. pp(v, depth + 1)
|
||||
end
|
||||
return "{" .. table.concat(parts, ", ") .. "}"
|
||||
end
|
||||
|
||||
function Console.new(game)
|
||||
local self = setmetatable({
|
||||
game = game,
|
||||
buffer = "",
|
||||
lines = {},
|
||||
history = {},
|
||||
historyIndex = 0,
|
||||
scroll = 0,
|
||||
}, Console)
|
||||
self.env = setmetatable({
|
||||
game = game,
|
||||
data = game.data,
|
||||
mods = game.mods,
|
||||
pp = pp,
|
||||
}, { __index = _G })
|
||||
self:print("dev console -- `help` for verbs, ` to close")
|
||||
return self
|
||||
end
|
||||
|
||||
function Console:print(text)
|
||||
for line in (tostring(text) .. "\n"):gmatch("([^\n]*)\n") do
|
||||
-- wrap to the canvas width so long payload dumps stay readable
|
||||
repeat
|
||||
self.lines[#self.lines + 1] = line:sub(1, COLS)
|
||||
line = line:sub(COLS + 1)
|
||||
until line == ""
|
||||
end
|
||||
while #self.lines > SCROLLBACK_MAX do table.remove(self.lines, 1) end
|
||||
self.scroll = 0
|
||||
end
|
||||
|
||||
-- ------- tracer
|
||||
|
||||
-- glob -> anchored lua pattern ("battle.*" matches battle.turn etc.)
|
||||
local function globPattern(glob)
|
||||
local escaped = glob:gsub("[%^%$%(%)%%%.%[%]%+%-%?]", "%%%0")
|
||||
return "^" .. escaped:gsub("%*", ".*") .. "$"
|
||||
end
|
||||
|
||||
-- The buses have no name catalog to subscribe against, so the tracer shims
|
||||
-- the live instances: an instance field shadows the class method and
|
||||
-- removing it restores the original. Runtime.wants is widened too, or
|
||||
-- guarded call sites would skip payload construction for unwatched names.
|
||||
function Console:startTrace(glob)
|
||||
self:stopTrace()
|
||||
local Runtime = require("src.mods.Runtime")
|
||||
local loader = self.game.mods
|
||||
local events = loader and loader.events
|
||||
local hooks = loader and loader.hooks
|
||||
if not (events and hooks) then
|
||||
self:print("trace: no loader buses")
|
||||
return
|
||||
end
|
||||
local pattern = globPattern(glob)
|
||||
local function matches(name)
|
||||
return type(name) == "string" and name:match(pattern) ~= nil
|
||||
end
|
||||
local console = self
|
||||
local trace = {
|
||||
glob = glob, events = events, hooks = hooks,
|
||||
emit = events.emit, call = hooks.call,
|
||||
wants = Runtime.wants, wantsHook = Runtime.wantsHook,
|
||||
}
|
||||
events.emit = function(bus, name, payload)
|
||||
if matches(name) then
|
||||
console:print("[event] " .. name .. " " .. pp(payload))
|
||||
end
|
||||
return trace.emit(bus, name, payload)
|
||||
end
|
||||
hooks.call = function(bus, name, vanilla, ...)
|
||||
if not matches(name) then return trace.call(bus, name, vanilla, ...) end
|
||||
console:print("[hook] " .. name .. " in " .. pp({ ... }))
|
||||
local result = { trace.call(bus, name, vanilla, ...) }
|
||||
console:print("[hook] " .. name .. " out " .. pp(result))
|
||||
local unpack_ = table.unpack or unpack
|
||||
return unpack_(result)
|
||||
end
|
||||
Runtime.wants = function(name)
|
||||
return matches(name) or trace.wants(name)
|
||||
end
|
||||
Runtime.wantsHook = function(name)
|
||||
return matches(name) or trace.wantsHook(name)
|
||||
end
|
||||
self.trace = trace
|
||||
self:print(("tracing %s"):format(glob))
|
||||
end
|
||||
|
||||
function Console:stopTrace()
|
||||
local trace = self.trace
|
||||
if not trace then return end
|
||||
local Runtime = require("src.mods.Runtime")
|
||||
-- clearing the instance fields re-exposes the class methods
|
||||
trace.events.emit = nil
|
||||
trace.hooks.call = nil
|
||||
Runtime.wants = trace.wants
|
||||
Runtime.wantsHook = trace.wantsHook
|
||||
self.trace = nil
|
||||
end
|
||||
|
||||
-- ------- verbs
|
||||
|
||||
local VERBS = {}
|
||||
|
||||
function VERBS.help(self)
|
||||
self:print("warp MAP [x y]")
|
||||
self:print("give ID [n|level]")
|
||||
self:print("flag NAME [on|off]")
|
||||
self:print("party mods reload")
|
||||
self:print("trace PAT | trace off")
|
||||
self:print("anything else = lua")
|
||||
end
|
||||
|
||||
function VERBS.mods(self)
|
||||
local status = self.game.modStatus
|
||||
or (self.game.mods and self.game.mods:status())
|
||||
if not status then
|
||||
self:print("no loader")
|
||||
return
|
||||
end
|
||||
for _, mod in ipairs(status.available) do
|
||||
self:print(("%s %s %s"):format(mod.id, mod.version or "?", mod.state))
|
||||
end
|
||||
self:print(("%d errors"):format(#status.errors))
|
||||
end
|
||||
|
||||
function VERBS.reload(self)
|
||||
self:stopTrace()
|
||||
local _, summary = require("src.dev.HotReload").run(self.game)
|
||||
-- the reload swapped the buses and the env's loader reference with them
|
||||
self.env.mods = self.game.mods
|
||||
self.env.data = self.game.data
|
||||
self:print(summary)
|
||||
end
|
||||
|
||||
function VERBS.warp(self, rest)
|
||||
local mapId, x, y = rest:match("^(%S+)%s*(%d*)%s*(%d*)")
|
||||
local game = self.game
|
||||
if not mapId or not (game.data.maps and game.data.maps[mapId]) then
|
||||
self:print("unknown map: " .. tostring(mapId))
|
||||
return
|
||||
end
|
||||
x, y = tonumber(x) or 5, tonumber(y) or 5
|
||||
-- the driver kit's teleport rebuild: everything (this console included)
|
||||
-- pops and a fresh overworld enters at the target
|
||||
while game.stack:top() do game.stack:pop() end
|
||||
game.stack:push(require("src.world.OverworldController"), mapId, x, y, "down")
|
||||
end
|
||||
|
||||
function VERBS.give(self, rest)
|
||||
local id, count = rest:match("^(%S+)%s*(%d*)")
|
||||
local game = self.game
|
||||
local save = game.save
|
||||
if not id or id == "" then
|
||||
self:print("give what?")
|
||||
return
|
||||
end
|
||||
if game.data.pokemon and game.data.pokemon[id] then
|
||||
local level = tonumber(count) or 5
|
||||
local mon = require("src.pokemon.Pokemon").new(game.data, id, level)
|
||||
if require("src.pokemon.Party").add(save.party, mon) then
|
||||
self:print(("%s L%d joined the party"):format(id, level))
|
||||
elseif require("src.pokemon.Boxes").deposit(save, mon) then
|
||||
self:print(("%s L%d sent to the PC"):format(id, level))
|
||||
else
|
||||
self:print("party and boxes full")
|
||||
end
|
||||
elseif game.data.items and game.data.items[id] then
|
||||
local n = tonumber(count) or 1
|
||||
if require("src.inventory.Bag").add(save, id, n) then
|
||||
self:print(("%s x%d added"):format(id, n))
|
||||
else
|
||||
self:print("bag full")
|
||||
end
|
||||
else
|
||||
self:print("unknown id: " .. id)
|
||||
end
|
||||
end
|
||||
|
||||
function VERBS.flag(self, rest)
|
||||
local name, value = rest:match("^(%S+)%s*(%S*)")
|
||||
local flags = self.game.save and self.game.save.flags
|
||||
if not name or not flags then
|
||||
self:print("flag what?")
|
||||
return
|
||||
end
|
||||
if value == "on" then
|
||||
flags[name] = true
|
||||
elseif value == "off" then
|
||||
flags[name] = nil
|
||||
end
|
||||
self:print(("%s = %s"):format(name, tostring(flags[name] or false)))
|
||||
end
|
||||
|
||||
function VERBS.party(self)
|
||||
local party = self.game.save and self.game.save.party or {}
|
||||
if #party == 0 then
|
||||
self:print("(empty)")
|
||||
return
|
||||
end
|
||||
for i, mon in ipairs(party) do
|
||||
self:print(("%d %s L%d %d/%d"):format(i, tostring(mon.species),
|
||||
mon.level or 0, mon.hp or 0, (mon.stats and mon.stats.hp) or 0))
|
||||
end
|
||||
end
|
||||
|
||||
function VERBS.trace(self, rest)
|
||||
local glob = rest:match("^(%S+)")
|
||||
if not glob or glob == "off" then
|
||||
self:stopTrace()
|
||||
if glob then self:print("trace off") end
|
||||
return
|
||||
end
|
||||
self:startTrace(glob)
|
||||
end
|
||||
|
||||
-- ------- repl
|
||||
|
||||
function Console:exec(line)
|
||||
self:print("> " .. line)
|
||||
if line:match("^%s*$") then return end
|
||||
self.history[#self.history + 1] = line
|
||||
while #self.history > HISTORY_MAX do table.remove(self.history, 1) end
|
||||
self.historyIndex = #self.history + 1
|
||||
local verb, rest = line:match("^(%S+)%s*(.*)$")
|
||||
local handler = VERBS[verb]
|
||||
if handler then
|
||||
local ok, err = pcall(handler, self, rest or "")
|
||||
if not ok then self:print("error: " .. tostring(err)) end
|
||||
return
|
||||
end
|
||||
-- expression first so `1+1` prints 2; statements fall through
|
||||
local chunk, err = loadstring("return " .. line, "=console")
|
||||
if not chunk then
|
||||
chunk, err = loadstring(line, "=console")
|
||||
end
|
||||
if not chunk then
|
||||
self:print("error: " .. tostring(err))
|
||||
return
|
||||
end
|
||||
setfenv(chunk, self.env)
|
||||
local results = { pcall(chunk) }
|
||||
if not results[1] then
|
||||
self:print("error: " .. tostring(results[2]))
|
||||
return
|
||||
end
|
||||
if #results == 1 then return end
|
||||
for i = 2, #results do
|
||||
self:print(pp(results[i]))
|
||||
end
|
||||
end
|
||||
|
||||
-- tab completion against the env (and one dotted level into it)
|
||||
function Console:complete()
|
||||
local prefix, partial = self.buffer:match("^(.-)([%w_%.]*)$")
|
||||
local holder, field = partial:match("^([%w_]+)%.([%w_]*)$")
|
||||
local scope, stem
|
||||
if holder then
|
||||
local ok, value = pcall(function() return self.env[holder] end)
|
||||
if not ok or type(value) ~= "table" then return end
|
||||
scope, stem = value, field
|
||||
prefix = prefix .. holder .. "."
|
||||
else
|
||||
scope, stem = self.env, partial
|
||||
end
|
||||
local matches = {}
|
||||
local seen = scope
|
||||
while type(seen) == "table" do
|
||||
for key in pairs(seen) do
|
||||
if type(key) == "string" and key:sub(1, #stem) == stem then
|
||||
matches[#matches + 1] = key
|
||||
end
|
||||
end
|
||||
local meta = getmetatable(seen)
|
||||
seen = meta and meta.__index
|
||||
if type(seen) ~= "table" then break end
|
||||
end
|
||||
table.sort(matches)
|
||||
if #matches == 1 then
|
||||
self.buffer = prefix .. matches[1]
|
||||
elseif #matches > 1 then
|
||||
self:print(table.concat(matches, " ", 1, math.min(#matches, 12)))
|
||||
end
|
||||
end
|
||||
|
||||
-- ------- input & drawing
|
||||
|
||||
function Console:onKeyPressed(key)
|
||||
if key == "`" then
|
||||
self:stopTrace()
|
||||
self.game.stack:pop()
|
||||
elseif key == "return" or key == "kpenter" then
|
||||
local line = self.buffer
|
||||
self.buffer = ""
|
||||
self:exec(line)
|
||||
elseif key == "backspace" then
|
||||
self.buffer = self.buffer:sub(1, -2)
|
||||
elseif key == "tab" then
|
||||
self:complete()
|
||||
elseif key == "up" then
|
||||
if self.historyIndex > 1 then
|
||||
self.historyIndex = self.historyIndex - 1
|
||||
self.buffer = self.history[self.historyIndex] or ""
|
||||
end
|
||||
elseif key == "down" then
|
||||
if self.historyIndex <= #self.history then
|
||||
self.historyIndex = self.historyIndex + 1
|
||||
self.buffer = self.history[self.historyIndex] or ""
|
||||
end
|
||||
elseif key == "pageup" then
|
||||
self.scroll = math.min(self.scroll + ROWS,
|
||||
math.max(0, #self.lines - ROWS))
|
||||
elseif key == "pagedown" then
|
||||
self.scroll = math.max(0, self.scroll - ROWS)
|
||||
else
|
||||
local chars = KEY_CHARS[key]
|
||||
if chars then
|
||||
local index = shiftDown() and 2 or 1
|
||||
self.buffer = self.buffer .. chars:sub(index, index)
|
||||
elseif key:match("^%a$") then
|
||||
self.buffer = self.buffer .. (shiftDown() and key:upper() or key)
|
||||
elseif key:match("^kp%d$") then
|
||||
self.buffer = self.buffer .. key:sub(3)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
function Console:update() end
|
||||
|
||||
function Console:exit()
|
||||
self:stopTrace()
|
||||
end
|
||||
|
||||
function Console:draw()
|
||||
love.graphics.setColor(1, 1, 1, 0.92)
|
||||
love.graphics.rectangle("fill", 0, 0, 160, 144)
|
||||
love.graphics.setColor(0, 0, 0, 1)
|
||||
local first = math.max(1, #self.lines - ROWS + 1 - self.scroll)
|
||||
local row = 0
|
||||
for i = first, math.min(#self.lines, first + ROWS - 1) do
|
||||
Font.draw(self.lines[i], 4, 2 + row * 9)
|
||||
row = row + 1
|
||||
end
|
||||
local input = "> " .. self.buffer
|
||||
-- keep the tail visible while typing past the canvas edge
|
||||
if #input > COLS then input = input:sub(#input - COLS + 1) end
|
||||
Font.draw(input, 4, 134)
|
||||
love.graphics.setColor(1, 1, 1, 1)
|
||||
end
|
||||
|
||||
return Console
|
||||
@@ -0,0 +1,60 @@
|
||||
-- Dev-mode hot reload (POKEPORT_DEV=1, F5): restore pristine base data,
|
||||
-- re-run the mod loader against the current mod files, re-merge, and flush
|
||||
-- every cache registered on the Assets bus. Teardown is wholesale: the old
|
||||
-- loader -- registries, event/hook subscriptions, exports -- is dropped and
|
||||
-- a fresh one built, so nothing has to be un-registered piecemeal and the
|
||||
-- boot-time freeze never needs an unfreeze. Only required from the dev
|
||||
-- hotkey path and the console, never on a player boot.
|
||||
|
||||
local HotReload = {}
|
||||
|
||||
-- the overworld holds a built Map object; rebuild it in place so the
|
||||
-- reloaded records are what the world reads from the next step on
|
||||
local function reloadMap(game)
|
||||
local ow = game.overworld
|
||||
if not (ow and ow.map and ow.setMap and ow.player and game.stack) then return end
|
||||
for _, state in ipairs(game.stack.states or {}) do
|
||||
if state == ow then
|
||||
ow:setMap(ow.map.id, ow.player.cellX, ow.player.cellY,
|
||||
ow.player.facing, { via = "boot" })
|
||||
return
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
-- opts.fs / opts.dev thread through to Loader.new for headless tests; the
|
||||
-- in-game F5 path passes nothing and picks up love.filesystem
|
||||
function HotReload.run(game, opts)
|
||||
local Loader = require("src.mods.Loader")
|
||||
local Assets = require("src.render.Assets")
|
||||
local Runtime = require("src.mods.Runtime")
|
||||
local Logger = require("src.core.Logger")
|
||||
local data = game.data
|
||||
if data and data.reloadGenerated then data:reloadGenerated() end
|
||||
local loader = Loader.new(opts and { fs = opts.fs, dev = opts.dev } or nil)
|
||||
loader.game = game
|
||||
-- mod.save keeps pointing at the live slot across the reload
|
||||
if game.save and game.save.modData then loader.modSave = game.save.modData end
|
||||
local ok, err = pcall(loader.load, loader, data)
|
||||
if not ok then
|
||||
loader.errors[#loader.errors + 1] = tostring(err)
|
||||
Logger.error("hot reload: %s", tostring(err))
|
||||
end
|
||||
game.mods = loader
|
||||
game.modStatus = loader:status()
|
||||
-- the one invalidation entry point: every downstream cache registered
|
||||
-- against Assets empties here (maps, tiles, sprites, pics, font, screens)
|
||||
Assets.flush()
|
||||
-- Theme binds Data outside the cache contract, so re-fold it by hand
|
||||
local themeOk, Theme = pcall(require, "src.ui.Theme")
|
||||
if themeOk and Theme.load then pcall(Theme.load, data) end
|
||||
-- entry chunks just re-ran and re-subscribed; hand them the Game again
|
||||
Runtime.emit("game.ready", { game = game })
|
||||
reloadMap(game)
|
||||
local summary = ("reloaded %d mods (%d errors)")
|
||||
:format(#loader.loaded, #loader.errors)
|
||||
Logger.info("%s", summary)
|
||||
return loader, summary
|
||||
end
|
||||
|
||||
return HotReload
|
||||