mirror of
https://github.com/bryanthaboi/gen1recomp.git
synced 2026-08-12 00:10:56 +02:00
Merge branch 'fix1037' into fix1038
# Conflicts: # docs/modding.md
This commit is contained in:
+10
-15
@@ -1067,23 +1067,18 @@ local championsRoomRivalScript = {
|
|||||||
{ "show_text", "_ChampionsRoomOakComeWithMeText" }, -- 23
|
{ "show_text", "_ChampionsRoomOakComeWithMeText" }, -- 23
|
||||||
{ "move_npc", 2, "up", 2 }, -- 24 OakExitChampionsRoomMovement
|
{ "move_npc", 2, "up", 2 }, -- 24 OakExitChampionsRoomMovement
|
||||||
{ "hide_object", "CHAMPIONS_ROOM", "CHAMPIONSROOM_OAK" }, -- 25
|
{ "hide_object", "CHAMPIONS_ROOM", "CHAMPIONSROOM_OAK" }, -- 25
|
||||||
-- ChampionsRoomPlayerFollowsOakScript / WalkToHallOfFame_RLEMovement
|
-- ChampionsRoomPlayerFollowsOakScript / WalkToHallOfFame_RLEMovement.
|
||||||
-- (PAD_UP 4, PAD_LEFT 1): the player walks out after Oak instead of the
|
-- The player walks out after Oak instead of the screen just fading on the
|
||||||
-- screen just fading on the spot (#704). The entrance walk leaves the
|
-- spot (#704). Route one tile right before walking north so the player
|
||||||
-- player at (4,3) and both north-wall warps sit on row 0, so the original
|
-- reaches the north-wall HALL_OF_FAME warp without sharing the rival's
|
||||||
-- only ever spends three of those simulated steps -- CheckWarpsNoCollision
|
-- (4,2) cell. The original simulated movement bypasses entity collision,
|
||||||
-- takes the HALL_OF_FAME warp the moment the walk lands on (4,0) and the
|
-- but this scene should not visibly walk through the defeated rival.
|
||||||
-- trailing UP/LEFT are dropped. Scripted steps ignore collision here just
|
{ "move_player", "right", 1 }, -- 26
|
||||||
-- as they do in the original (CollisionCheckOnLand skips its checks while
|
{ "move_player", "up", 3 }, -- 27
|
||||||
-- wSimulatedJoypadStatesIndex is non-zero), so stepping through the
|
|
||||||
-- rival's cell at (4,2) is the ported behavior, not a clip. Re-reported
|
|
||||||
-- as a clip in #847 and re-checked against home/overworld.asm
|
|
||||||
-- CollisionCheckOnLand, which is still the authority: do not "fix" it.
|
|
||||||
{ "move_player", "up", 3 }, -- 26
|
|
||||||
-- hand the induction off to the HALL_OF_FAME room (consumed by its
|
-- hand the induction off to the HALL_OF_FAME room (consumed by its
|
||||||
-- onEnter), then warp up into it (destWarp 1 lands at (4,7) facing up)
|
-- onEnter), then warp up into it (destWarp 1 lands at (4,7) facing up)
|
||||||
{ "set_field", "pendingHallOfFame", true }, -- 27
|
{ "set_field", "pendingHallOfFame", true }, -- 28
|
||||||
{ "warp", "HALL_OF_FAME", 4, 7, "up" }, -- 28
|
{ "warp", "HALL_OF_FAME", 4, 7, "up" }, -- 29
|
||||||
}
|
}
|
||||||
|
|
||||||
M.CHAMPIONS_ROOM = {
|
M.CHAMPIONS_ROOM = {
|
||||||
|
|||||||
@@ -38,7 +38,7 @@ the same core data and graphics into the source tree for verification.
|
|||||||
| | `src/core/SaveData.lua` | Lua-serialized save in the LÖVE save dir |
|
| | `src/core/SaveData.lua` | Lua-serialized save in the LÖVE save dir |
|
||||||
| render | `src/render/Renderer.lua` | 160x144 canvas, integer nearest scaling |
|
| render | `src/render/Renderer.lua` | 160x144 canvas, integer nearest scaling |
|
||||||
| | `src/render/TileRenderer.lua` | one SpriteBatch per map (8x8 quads) + border-block ring |
|
| | `src/render/TileRenderer.lua` | one SpriteBatch per map (8x8 quads) + border-block ring |
|
||||||
| | `src/render/SpriteRenderer.lua` | 6-frame walker sheets, flipped right facing |
|
| | `src/render/SpriteRenderer.lua` | variable-size anchored sprite sheets, 6-frame walkers and flipped right facing |
|
||||||
| | `src/render/Font.lua` | glyph rendering via charmap (greedy longest match) |
|
| | `src/render/Font.lua` | glyph rendering via charmap (greedy longest match) |
|
||||||
| | `src/render/TextBox.lua` | dialogue box: typewriter, `\n` line, `\v` scroll, `\f` page |
|
| | `src/render/TextBox.lua` | dialogue box: typewriter, `\n` line, `\v` scroll, `\f` page |
|
||||||
| | `src/render/Camera.lua`, `Transition.lua` | follow camera, warp fades |
|
| | `src/render/Camera.lua`, `Transition.lua` | follow camera, warp fades |
|
||||||
|
|||||||
@@ -35,6 +35,16 @@ An edited vanilla map becomes a `mod.content.maps:patch` carrying only the
|
|||||||
fields that moved; a new map becomes a `:register`. See
|
fields that moved; a new map becomes a `:register`. See
|
||||||
`docs/new-features.md` and the extension's own README.
|
`docs/new-features.md` and the extension's own README.
|
||||||
|
|
||||||
|
## Read-only map overviews
|
||||||
|
|
||||||
|
`mod.world:mapOverview()` returns collision `rows` at map-cell resolution,
|
||||||
|
optional visual `tileRows` at 2x resolution, and optional `tileDetailRows` at
|
||||||
|
4x resolution. Visual rows contain Game Boy shades from `"0"` (lightest) to
|
||||||
|
`"3"` (darkest); their matching width and height fields describe the grid.
|
||||||
|
`markers` contains active `{ kind, x, y }` points in map-cell coordinates for
|
||||||
|
`warp`, visible `item`, and untaken `hidden` locations. All fields are
|
||||||
|
read-only snapshots; mods choose which layers to render.
|
||||||
|
|
||||||
## Rendering pipelines
|
## Rendering pipelines
|
||||||
|
|
||||||
Most registries hand the engine *content*. `render_pipelines` hands it
|
Most registries hand the engine *content*. `render_pipelines` hands it
|
||||||
@@ -100,6 +110,44 @@ Three rules worth knowing:
|
|||||||
Returning `nil` from `drawWorld` is a normal answer meaning "not this
|
Returning `nil` from `drawWorld` is a normal answer meaning "not this
|
||||||
frame"; the engine draws the vanilla world instead.
|
frame"; the engine draws the vanilla world instead.
|
||||||
|
|
||||||
|
## Variable-size overworld sprites
|
||||||
|
|
||||||
|
The `sprites` registry keeps the vanilla 16x16 grounded walker as its default,
|
||||||
|
but a mod can describe any frame rectangle and anchor for player characters,
|
||||||
|
NPCs, followers, mounts, vehicles, bosses, or other field actors:
|
||||||
|
|
||||||
|
```lua
|
||||||
|
mod.content.sprites:register("SPRITE_COMPANION", {
|
||||||
|
image = "mods/example/companion.png", -- one frame per row
|
||||||
|
frames = 6,
|
||||||
|
walker = true,
|
||||||
|
frameWidth = 32,
|
||||||
|
frameHeight = 32,
|
||||||
|
anchorX = 16, -- frame-relative bottom-center anchor
|
||||||
|
anchorY = 32,
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
`frameWidth` and `frameHeight` are sheet pixels. `anchorX` and `anchorY` are
|
||||||
|
measured from each frame's top-left; when omitted they default to the frame's
|
||||||
|
horizontal center and bottom edge, so a larger sprite grows upward while its
|
||||||
|
feet stay on the same world cell. Omitting all four fields is exactly the
|
||||||
|
vanilla 16x16 placement. The normal player/NPC/follower draw paths consume
|
||||||
|
these values automatically, including horizontal flips and the fishing pose.
|
||||||
|
|
||||||
|
Custom render pipelines can use the same geometry without reproducing the
|
||||||
|
pose rules:
|
||||||
|
|
||||||
|
```lua
|
||||||
|
local geometry = sprite:getPoseGeometry(facing, walkPhase, stepFlip)
|
||||||
|
-- geometry.quad, .x/.y/.width/.height, .anchorX/.anchorY, .mirror
|
||||||
|
local originX, originY = sprite:getScreenOrigin(px, py, camX, camY)
|
||||||
|
```
|
||||||
|
|
||||||
|
`getFrameGeometry(frame)` is the corresponding accessor for a specific
|
||||||
|
zero-based sheet frame. Both accessors return fresh tables and share the
|
||||||
|
renderer’s frame selection and mirror conventions.
|
||||||
|
|
||||||
## Battle sprite scaling
|
## Battle sprite scaling
|
||||||
|
|
||||||
The enemy's front pic draws at 1x and the player's back pic at 2x, the way
|
The enemy's front pic draws at 1x and the player's back pic at 2x, the way
|
||||||
@@ -290,6 +338,11 @@ update and input ownership, so a mod can mirror a native menu on another
|
|||||||
display without reimplementing it. The default is `true`. Treat the wrapper as
|
display without reimplementing it. The default is `true`. Treat the wrapper as
|
||||||
a pure predicate: the renderer may ask it more than once per frame.
|
a pure predicate: the renderer may ask it more than once per frame.
|
||||||
|
|
||||||
|
Scrollable list states expose `state.kind` for use with this hook. Generic
|
||||||
|
lists fall back to their title; PC lists use stable, localization-independent
|
||||||
|
identifiers: `pc_box_withdraw`, `pc_box_deposit`, `pc_box_release`,
|
||||||
|
`pc_box_change`, `pc_item_withdraw`, `pc_item_deposit`, and `pc_item_toss`.
|
||||||
|
|
||||||
`core.logic_speed` receives `(next, game)` once per `Game:logicSpeed()` call
|
`core.logic_speed` receives `(next, game)` once per `Game:logicSpeed()` call
|
||||||
(once per frame). Vanilla behavior resolves the per-category GAME SPEED
|
(once per frame). Vanilla behavior resolves the per-category GAME SPEED
|
||||||
option (`GameSpeed.CATEGORIES`: overworld/battle/menu) for whichever
|
option (`GameSpeed.CATEGORIES`: overworld/battle/menu) for whichever
|
||||||
@@ -305,3 +358,36 @@ which stay unconditional and are never visible to a subscriber.
|
|||||||
|
|
||||||
Developer mode also arms the mod loader's dev tripwire, which flags mods
|
Developer mode also arms the mod loader's dev tripwire, which flags mods
|
||||||
that reach outside their permission set.
|
that reach outside their permission set.
|
||||||
|
|
||||||
|
## Process-lifecycle hooks
|
||||||
|
|
||||||
|
These exist so a platform-specific launcher integration (a native shell
|
||||||
|
that embeds this engine and wraps its window in platform UI) can live
|
||||||
|
entirely in a mod instead of hand-patching `main.lua`, which every other
|
||||||
|
engine change also touches.
|
||||||
|
|
||||||
|
`core.update` receives `(next, game, dt)` once per frame from
|
||||||
|
`love.update`. Vanilla behavior is `game:update(dt)`, unconditionally. A
|
||||||
|
mod may skip calling `next(game, dt)` to pause the simulation for that
|
||||||
|
frame (e.g. while a native settings sheet is on top), and may run
|
||||||
|
additional per-frame polling before or after that call regardless of
|
||||||
|
whether it calls `next` -- useful for one-shot flags that must be observed
|
||||||
|
every frame even while paused.
|
||||||
|
|
||||||
|
`core.quit_to_launcher` receives `(next)` once from `love.quit()`. `next()`
|
||||||
|
returns the engine's own decision for whether closing the window should
|
||||||
|
return to the Lua launcher instead of exiting; a mod may return `false`
|
||||||
|
outright, without ever calling `next`, to veto that and let the process
|
||||||
|
really quit -- for a platform host that owns its own "return to launcher"
|
||||||
|
UI and would otherwise get looped straight back into the game it just
|
||||||
|
quit.
|
||||||
|
|
||||||
|
A manifest may also declare `force_enable_env`, an environment variable
|
||||||
|
name that re-enables the mod regardless of a saved disable in
|
||||||
|
`options.mods` when that variable is set to `"1"`. This is for a mod that
|
||||||
|
cannot function disabled on the one build where its env var is set (a
|
||||||
|
platform-bridge mod bundled only with that build's launcher, for example).
|
||||||
|
|
||||||
|
Neither hook needs a `Runtime.wantsHook` guard before calling it: `Hooks:call`
|
||||||
|
already falls straight through to the vanilla function when no mod has
|
||||||
|
wrapped the name, at negligible cost.
|
||||||
|
|||||||
@@ -0,0 +1,107 @@
|
|||||||
|
# RFC 0006 — Generic process-lifecycle hooks for platform launcher integrations
|
||||||
|
|
||||||
|
## Status
|
||||||
|
|
||||||
|
Proposed. Engine: `PlatformHooks.lua` (new), `main.lua`, `Manifest.lua`,
|
||||||
|
`Loader.lua`. Tests: `tests/modkit/cases/platform_lifecycle_hooks.lua`,
|
||||||
|
`tests/mod_loader_tests.lua`, `tests/mod_manifest_tests.lua`.
|
||||||
|
|
||||||
|
## Motivation
|
||||||
|
|
||||||
|
A platform-specific launcher wrapper -- a native shell that embeds this
|
||||||
|
engine and owns its own UI around the game window (a mobile app shell,
|
||||||
|
say, presenting its own settings/import/save screens and only handing
|
||||||
|
control to the LÖVE window once play starts) needs three things no
|
||||||
|
current hook covers:
|
||||||
|
|
||||||
|
1. Pause the simulation while its own UI is on top of the game window.
|
||||||
|
2. Live-reload options it wrote from outside any Lua UI.
|
||||||
|
3. Veto `main.lua`'s "closing the window returns to the Lua launcher"
|
||||||
|
behavior when the platform shell owns that job itself -- without this,
|
||||||
|
a shell that re-fronts its own launcher UI on quit gets looped straight
|
||||||
|
back into `HostShell.restart()`'s in-process reboot instead.
|
||||||
|
|
||||||
|
Implementing this by hand-patching `main.lua`'s `love.update`/`love.quit`
|
||||||
|
directly ties every such integration to editing the one file every other
|
||||||
|
engine change also touches, guaranteeing merge conflicts for any second
|
||||||
|
platform integration (or any unrelated engine PR landing around the same
|
||||||
|
time). No existing hook covers "should the per-frame simulation step run"
|
||||||
|
or "should closing the window return to the Lua launcher."
|
||||||
|
|
||||||
|
## The decision it extends
|
||||||
|
|
||||||
|
No prior D-number. Extends the hook-contract section of `docs/modding.md`
|
||||||
|
alongside `input.step`, `render.hud`, `screen.render_visible`, etc.
|
||||||
|
|
||||||
|
## The exact API delta
|
||||||
|
|
||||||
|
Backward-compatible, additive-only.
|
||||||
|
|
||||||
|
### `core.update`
|
||||||
|
|
||||||
|
New hook, `(game, dt) -> nil` through the public wrapper signature
|
||||||
|
`(next, game, dt)`, called once per frame from `love.update` via
|
||||||
|
`src/core/PlatformHooks.lua`'s `PlatformHooks.update(game, dt)`. Vanilla
|
||||||
|
behavior (used when no mod claims the hook) is `game:update(dt)`,
|
||||||
|
unconditionally -- identical to `love.update`'s behavior before this hook
|
||||||
|
existed. A subscriber may skip calling `next(game, dt)` to pause the
|
||||||
|
simulation for that frame, or do additional per-frame work before/after
|
||||||
|
calling it regardless of whether it calls `next`.
|
||||||
|
|
||||||
|
### `core.quit_to_launcher`
|
||||||
|
|
||||||
|
New hook, `() -> boolean` through the public wrapper signature `(next)`,
|
||||||
|
called once from `love.quit()` via
|
||||||
|
`PlatformHooks.quitToLauncher(vanilla)`. `vanilla` is the pre-existing
|
||||||
|
non-platform-specific decision (`Game and not Importer and not
|
||||||
|
quitToLauncher and not scripted and not launchedIntoGame`). A subscriber
|
||||||
|
may return `false` outright to veto returning to the Lua launcher (without
|
||||||
|
ever calling `next`, so the vanilla condition is never evaluated), or call
|
||||||
|
`next()` and return its result to pass the vanilla decision through
|
||||||
|
unchanged.
|
||||||
|
|
||||||
|
Neither hook is guarded by `Runtime.wantsHook` -- both fire unconditionally
|
||||||
|
every call, matching the existing `input.step` precedent
|
||||||
|
(`src/core/Game.lua`), since `Hooks:call` already fast-paths to a bare
|
||||||
|
`vanilla(...)` call when no mod has wrapped the name.
|
||||||
|
|
||||||
|
### `Manifest.force_enable_env`
|
||||||
|
|
||||||
|
New optional manifest field, a bare env-var name. `Loader:load` re-enables
|
||||||
|
a mod carrying this field whenever that variable is set to `"1"`,
|
||||||
|
regardless of a saved disable in `options.mods`. This exists for exactly
|
||||||
|
the mod class this RFC is for: a platform-bridge mod that ships only with
|
||||||
|
one build and cannot function disabled there, but must still behave like
|
||||||
|
every other mod (a manifest opt-in, not an engine special case) on every
|
||||||
|
build that doesn't set its variable.
|
||||||
|
|
||||||
|
## Migration note for existing mods
|
||||||
|
|
||||||
|
**Nothing.** With no subscriber, `love.update` still calls `Game:update(dt)`
|
||||||
|
unconditionally every frame and `love.quit()`'s restart-to-launcher
|
||||||
|
decision is exactly the pre-existing condition -- bit-identical to today's
|
||||||
|
behavior on every platform where no mod wraps either hook. A manifest with
|
||||||
|
no `force_enable_env` field behaves exactly as before.
|
||||||
|
|
||||||
|
## Parity tests
|
||||||
|
|
||||||
|
- **No-mod:** `core.update`'s vanilla runs exactly once per call with the
|
||||||
|
hook chain empty; `core.quit_to_launcher`'s vanilla return value passes
|
||||||
|
through unchanged. Both hooks are picked up automatically by the
|
||||||
|
catalog-driven no-mod gate (`tests/engine/gate_hooks.lua`, which scans
|
||||||
|
for `Runtime.call("...")` call sites), so neither needs a dedicated
|
||||||
|
no-mod test file.
|
||||||
|
- **Mod-API:** `tests/modkit/cases/platform_lifecycle_hooks.lua` proves,
|
||||||
|
through a fixture mod loaded via the public loader (not the engine's
|
||||||
|
internals), that a subscriber can skip the vanilla update call (pause),
|
||||||
|
run extra per-frame polling regardless of pause state, and veto the
|
||||||
|
quit-to-launcher decision without the vanilla condition ever running.
|
||||||
|
- `tests/mod_loader_tests.lua` and `tests/mod_manifest_tests.lua` cover
|
||||||
|
`force_enable_env`: a matching env var re-enables a mod saved as
|
||||||
|
disabled, and an unset one leaves the saved disable alone.
|
||||||
|
|
||||||
|
## Deprecation etiquette
|
||||||
|
|
||||||
|
Nothing deprecated. These are two additive hooks and one additive manifest
|
||||||
|
field; `main.lua`'s only footprint is one `require` and two call sites
|
||||||
|
into `src/core/PlatformHooks.lua`.
|
||||||
@@ -13,6 +13,7 @@ local editorMode = os.getenv("POKEPORT_EDITOR") == "1" or POKEPORT_EDITOR_MODE =
|
|||||||
local SwitchDiagnostics = require("src.debug.SwitchDiagnostics")
|
local SwitchDiagnostics = require("src.debug.SwitchDiagnostics")
|
||||||
local LaunchOptions = require("src.core.LaunchOptions")
|
local LaunchOptions = require("src.core.LaunchOptions")
|
||||||
local NxDisplay = require("src.core.NxDisplay")
|
local NxDisplay = require("src.core.NxDisplay")
|
||||||
|
local PlatformHooks = require("src.core.PlatformHooks")
|
||||||
|
|
||||||
-- Lua errors: persist a redacted trace in the save dir and surface a hint.
|
-- Lua errors: persist a redacted trace in the save dir and surface a hint.
|
||||||
do
|
do
|
||||||
@@ -426,7 +427,11 @@ function love.update(dt)
|
|||||||
end
|
end
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
Game:update(dt)
|
-- Mods may wrap or veto the per-frame simulation step (pause it, react
|
||||||
|
-- to external platform state, etc.) -- see docs/modding.md's core.update
|
||||||
|
-- entry. Vanilla behavior (used when no mod claims the hook) is just
|
||||||
|
-- Game:update(dt), unconditionally, exactly as before this hook existed.
|
||||||
|
PlatformHooks.update(Game, dt)
|
||||||
end
|
end
|
||||||
|
|
||||||
function love.draw()
|
function love.draw()
|
||||||
@@ -819,8 +824,16 @@ function love.quit()
|
|||||||
or os.getenv("POKEPORT_IMPORT_ONLY") == "1" or os.getenv("POKEPORT_IMPORT_ROM")
|
or os.getenv("POKEPORT_IMPORT_ONLY") == "1" or os.getenv("POKEPORT_IMPORT_ROM")
|
||||||
-- #887: a shortcut session (--game / POKEPORT_GAME) has no launcher to go
|
-- #887: a shortcut session (--game / POKEPORT_GAME) has no launcher to go
|
||||||
-- back to and the restart would re-read the shortcut, so it exits instead.
|
-- back to and the restart would re-read the shortcut, so it exits instead.
|
||||||
if Game and not Importer and not quitToLauncher and not scripted
|
--
|
||||||
and not launchedIntoGame then
|
-- A platform launcher that owns "return to launcher" itself (see
|
||||||
|
-- docs/modding.md's core.quit_to_launcher entry) may veto returning to
|
||||||
|
-- this Lua launcher via that hook. Vanilla behavior (used when no mod
|
||||||
|
-- claims the hook) is exactly the condition below.
|
||||||
|
local wouldReturnToLauncher = PlatformHooks.quitToLauncher(function()
|
||||||
|
return Game and not Importer and not quitToLauncher and not scripted
|
||||||
|
and not launchedIntoGame
|
||||||
|
end)
|
||||||
|
if wouldReturnToLauncher then
|
||||||
quitToLauncher = true
|
quitToLauncher = true
|
||||||
-- Tell the fresh boot to ignore any boot-straight-into-a-game option this
|
-- Tell the fresh boot to ignore any boot-straight-into-a-game option this
|
||||||
-- once, so the restart really does land in the launcher (#887). A failed
|
-- once, so the restart really does land in the launcher (#887). A failed
|
||||||
|
|||||||
@@ -1899,6 +1899,7 @@ function BattleState:update(dt)
|
|||||||
end
|
end
|
||||||
self.menuIndex = row * 2 + col + 1
|
self.menuIndex = row * 2 + col + 1
|
||||||
if input:wasPressed("a") then
|
if input:wasPressed("a") then
|
||||||
|
require("src.core.Sound").play(self.data, "Press_AB")
|
||||||
self:safariAction(({ "ball", "bait", "rock", "run" })[self.menuIndex])
|
self:safariAction(({ "ball", "bait", "rock", "run" })[self.menuIndex])
|
||||||
end
|
end
|
||||||
return
|
return
|
||||||
@@ -1935,6 +1936,7 @@ function BattleState:update(dt)
|
|||||||
end
|
end
|
||||||
self.menuIndex = row * 2 + col + 1
|
self.menuIndex = row * 2 + col + 1
|
||||||
if input:wasPressed("a") then
|
if input:wasPressed("a") then
|
||||||
|
require("src.core.Sound").play(self.data, "Press_AB")
|
||||||
local choice = ({ "fight", "pkmn", "item", "run" })[self.menuIndex]
|
local choice = ({ "fight", "pkmn", "item", "run" })[self.menuIndex]
|
||||||
if choice == "fight" and self.ghost then
|
if choice == "fight" and self.ghost then
|
||||||
self:say(Strings("%s is too\nscared to move!", self.player.name))
|
self:say(Strings("%s is too\nscared to move!", self.player.name))
|
||||||
@@ -1996,9 +1998,11 @@ function BattleState:update(dt)
|
|||||||
self.moveSwapIndex = self.moveIndex
|
self.moveSwapIndex = self.moveIndex
|
||||||
end
|
end
|
||||||
elseif input:wasPressed("b") then
|
elseif input:wasPressed("b") then
|
||||||
|
require("src.core.Sound").play(self.data, "Press_AB")
|
||||||
self.moveSwapIndex = nil
|
self.moveSwapIndex = nil
|
||||||
self.phase = "menu"
|
self.phase = "menu"
|
||||||
elseif input:wasPressed("a") then
|
elseif input:wasPressed("a") then
|
||||||
|
require("src.core.Sound").play(self.data, "Press_AB")
|
||||||
if self.moveSwapIndex then
|
if self.moveSwapIndex then
|
||||||
self:swapMoves(self.moveSwapIndex, self.moveIndex)
|
self:swapMoves(self.moveSwapIndex, self.moveIndex)
|
||||||
self.moveSwapIndex = nil
|
self.moveSwapIndex = nil
|
||||||
@@ -2037,6 +2041,7 @@ function BattleState:update(dt)
|
|||||||
elseif input:wasPressed("down") then
|
elseif input:wasPressed("down") then
|
||||||
self.mimicIndex = self.mimicIndex < #moves and self.mimicIndex + 1 or 1
|
self.mimicIndex = self.mimicIndex < #moves and self.mimicIndex + 1 or 1
|
||||||
elseif input:wasPressed("a") then
|
elseif input:wasPressed("a") then
|
||||||
|
require("src.core.Sound").play(self.data, "Press_AB")
|
||||||
local pick = moves[self.mimicIndex]
|
local pick = moves[self.mimicIndex]
|
||||||
local ctx = self.mimicCtx
|
local ctx = self.mimicCtx
|
||||||
self.mimicMoves, self.mimicCtx = nil, nil
|
self.mimicMoves, self.mimicCtx = nil, nil
|
||||||
|
|||||||
@@ -281,11 +281,11 @@ MoveEffects.primary = {
|
|||||||
failed = true }
|
failed = true }
|
||||||
end
|
end
|
||||||
local cost = math.floor(user.mon.stats.hp / 4)
|
local cost = math.floor(user.mon.stats.hp / 4)
|
||||||
-- substitute.asm only fails on subtraction underflow (current HP
|
-- A Substitute costs one quarter of max HP, rounded down. Do not let
|
||||||
-- strictly below maxHP/4); at equality the substitute is built and
|
-- the cost consume the user's last HP: the move must fail at the exact
|
||||||
-- the user is left standing on exactly 0 HP (it faints only when
|
-- boundary as well as below it, or the next turn's HP guard can leave a
|
||||||
-- the engine next checks HP, not here)
|
-- trainer battle unable to progress.
|
||||||
if user.mon.hp < cost then
|
if user.mon.hp <= cost then
|
||||||
return { romText(battle.data, "_TooWeakSubstituteText", "Too weak to make\na SUBSTITUTE!"),
|
return { romText(battle.data, "_TooWeakSubstituteText", "Too weak to make\na SUBSTITUTE!"),
|
||||||
failed = true }
|
failed = true }
|
||||||
end
|
end
|
||||||
|
|||||||
+83
-30
@@ -111,6 +111,11 @@ local NOISE_DIVISORS = {
|
|||||||
[4] = 64, [5] = 80, [6] = 96, [7] = 112,
|
[4] = 64, [5] = 80, [6] = 96, [7] = 112,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
local HPF_CHARGE = 0.999958 ^ (GB_CLOCK / SAMPLE_RATE)
|
||||||
|
local LPF_ALPHA = 0.8
|
||||||
|
local MIX_SCALE = 0.5
|
||||||
|
|
||||||
local function snapTicks(ticks)
|
local function snapTicks(ticks)
|
||||||
return math.floor((ticks * 1470 + 256) / 512)
|
return math.floor((ticks * 1470 + 256) / 512)
|
||||||
end
|
end
|
||||||
@@ -269,6 +274,7 @@ function Channel.new(engine, spec, options)
|
|||||||
phase = 0,
|
phase = 0,
|
||||||
noiseLfsr = 0x7FFF,
|
noiseLfsr = 0x7FFF,
|
||||||
noiseClock = 0,
|
noiseClock = 0,
|
||||||
|
drumTail = nil,
|
||||||
timeTicks = 0,
|
timeTicks = 0,
|
||||||
}, Channel)
|
}, Channel)
|
||||||
end
|
end
|
||||||
@@ -527,6 +533,25 @@ local function envelopeVolume(volume, fade, elapsed)
|
|||||||
return math.min(15, volume + steps)
|
return math.min(15, volume + steps)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
||||||
|
local function envelopeRingSamples(volume, fade)
|
||||||
|
if not fade or fade <= 0 or not volume or volume <= 0 then return 0 end
|
||||||
|
return math.floor(volume * (fade / 64) * SAMPLE_RATE + 0.5)
|
||||||
|
end
|
||||||
|
|
||||||
|
local function extendDrumEnvelope(segments)
|
||||||
|
local last = segments and segments[#segments]
|
||||||
|
if not last then return segments end
|
||||||
|
local ringEnd = last.startSample + envelopeRingSamples(last.volume, last.fade)
|
||||||
|
if ringEnd > last.endSample then last.endSample = ringEnd end
|
||||||
|
return segments
|
||||||
|
end
|
||||||
|
|
||||||
|
local function drumAudioEnd(drum)
|
||||||
|
local last = drum and drum[#drum]
|
||||||
|
return last and last.endSample or 0
|
||||||
|
end
|
||||||
|
|
||||||
function Channel:resetNoise()
|
function Channel:resetNoise()
|
||||||
self.noiseLfsr = 0x7FFF
|
self.noiseLfsr = 0x7FFF
|
||||||
self.noiseClock = 0
|
self.noiseClock = 0
|
||||||
@@ -566,8 +591,7 @@ function Channel:sampleNoise(parameter)
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
-- LuaGB: instantaneous inverted LFSR LSB (high when bit0 == 0)
|
return bit.band(self.noiseLfsr, 1) == 0 and 1 or 0
|
||||||
return bit.band(self.noiseLfsr, 1) == 0 and 1 or -1
|
|
||||||
end
|
end
|
||||||
|
|
||||||
local function sweepCalculation(register, sweep)
|
local function sweepCalculation(register, sweep)
|
||||||
@@ -611,21 +635,51 @@ end
|
|||||||
function Channel:sample()
|
function Channel:sample()
|
||||||
while not self.ended
|
while not self.ended
|
||||||
and (not self.event or self.event.sample >= self.event.samples) do
|
and (not self.event or self.event.sample >= self.event.samples) do
|
||||||
|
local prev = self.event
|
||||||
self.event = self:nextEvent()
|
self.event = self:nextEvent()
|
||||||
self.phase = 0
|
self.phase = 0
|
||||||
self:resetNoise()
|
if self.event and self.event.drum then
|
||||||
|
self.drumTail = nil
|
||||||
|
self:resetNoise()
|
||||||
|
elseif prev and prev.drum and prev.sample < drumAudioEnd(prev.drum) then
|
||||||
|
-- ..(audio/engine_1.asm ln 197)
|
||||||
|
self.drumTail = prev
|
||||||
|
elseif not (self.event and self.event.silence and self.drumTail) then
|
||||||
|
self.drumTail = nil
|
||||||
|
self:resetNoise()
|
||||||
|
end
|
||||||
end
|
end
|
||||||
local event = self.event
|
local event = self.event
|
||||||
if not event then return 0 end
|
local gain = channelVolume[self.hardware] or 1
|
||||||
|
if not event then
|
||||||
|
local tail = self.drumTail
|
||||||
|
if not tail then return 0 end
|
||||||
|
local sampleIndex = tail.sample
|
||||||
|
tail.sample = sampleIndex + 1
|
||||||
|
if sampleIndex >= drumAudioEnd(tail.drum) then
|
||||||
|
self.drumTail = nil
|
||||||
|
return 0
|
||||||
|
end
|
||||||
|
return self:sampleDrum(tail, sampleIndex) * gain
|
||||||
|
end
|
||||||
local sampleIndex = event.sample
|
local sampleIndex = event.sample
|
||||||
event.elapsed = sampleIndex / SAMPLE_RATE
|
event.elapsed = sampleIndex / SAMPLE_RATE
|
||||||
event.sample = sampleIndex + 1
|
event.sample = sampleIndex + 1
|
||||||
if event.silence then return 0 end
|
if event.silence then
|
||||||
|
local tail = self.drumTail
|
||||||
local gain = channelVolume[self.hardware] or 1
|
if not tail then return 0 end
|
||||||
|
local tailIndex = tail.sample
|
||||||
|
tail.sample = tailIndex + 1
|
||||||
|
if tailIndex >= drumAudioEnd(tail.drum) then
|
||||||
|
self.drumTail = nil
|
||||||
|
return 0
|
||||||
|
end
|
||||||
|
return self:sampleDrum(tail, tailIndex) * gain
|
||||||
|
end
|
||||||
if event.drum then
|
if event.drum then
|
||||||
return self:sampleDrum(event, sampleIndex) * gain
|
return self:sampleDrum(event, sampleIndex) * gain
|
||||||
end
|
end
|
||||||
|
self.drumTail = nil
|
||||||
local volume = envelopeVolume(
|
local volume = envelopeVolume(
|
||||||
event.volume or 0, event.fade or 0, event.elapsed)
|
event.volume or 0, event.fade or 0, event.elapsed)
|
||||||
if event.noise then
|
if event.noise then
|
||||||
@@ -665,7 +719,8 @@ function Channel:sample()
|
|||||||
-- a def-local program may omit its wave table entirely
|
-- a def-local program may omit its wave table entirely
|
||||||
if not wave then return 0 end
|
if not wave then return 0 end
|
||||||
local index = math.min(32, math.floor(phase * 32) + 1)
|
local index = math.min(32, math.floor(phase * 32) + 1)
|
||||||
return wave[index] * event.waveLevel * gain
|
local nibble = math.max(0, math.min(15, wave[index] * 8 + 8))
|
||||||
|
return (nibble / 15) * event.waveLevel * gain
|
||||||
end
|
end
|
||||||
local duty = event.duty
|
local duty = event.duty
|
||||||
if type(duty) == "table" then
|
if type(duty) == "table" then
|
||||||
@@ -674,7 +729,7 @@ function Channel:sample()
|
|||||||
local pattern = WAVE_PATTERN_TABLES[duty or 2] or WAVE_PATTERN_TABLES[2]
|
local pattern = WAVE_PATTERN_TABLES[duty or 2] or WAVE_PATTERN_TABLES[2]
|
||||||
local step = math.floor(phase * 8) % 8
|
local step = math.floor(phase * 8) % 8
|
||||||
if pattern[step + 1] == 0 then
|
if pattern[step + 1] == 0 then
|
||||||
return -volume / 15 * gain
|
return 0
|
||||||
end
|
end
|
||||||
return volume / 15 * gain
|
return volume / 15 * gain
|
||||||
end
|
end
|
||||||
@@ -685,7 +740,7 @@ Engine.__index = Engine
|
|||||||
function Engine:noiseInstrument(number)
|
function Engine:noiseInstrument(number)
|
||||||
-- a def-local drum wins over the ROM engine's table for that id
|
-- a def-local drum wins over the ROM engine's table for that id
|
||||||
local custom = self.customDrums and self.customDrums[number]
|
local custom = self.customDrums and self.customDrums[number]
|
||||||
if custom then return custom end
|
if custom then return extendDrumEnvelope(custom) end
|
||||||
local cached = self.noiseInstruments[number]
|
local cached = self.noiseInstruments[number]
|
||||||
if cached then return cached end
|
if cached then return cached end
|
||||||
|
|
||||||
@@ -718,6 +773,7 @@ function Engine:noiseInstrument(number)
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
extendDrumEnvelope(segments)
|
||||||
self.noiseInstruments[number] = segments
|
self.noiseInstruments[number] = segments
|
||||||
return segments
|
return segments
|
||||||
end
|
end
|
||||||
@@ -792,6 +848,8 @@ function Engine.new(data, header, options)
|
|||||||
customDrums = chip and chip.drums or nil,
|
customDrums = chip and chip.drums or nil,
|
||||||
noiseInstruments = {},
|
noiseInstruments = {},
|
||||||
channels = {},
|
channels = {},
|
||||||
|
hpfCap = 0, hpfCapLeft = 0, hpfCapRight = 0,
|
||||||
|
lpf = 0, lpfLeft = 0, lpfRight = 0,
|
||||||
}, Engine)
|
}, Engine)
|
||||||
-- header.tempo: the Music_*AlternateTempo override Music.play stamps onto
|
-- header.tempo: the Music_*AlternateTempo override Music.play stamps onto
|
||||||
-- a copy of the song def (audio/alternate_tempo.asm) (#847)
|
-- a copy of the song def (audio/alternate_tempo.asm) (#847)
|
||||||
@@ -826,10 +884,20 @@ function Engine:finished()
|
|||||||
return true
|
return true
|
||||||
end
|
end
|
||||||
|
|
||||||
|
local function analogOut(engine, input, hpfField, lpfField)
|
||||||
|
local cap = engine[hpfField]
|
||||||
|
local hp = input - cap
|
||||||
|
engine[hpfField] = input - hp * HPF_CHARGE
|
||||||
|
local prev = engine[lpfField]
|
||||||
|
local lp = prev + LPF_ALPHA * (hp - prev)
|
||||||
|
engine[lpfField] = lp
|
||||||
|
return math.max(-1, math.min(1, lp * MIX_SCALE))
|
||||||
|
end
|
||||||
|
|
||||||
function Engine:sample()
|
function Engine:sample()
|
||||||
local value = 0
|
local value = 0
|
||||||
for _, channel in ipairs(self.channels) do value = value + channel:sample() end
|
for _, channel in ipairs(self.channels) do value = value + channel:sample() end
|
||||||
return math.max(-1, math.min(1, value / 4))
|
return analogOut(self, value, "hpfCap", "lpf")
|
||||||
end
|
end
|
||||||
|
|
||||||
function Engine:sampleStereo()
|
function Engine:sampleStereo()
|
||||||
@@ -840,8 +908,8 @@ function Engine:sampleStereo()
|
|||||||
if not event or event.panLeft ~= false then left = left + value end
|
if not event or event.panLeft ~= false then left = left + value end
|
||||||
if not event or event.panRight ~= false then right = right + value end
|
if not event or event.panRight ~= false then right = right + value end
|
||||||
end
|
end
|
||||||
return math.max(-1, math.min(1, left / 4)),
|
return analogOut(self, left, "hpfCapLeft", "lpfLeft"),
|
||||||
math.max(-1, math.min(1, right / 4))
|
analogOut(self, right, "hpfCapRight", "lpfRight")
|
||||||
end
|
end
|
||||||
|
|
||||||
function Engine:sampleChannel(number)
|
function Engine:sampleChannel(number)
|
||||||
@@ -850,7 +918,7 @@ function Engine:sampleChannel(number)
|
|||||||
local value = channel:sample()
|
local value = channel:sample()
|
||||||
if channel.number == number then selected = value end
|
if channel.number == number then selected = value end
|
||||||
end
|
end
|
||||||
return math.max(-1, math.min(1, selected / 4))
|
return analogOut(self, selected, "hpfCap", "lpf")
|
||||||
end
|
end
|
||||||
|
|
||||||
-- render `samples` frames into a fresh SoundData (mono or stereo). love.sound
|
-- render `samples` frames into a fresh SoundData (mono or stereo). love.sound
|
||||||
@@ -870,22 +938,7 @@ local function soundData(engine, samples, channels)
|
|||||||
return result
|
return result
|
||||||
end
|
end
|
||||||
|
|
||||||
-- Render a one-shot effect (SFX/cry) to a two-channel SoundData, or nil when
|
|
||||||
-- it is too short to be audible. The caller wraps it in a static
|
|
||||||
-- love.audio.Source (a playback concern, hence not done here).
|
|
||||||
--
|
|
||||||
-- The synthesis is mono (one summed value per frame, unlike the music path's
|
|
||||||
-- sampleStereo), but the buffer is written stereo on purpose: OpenAL only
|
|
||||||
-- spatializes 1-channel Sources, and a Source left at the default (0,0,0)
|
|
||||||
-- position, exactly where the listener sits, is rendered as an ambient sound
|
|
||||||
-- spread over EVERY output channel the device exposes at gains that differ
|
|
||||||
-- from the front pair. On an interface with more than two outputs that put
|
|
||||||
-- the SFX on outputs 5+6 as well, while the 2-channel music source
|
|
||||||
-- (ChipAudio.playMusic) stayed on 1+2 (#626). Multi-channel buffers skip
|
|
||||||
-- spatialization entirely and map onto the front pair, so duplicating the
|
|
||||||
-- sample costs one buffer's memory and makes effects route exactly like
|
|
||||||
-- music. Deliberately not sampleStereo: that honors the NR51 panning byte
|
|
||||||
-- and would newly hard-pan any effect whose header issues command 0xEE.
|
|
||||||
local function renderEffectData(data, header, options)
|
local function renderEffectData(data, header, options)
|
||||||
if not header then return nil end
|
if not header then return nil end
|
||||||
options = options or {}
|
options = options or {}
|
||||||
|
|||||||
+37
-39
@@ -1,12 +1,3 @@
|
|||||||
-- Music playback supports compact ROM channel programs synthesized live by
|
|
||||||
-- 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 the bike song until dismount.
|
|
||||||
|
|
||||||
local Logger = require("src.core.Logger")
|
local Logger = require("src.core.Logger")
|
||||||
local Runtime = require("src.mods.Runtime")
|
local Runtime = require("src.mods.Runtime")
|
||||||
|
|
||||||
@@ -14,20 +5,10 @@ local Music = {}
|
|||||||
|
|
||||||
local VOLUME = 0.7
|
local VOLUME = 0.7
|
||||||
|
|
||||||
-- port additions driven by OptionsMenu / save.options: musicVol scales
|
|
||||||
-- VOLUME (0-7 level like the GB's NR50 master volume) and musicFilter
|
|
||||||
-- low-passes the song. Each filter step keeps 40% of the previous
|
|
||||||
-- step's treble (highgain 0.4^level), so 2X/3X are the 1X filter
|
|
||||||
-- applied twice/three times over.
|
|
||||||
local volumeScale = 1
|
local volumeScale = 1
|
||||||
local FILTER_HIGHGAIN = { 0.4, 0.16, 0.064 }
|
local FILTER_HIGHGAIN = { 0.4, 0.16, 0.064 }
|
||||||
local filterLevel = 0
|
local filterLevel = 0
|
||||||
|
|
||||||
-- Forward-declared here so applyVolume (below) closes over the real playback
|
|
||||||
-- state rather than a nil global: the table literal is assigned further down,
|
|
||||||
-- but a `local state = {}` there would leave every reference above it bound
|
|
||||||
-- to the global `state`. Before this, registering the `music.volume` mod
|
|
||||||
-- hook crashed applyVolume on `state.current` (a nil index).
|
|
||||||
local state
|
local state
|
||||||
|
|
||||||
local function applyVolume(src)
|
local function applyVolume(src)
|
||||||
@@ -115,11 +96,6 @@ function Music.duckForFanfare(src)
|
|||||||
end
|
end
|
||||||
end
|
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 = {
|
local OUTDOOR = {
|
||||||
Music_PalletTown = true,
|
Music_PalletTown = true,
|
||||||
Music_Cities1 = true,
|
Music_Cities1 = true,
|
||||||
@@ -218,6 +194,7 @@ end
|
|||||||
-- the single choke point every song choice passes through, so one hook
|
-- the single choke point every song choice passes through, so one hook
|
||||||
-- covers map themes, battle themes, jingles and scene music
|
-- covers map themes, battle themes, jingles and scene music
|
||||||
local function selectSong(song, ctx)
|
local function selectSong(song, ctx)
|
||||||
|
if ctx and ctx.selected then return song end
|
||||||
if not Runtime.wantsHook("music.select") then return song end
|
if not Runtime.wantsHook("music.select") then return song end
|
||||||
return Runtime.call("music.select", function(chosen) return chosen end, song, {
|
return Runtime.call("music.select", function(chosen) return chosen end, song, {
|
||||||
reason = ctx and ctx.reason or "direct",
|
reason = ctx and ctx.reason or "direct",
|
||||||
@@ -234,17 +211,28 @@ end
|
|||||||
function Music.play(data, song, loop, ctx)
|
function Music.play(data, song, loop, ctx)
|
||||||
if not song then return end
|
if not song then return end
|
||||||
if not love.audio then return end -- headless test stub
|
if not love.audio then return end -- headless test stub
|
||||||
|
ctx = ctx or {}
|
||||||
song = selectSong(song, ctx)
|
song = selectSong(song, ctx)
|
||||||
-- ctx.tempo is a Music_*AlternateTempo cue (audio/alternate_tempo.asm):
|
|
||||||
-- the same song restarted with channel 1 re-pointed at a stub whose only
|
|
||||||
-- difference is its `tempo`, so the same label at a different tempo is a
|
|
||||||
-- different cue and must not be deduped away (#847)
|
|
||||||
local tempo = ctx and ctx.tempo or nil
|
local tempo = ctx and ctx.tempo or nil
|
||||||
-- a hook may silence the cue outright, or swap in a label the dedupe
|
-- a hook may silence the cue outright, or swap in a label the dedupe
|
||||||
-- below has to compare against
|
-- below has to compare against
|
||||||
if not song or (song == state.current and tempo == state.tempo) then return end
|
if not song or (song == state.current and tempo == state.tempo) then return end
|
||||||
local def = songDef(data, song)
|
local def = songDef(data, song)
|
||||||
if not def or state.failed[song] then return end
|
if not def or state.failed[song] then return end
|
||||||
|
|
||||||
|
if ctx.fade and state.source then
|
||||||
|
local queued = {}
|
||||||
|
for key, value in pairs(ctx) do queued[key] = value end
|
||||||
|
queued.fade, queued.selected = nil, true
|
||||||
|
local pending = { data = data, song = song, loop = loop, ctx = queued }
|
||||||
|
if state.fade then
|
||||||
|
state.fade.pending = pending
|
||||||
|
else
|
||||||
|
Music.fadeOut(ctx.fade, pending)
|
||||||
|
end
|
||||||
|
return
|
||||||
|
end
|
||||||
if tempo then
|
if tempo then
|
||||||
-- shallow copy: the registry def is shared, only this playback is slowed
|
-- shallow copy: the registry def is shared, only this playback is slowed
|
||||||
local slowed = {}
|
local slowed = {}
|
||||||
@@ -317,24 +305,26 @@ function Music.reload()
|
|||||||
Music.stop()
|
Music.stop()
|
||||||
end
|
end
|
||||||
|
|
||||||
-- Ramp the current song's volume to silence, then stop it, mirroring the
|
function Music.fadeOut(control, pending)
|
||||||
-- Game Boy's audio fade-out (home/fade_audio.asm FadeOutAudio +
|
if not state.source then
|
||||||
-- home/audio.asm's .fadeOut): rAUDVOL's master volume steps 7 -> 0 in
|
Music.stop()
|
||||||
-- integer levels, one level every `control` frames, and the music stops
|
if pending then
|
||||||
-- when it reaches 0. `control` is the wAudioFadeOutControl value the ROM
|
Music.play(pending.data, pending.song, pending.loop, pending.ctx)
|
||||||
-- writes (oak_speech.asm sets 10 at the shrink beat -> 7*10 = 70 frames
|
end
|
||||||
-- to silence). Ticked once per frame from Music.update().
|
return
|
||||||
function Music.fadeOut(control)
|
end
|
||||||
if not state.source then Music.stop() return end
|
|
||||||
control = math.max(1, control or 10)
|
control = math.max(1, control or 10)
|
||||||
state.fade = {
|
state.fade = {
|
||||||
control = control,
|
control = control,
|
||||||
counter = control, -- frames until the next volume step
|
counter = control, -- frames until the next volume step
|
||||||
level = 7, -- current master-volume level (rAUDVOL nibble)
|
level = 7, -- current master-volume level (rAUDVOL nibble)
|
||||||
from = VOLUME * volumeScale, -- level-7 (full) source volume
|
from = VOLUME * volumeScale, -- level-7 (full) source volume
|
||||||
|
pending = pending,
|
||||||
}
|
}
|
||||||
end
|
end
|
||||||
|
|
||||||
|
Music.MAP_FADE = 10
|
||||||
|
|
||||||
-- the song a map should currently play, honoring the bike/surf overrides
|
-- the song a map should currently play, honoring the bike/surf overrides
|
||||||
local function effectiveMapSong(data, song)
|
local function effectiveMapSong(data, song)
|
||||||
if not song or not outdoorSongs(data)[song] then return song end
|
if not song or not outdoorSongs(data)[song] then return song end
|
||||||
@@ -351,14 +341,17 @@ end
|
|||||||
|
|
||||||
-- overworld map theme; onBike/surfing override outdoor themes with the
|
-- overworld map theme; onBike/surfing override outdoor themes with the
|
||||||
-- bike/surf songs and restore the map theme when they end
|
-- bike/surf songs and restore the map theme when they end
|
||||||
function Music.playMap(data, mapId, onBike, surfing)
|
function Music.playMap(data, mapId, onBike, surfing, fade)
|
||||||
local song = data and data.audio and data.audio.mapSongs
|
local song = data and data.audio and data.audio.mapSongs
|
||||||
and mapId and data.audio.mapSongs[mapId] or nil
|
and mapId and data.audio.mapSongs[mapId] or nil
|
||||||
state.mapSong = song
|
state.mapSong = song
|
||||||
state.onBike = not not onBike
|
state.onBike = not not onBike
|
||||||
state.surfing = not not surfing
|
state.surfing = not not surfing
|
||||||
local play = effectiveMapSong(data, song)
|
local play = effectiveMapSong(data, song)
|
||||||
if play then Music.play(data, play, nil, { reason = "map", mapId = mapId }) end
|
if play then
|
||||||
|
Music.play(data, play, nil,
|
||||||
|
{ reason = "map", mapId = mapId, fade = fade })
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
-- toggle the surf override mid-map (starting/ending a surf)
|
-- toggle the surf override mid-map (starting/ending a surf)
|
||||||
@@ -475,8 +468,13 @@ function Music.update(data)
|
|||||||
f.counter = f.control
|
f.counter = f.control
|
||||||
f.level = f.level - 1
|
f.level = f.level - 1
|
||||||
if f.level <= 0 then
|
if f.level <= 0 then
|
||||||
|
-- ..(home/fade_audio.asm ln 36)
|
||||||
state.fade = nil
|
state.fade = nil
|
||||||
|
local pending = f.pending
|
||||||
Music.stop()
|
Music.stop()
|
||||||
|
if pending then
|
||||||
|
Music.play(pending.data, pending.song, pending.loop, pending.ctx)
|
||||||
|
end
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
local vol = f.from * f.level / 7
|
local vol = f.from * f.level / 7
|
||||||
|
|||||||
@@ -0,0 +1,18 @@
|
|||||||
|
-- Generic process-lifecycle mod hooks so a platform-specific launcher
|
||||||
|
-- integration (a native shell embedding this engine, e.g. wrapping the
|
||||||
|
-- window in a platform UI) can live entirely in a mod instead of
|
||||||
|
-- hand-patching main.lua, which every other engine change also touches.
|
||||||
|
-- See docs/modding.md's "Process-lifecycle hooks" section.
|
||||||
|
local ModRuntime = require("src.mods.Runtime")
|
||||||
|
|
||||||
|
local PlatformHooks = {}
|
||||||
|
|
||||||
|
function PlatformHooks.update(game, dt)
|
||||||
|
return ModRuntime.call("core.update", function(g, d) g:update(d) end, game, dt)
|
||||||
|
end
|
||||||
|
|
||||||
|
function PlatformHooks.quitToLauncher(vanilla)
|
||||||
|
return ModRuntime.call("core.quit_to_launcher", vanilla)
|
||||||
|
end
|
||||||
|
|
||||||
|
return PlatformHooks
|
||||||
@@ -25,6 +25,18 @@ local function noEffect(data)
|
|||||||
return romText(data, "_ItemUseNoEffectText", "It won't have\nany effect.")
|
return romText(data, "_ItemUseNoEffectText", "It won't have\nany effect.")
|
||||||
end
|
end
|
||||||
|
|
||||||
|
local function registeredEffect(data, itemDef)
|
||||||
|
if not data or not itemDef or not itemDef.effect then
|
||||||
|
return nil
|
||||||
|
end
|
||||||
|
|
||||||
|
if not data.item_effects then
|
||||||
|
return nil
|
||||||
|
end
|
||||||
|
|
||||||
|
return data.item_effects[itemDef.effect]
|
||||||
|
end
|
||||||
|
|
||||||
local HEAL_AMOUNT = {
|
local HEAL_AMOUNT = {
|
||||||
POTION = 20, SUPER_POTION = 50, HYPER_POTION = 200,
|
POTION = 20, SUPER_POTION = 50, HYPER_POTION = 200,
|
||||||
FRESH_WATER = 50, SODA_POP = 60, LEMONADE = 80,
|
FRESH_WATER = 50, SODA_POP = 60, LEMONADE = 80,
|
||||||
@@ -72,7 +84,18 @@ function ItemEffects.healsHP(id)
|
|||||||
end
|
end
|
||||||
|
|
||||||
-- Does this item need a party-member target?
|
-- Does this item need a party-member target?
|
||||||
function ItemEffects.needsTarget(id, itemDef)
|
-- 'data' is optional for compat purposes; targeting falls back to itemDef/vanilla detection
|
||||||
|
function ItemEffects.needsTarget(id, itemDef, data)
|
||||||
|
if itemDef and itemDef.needsTarget ~= nil then
|
||||||
|
return itemDef.needsTarget
|
||||||
|
end
|
||||||
|
|
||||||
|
local effect = registeredEffect(data, itemDef)
|
||||||
|
|
||||||
|
if effect and effect.needsTarget ~= nil then
|
||||||
|
return effect.needsTarget
|
||||||
|
end
|
||||||
|
|
||||||
return HEAL_AMOUNT[id] or STATUS_HEAL[id] or id == "MAX_POTION"
|
return HEAL_AMOUNT[id] or STATUS_HEAL[id] or id == "MAX_POTION"
|
||||||
or id == "FULL_RESTORE" or id == "REVIVE" or id == "MAX_REVIVE"
|
or id == "FULL_RESTORE" or id == "REVIVE" or id == "MAX_REVIVE"
|
||||||
or id == "RARE_CANDY" or STONES[id]
|
or id == "RARE_CANDY" or STONES[id]
|
||||||
@@ -147,6 +170,28 @@ function ItemEffects.use(data, save, itemId, target, battle, moveIndex, ow)
|
|||||||
local itemDef = data.items[itemId]
|
local itemDef = data.items[itemId]
|
||||||
local name = itemDef and itemDef.name or itemId
|
local name = itemDef and itemDef.name or itemId
|
||||||
|
|
||||||
|
local effectDef = registeredEffect(data, itemDef)
|
||||||
|
if effectDef then
|
||||||
|
if battle and effectDef.battle == false then
|
||||||
|
return "failed", { notTime(data, save) }
|
||||||
|
end
|
||||||
|
|
||||||
|
if not battle and effectDef.field == false then
|
||||||
|
return "failed", { notTime(data, save) }
|
||||||
|
end
|
||||||
|
|
||||||
|
return effectDef.use({
|
||||||
|
data = data,
|
||||||
|
save = save,
|
||||||
|
itemId = itemId,
|
||||||
|
item = itemDef,
|
||||||
|
target = target,
|
||||||
|
battle = battle,
|
||||||
|
moveIndex = moveIndex,
|
||||||
|
overworld = ow,
|
||||||
|
})
|
||||||
|
end
|
||||||
|
|
||||||
-- ItemUseVitamin / ItemUsePPUp / ItemUseEvoStone / ItemUseCoinCase /
|
-- ItemUseVitamin / ItemUsePPUp / ItemUseEvoStone / ItemUseCoinCase /
|
||||||
-- ItemUseTMHM / ItemUseRepelCommon all refuse mid-battle
|
-- ItemUseTMHM / ItemUseRepelCommon all refuse mid-battle
|
||||||
-- (jp nz, ItemUseNotTime)
|
-- (jp nz, ItemUseNotTime)
|
||||||
|
|||||||
@@ -951,6 +951,16 @@ function Loader:load(data)
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
-- A manifest may name an env var that force-enables it regardless of a
|
||||||
|
-- saved disable in options.mods -- generic, not tied to any mod id, for
|
||||||
|
-- a mod (e.g. a native-launcher bridge) that cannot function disabled on
|
||||||
|
-- the one build where its env var is set.
|
||||||
|
for id, mod in pairs(self.mods) do
|
||||||
|
local envName = mod.manifest.force_enable_env
|
||||||
|
if envName and os.getenv(envName) == "1" then
|
||||||
|
self.disabled[id] = nil
|
||||||
|
end
|
||||||
|
end
|
||||||
for id, mod in pairs(self.mods) do
|
for id, mod in pairs(self.mods) do
|
||||||
mod.enabled = not self.disabled[id]
|
mod.enabled = not self.disabled[id]
|
||||||
mod.state = mod.enabled and "pending" or "disabled"
|
mod.state = mod.enabled and "pending" or "disabled"
|
||||||
|
|||||||
@@ -229,6 +229,7 @@ function Manifest.validate(raw, path)
|
|||||||
permissionSet = permissionSet,
|
permissionSet = permissionSet,
|
||||||
options_schema = optionalFile(raw.options_schema, "options_schema"),
|
options_schema = optionalFile(raw.options_schema, "options_schema"),
|
||||||
assets_transforms = optionalFile(raw.assets_transforms, "assets_transforms"),
|
assets_transforms = optionalFile(raw.assets_transforms, "assets_transforms"),
|
||||||
|
force_enable_env = optionalFile(raw.force_enable_env, "force_enable_env"),
|
||||||
path = path,
|
path = path,
|
||||||
raw = raw,
|
raw = raw,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -592,6 +592,13 @@ R.sprites = {
|
|||||||
image = f.path,
|
image = f.path,
|
||||||
frames = f.int(1),
|
frames = f.int(1),
|
||||||
walker = f.opt(f.bool),
|
walker = f.opt(f.bool),
|
||||||
|
-- Optional sheet geometry for mod actors. Defaults match the vanilla
|
||||||
|
-- 16x16 grounded walker; anchors are measured from each frame's
|
||||||
|
-- top-left in pixels (default: bottom-center).
|
||||||
|
frameWidth = f.opt(f.int(1)),
|
||||||
|
frameHeight = f.opt(f.int(1)),
|
||||||
|
anchorX = f.opt(f.num),
|
||||||
|
anchorY = f.opt(f.num),
|
||||||
trueColor = f.opt(f.bool),
|
trueColor = f.opt(f.bool),
|
||||||
-- Mod art can opt into an existing ROM sprite's Advanced-mode OBJ
|
-- Mod art can opt into an existing ROM sprite's Advanced-mode OBJ
|
||||||
-- palette assignment without claiming that the image itself came from
|
-- palette assignment without claiming that the image itself came from
|
||||||
|
|||||||
+4
-2
@@ -137,8 +137,10 @@ function Font.load(data)
|
|||||||
-- typo'd path degrades exactly like a missing page image does above.
|
-- typo'd path degrades exactly like a missing page image does above.
|
||||||
if type(def.ttf) == "table" then
|
if type(def.ttf) == "table" then
|
||||||
local file = def.ttf.file or Font.PLAINPIXEL
|
local file = def.ttf.file or Font.PLAINPIXEL
|
||||||
local ok, obj = pcall(love.graphics.newFont, file,
|
local size = def.ttf.size or Font.PLAINPIXEL_SIZE
|
||||||
def.ttf.size or Font.PLAINPIXEL_SIZE, "mono")
|
-- The game renders into a pixel-exact canvas, so keep the TTF rasterizer
|
||||||
|
-- on that same 1x grid instead of inheriting the window DPI on mobile.
|
||||||
|
local ok, obj = pcall(love.graphics.newFont, file, size, "mono", 1)
|
||||||
if ok and obj then
|
if ok and obj then
|
||||||
-- nearest keeps the pixel font crisp under the integer UI scale
|
-- nearest keeps the pixel font crisp under the integer UI scale
|
||||||
if obj.setFilter then pcall(obj.setFilter, obj, "nearest", "nearest") end
|
if obj.setFilter then pcall(obj.setFilter, obj, "nearest", "nearest") end
|
||||||
|
|||||||
+137
-34
@@ -1,7 +1,8 @@
|
|||||||
-- Overworld character sprites. A 12-tile sheet (16x96 PNG) holds 6 16x16
|
-- Overworld character sprites. The vanilla 12-tile sheet (16x96 PNG) holds
|
||||||
-- frames: stand down/up/left, walk down/up/left (data/sprites/facings.asm).
|
-- 6 16x16 frames: stand down/up/left, walk down/up/left
|
||||||
|
-- (data/sprites/facings.asm). Mod records may opt into another frame size
|
||||||
|
-- and anchor; the defaults below preserve the original grounded placement.
|
||||||
-- Right-facing frames are horizontal flips of the left frames.
|
-- Right-facing frames are horizontal flips of the left frames.
|
||||||
-- Sprites draw 4px above their cell, like the GB engine.
|
|
||||||
|
|
||||||
local Assets = require("src.render.Assets")
|
local Assets = require("src.render.Assets")
|
||||||
local PaletteFX = require("src.render.PaletteFX")
|
local PaletteFX = require("src.render.PaletteFX")
|
||||||
@@ -76,6 +77,58 @@ local WALK = { down = 3, up = 4, left = 5, right = 5 }
|
|||||||
SpriteRenderer.STAND = STAND
|
SpriteRenderer.STAND = STAND
|
||||||
SpriteRenderer.WALK = WALK
|
SpriteRenderer.WALK = WALK
|
||||||
|
|
||||||
|
-- Sprite records are anchored at the point where the actor stands in the
|
||||||
|
-- world. In the vanilla renderer that point is the bottom-center of a
|
||||||
|
-- 16x16 frame: the frame starts at (px, py - 4), so the ground point is
|
||||||
|
-- (px + 8, py + 12). Custom anchors are measured from the frame's top-left
|
||||||
|
-- in sheet pixels and may be fractional for a sub-pixel art style.
|
||||||
|
local DEFAULT_FRAME_WIDTH = 16
|
||||||
|
local DEFAULT_FRAME_HEIGHT = 16
|
||||||
|
local DEFAULT_ANCHOR_X = 8
|
||||||
|
local DEFAULT_ANCHOR_Y = 16
|
||||||
|
local WORLD_ANCHOR_X = 8
|
||||||
|
local WORLD_ANCHOR_Y = 12
|
||||||
|
SpriteRenderer.DEFAULT_FRAME_WIDTH = DEFAULT_FRAME_WIDTH
|
||||||
|
SpriteRenderer.DEFAULT_FRAME_HEIGHT = DEFAULT_FRAME_HEIGHT
|
||||||
|
SpriteRenderer.DEFAULT_ANCHOR_X = DEFAULT_ANCHOR_X
|
||||||
|
SpriteRenderer.DEFAULT_ANCHOR_Y = DEFAULT_ANCHOR_Y
|
||||||
|
|
||||||
|
local function finiteNumber(value)
|
||||||
|
if type(value) ~= "number" or value ~= value
|
||||||
|
or value == math.huge or value == -math.huge then
|
||||||
|
return nil
|
||||||
|
end
|
||||||
|
return value
|
||||||
|
end
|
||||||
|
|
||||||
|
local function positiveInteger(value, fallback)
|
||||||
|
value = finiteNumber(value)
|
||||||
|
if value and value >= 1 then return math.floor(value) end
|
||||||
|
return fallback
|
||||||
|
end
|
||||||
|
|
||||||
|
local function numberOr(value, fallback)
|
||||||
|
return finiteNumber(value) or fallback
|
||||||
|
end
|
||||||
|
|
||||||
|
local function pose(self, facing, walkPhase, stepFlip)
|
||||||
|
if self.frameCount <= 1 then return 0, false end
|
||||||
|
local frame = (self.def.walker and walkPhase == 1)
|
||||||
|
and WALK[facing] or STAND[facing]
|
||||||
|
frame = frame or 0
|
||||||
|
-- Preserve the old fallback for a short custom sheet whose pose table
|
||||||
|
-- names a frame it does not provide.
|
||||||
|
if not self.frames[frame] then frame = 0 end
|
||||||
|
local flip = false
|
||||||
|
if facing == "right" then
|
||||||
|
flip = true
|
||||||
|
elseif (facing == "down" or facing == "up")
|
||||||
|
and walkPhase == 1 and stepFlip then
|
||||||
|
flip = true
|
||||||
|
end
|
||||||
|
return frame, flip
|
||||||
|
end
|
||||||
|
|
||||||
-- seed: any stable per-instance value (e.g. an NPC's `id`) used to resolve
|
-- seed: any stable per-instance value (e.g. an NPC's `id`) used to resolve
|
||||||
-- RED++'s per-instance "random" OBP sentinel (PaletteFX.spriteObp)
|
-- RED++'s per-instance "random" OBP sentinel (PaletteFX.spriteObp)
|
||||||
function SpriteRenderer.new(spriteDef, seed)
|
function SpriteRenderer.new(spriteDef, seed)
|
||||||
@@ -83,14 +136,62 @@ function SpriteRenderer.new(spriteDef, seed)
|
|||||||
self.def = spriteDef
|
self.def = spriteDef
|
||||||
self.seed = seed
|
self.seed = seed
|
||||||
self.image = getImage(spriteDef.image)
|
self.image = getImage(spriteDef.image)
|
||||||
|
self.frameCount = positiveInteger(spriteDef.frames, 1)
|
||||||
|
self.frameWidth = positiveInteger(spriteDef.frameWidth, DEFAULT_FRAME_WIDTH)
|
||||||
|
self.frameHeight = positiveInteger(spriteDef.frameHeight, DEFAULT_FRAME_HEIGHT)
|
||||||
|
self.anchorX = numberOr(spriteDef.anchorX, self.frameWidth / 2)
|
||||||
|
self.anchorY = numberOr(spriteDef.anchorY, self.frameHeight)
|
||||||
local iw, ih = self.image:getDimensions()
|
local iw, ih = self.image:getDimensions()
|
||||||
self.frames = {}
|
self.frames = {}
|
||||||
for f = 0, spriteDef.frames - 1 do
|
for f = 0, self.frameCount - 1 do
|
||||||
self.frames[f] = love.graphics.newQuad(0, f * 16, 16, 16, iw, ih)
|
self.frames[f] = love.graphics.newQuad(0, f * self.frameHeight,
|
||||||
|
self.frameWidth, self.frameHeight,
|
||||||
|
iw, ih)
|
||||||
end
|
end
|
||||||
return self
|
return self
|
||||||
end
|
end
|
||||||
|
|
||||||
|
-- Return the sheet rectangle and top-left-relative anchor for a frame. The
|
||||||
|
-- result is a fresh table so a custom render pipeline may annotate it without
|
||||||
|
-- changing the renderer's shared definition.
|
||||||
|
function SpriteRenderer:getFrameGeometry(frame)
|
||||||
|
frame = math.floor(finiteNumber(frame) or 0)
|
||||||
|
if frame < 0 then frame = 0 end
|
||||||
|
if frame >= self.frameCount then frame = self.frameCount - 1 end
|
||||||
|
return {
|
||||||
|
frame = frame,
|
||||||
|
x = 0,
|
||||||
|
y = frame * self.frameHeight,
|
||||||
|
width = self.frameWidth,
|
||||||
|
height = self.frameHeight,
|
||||||
|
anchorX = self.anchorX,
|
||||||
|
anchorY = self.anchorY,
|
||||||
|
quad = self.frames[frame],
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Return the frame geometry selected by the ordinary 2D pose rules, plus the
|
||||||
|
-- horizontal mirror state that :draw applies. This is the supported hook for
|
||||||
|
-- custom render pipelines that need to draw actors with the same pose/flip.
|
||||||
|
function SpriteRenderer:getPoseGeometry(facing, walkPhase, stepFlip)
|
||||||
|
local frame, flip = pose(self, facing, walkPhase, stepFlip)
|
||||||
|
local geometry = self:getFrameGeometry(frame)
|
||||||
|
geometry.facing = facing
|
||||||
|
geometry.walkPhase = walkPhase
|
||||||
|
geometry.stepFlip = stepFlip
|
||||||
|
geometry.mirror = flip
|
||||||
|
return geometry
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Screen-space top-left for the actor's current world anchor. World-facing
|
||||||
|
-- effects such as fishing can use this instead of assuming a 16x16 frame.
|
||||||
|
function SpriteRenderer:getScreenOrigin(px, py, camX, camY)
|
||||||
|
local baseX = math.floor(px - camX) + WORLD_ANCHOR_X
|
||||||
|
local baseY = math.floor(py - camY) + WORLD_ANCHOR_Y
|
||||||
|
return math.floor(baseX - self.anchorX),
|
||||||
|
math.floor(baseY - self.anchorY)
|
||||||
|
end
|
||||||
|
|
||||||
-- The image this sprite would draw from right now: the plain sheet, or the
|
-- The image this sprite would draw from right now: the plain sheet, or the
|
||||||
-- OBP-recolored bake of it. Exposed so a render pipeline can texture its
|
-- OBP-recolored bake of it. Exposed so a render pipeline can texture its
|
||||||
-- own geometry from the very same image -- the geometry carries sheet pixel
|
-- own geometry from the very same image -- the geometry carries sheet pixel
|
||||||
@@ -125,27 +226,32 @@ end
|
|||||||
|
|
||||||
-- facing: down/up/left/right; walkPhase: 0 stand, 1 walk; flip: alternate
|
-- facing: down/up/left/right; walkPhase: 0 stand, 1 walk; flip: alternate
|
||||||
-- steps mirror the walk frame for up/down (GB uses OAM flip for this).
|
-- steps mirror the walk frame for up/down (GB uses OAM flip for this).
|
||||||
local function blitFrame(image, quad, x, y, flip, redraw)
|
local function blitFrame(image, quad, x, y, flip, redraw, frameWidth)
|
||||||
|
frameWidth = frameWidth or DEFAULT_FRAME_WIDTH
|
||||||
if flip then
|
if flip then
|
||||||
love.graphics.draw(image, quad, x + 16, y, 0, -1, 1)
|
love.graphics.draw(image, quad, x + frameWidth, y, 0, -1, 1)
|
||||||
if redraw then PaletteFX.markSpriteRedraw(image, quad, x + 16, y, -1) end
|
if redraw then
|
||||||
|
PaletteFX.markSpriteRedraw(image, quad, x + frameWidth, y, -1)
|
||||||
|
end
|
||||||
else
|
else
|
||||||
love.graphics.draw(image, quad, x, y)
|
love.graphics.draw(image, quad, x, y)
|
||||||
if redraw then PaletteFX.markSpriteRedraw(image, quad, x, y, 1) end
|
if redraw then PaletteFX.markSpriteRedraw(image, quad, x, y, 1) end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
-- topHalf blits only the upper 8 rows of the frame: FishingAnim overwrites the
|
-- topHalf blits everything above the bottom 8-pixel tile row: FishingAnim
|
||||||
-- bottom tile row of the standing frames with the fishing pose art, which the
|
-- overwrites that row of the standing frames with fishing pose art, which the
|
||||||
-- caller then draws itself through :drawTile (Player:draw, #384)
|
-- caller then draws itself through :drawTile (Player:draw, #384). Vanilla
|
||||||
|
-- frames therefore still draw 8 rows, while taller frames keep their larger
|
||||||
|
-- body and reserve only the overlay row.
|
||||||
function SpriteRenderer:draw(px, py, camX, camY, facing, walkPhase, stepFlip, topHalf)
|
function SpriteRenderer:draw(px, py, camX, camY, facing, walkPhase, stepFlip, topHalf)
|
||||||
local x = math.floor(px - camX)
|
local x, y = self:getScreenOrigin(px, py, camX, camY)
|
||||||
local y = math.floor(py - camY) - 4
|
|
||||||
local image = self.image
|
local image = self.image
|
||||||
local redraw = false
|
local redraw = false
|
||||||
-- full-color art claims its 16x16 cell out of the shade-remap pass
|
-- True-color sheets bypass every palette bake; the screen-space exemption
|
||||||
|
-- is recorded below once the final frame/height is known.
|
||||||
if self.def.trueColor then
|
if self.def.trueColor then
|
||||||
PaletteFX.markTrueColor(x, y, 16, 16)
|
image = self.image
|
||||||
elseif PaletteFX.usesGbcPack() then
|
elseif PaletteFX.usesGbcPack() then
|
||||||
-- RED++: the world canvas is already true-color (TileRenderer bakes
|
-- RED++: the world canvas is already true-color (TileRenderer bakes
|
||||||
-- terrain, this bakes the sprite) and the world pass runs unshaded
|
-- terrain, this bakes the sprite) and the world pass runs unshaded
|
||||||
@@ -177,31 +283,28 @@ function SpriteRenderer:draw(px, py, camX, camY, facing, walkPhase, stepFlip, to
|
|||||||
-- being colorized by the zone IS the point (#301).
|
-- being colorized by the zone IS the point (#301).
|
||||||
image = getObpImage(self.def.image, PaletteFX.dmgObj())
|
image = getObpImage(self.def.image, PaletteFX.dmgObj())
|
||||||
end
|
end
|
||||||
-- single-frame sprites (item balls, fossils...) have one fixed pose;
|
-- Single-frame sprites (item balls, fossils...) have one fixed pose;
|
||||||
-- still 3-frame sprites turn to face (the nurse at her machine,
|
-- still 3-frame sprites turn to face (the nurse at her machine,
|
||||||
-- facePlayer on STAY NPCs) but never show walk frames
|
-- facePlayer on STAY NPCs) but never show walk frames.
|
||||||
if self.def.frames <= 1 then
|
local frame, flip = pose(self, facing, walkPhase, stepFlip)
|
||||||
blitFrame(image, self.frames[0], x, y, false, redraw)
|
local quad = self.frames[frame]
|
||||||
return
|
local drawHeight = self.frameHeight
|
||||||
end
|
if topHalf and self.frameCount > 1 then
|
||||||
local frame = (self.def.walker and walkPhase == 1)
|
|
||||||
and WALK[facing] or STAND[facing]
|
|
||||||
local flip = false
|
|
||||||
if facing == "right" then
|
|
||||||
flip = true
|
|
||||||
elseif (facing == "down" or facing == "up") and walkPhase == 1 and stepFlip then
|
|
||||||
flip = true
|
|
||||||
end
|
|
||||||
local quad = self.frames[frame] or self.frames[0]
|
|
||||||
if topHalf then
|
|
||||||
self.halfFrames = self.halfFrames or {}
|
self.halfFrames = self.halfFrames or {}
|
||||||
if not self.halfFrames[frame] then
|
if not self.halfFrames[frame] then
|
||||||
local iw, ih = self.image:getDimensions()
|
local iw, ih = self.image:getDimensions()
|
||||||
self.halfFrames[frame] = love.graphics.newQuad(0, frame * 16, 16, 8, iw, ih)
|
local topHeight = math.max(1, self.frameHeight - math.min(8, self.frameHeight))
|
||||||
|
self.halfFrames[frame] = love.graphics.newQuad(
|
||||||
|
0, frame * self.frameHeight, self.frameWidth, topHeight, iw, ih)
|
||||||
end
|
end
|
||||||
quad = self.halfFrames[frame]
|
quad = self.halfFrames[frame]
|
||||||
|
drawHeight = math.max(1, self.frameHeight - math.min(8, self.frameHeight))
|
||||||
end
|
end
|
||||||
blitFrame(image, quad, x, y, flip, redraw)
|
-- Full-color art claims exactly the portion of the frame that was drawn.
|
||||||
|
if self.def.trueColor then
|
||||||
|
PaletteFX.markTrueColor(x, y, self.frameWidth, drawHeight)
|
||||||
|
end
|
||||||
|
blitFrame(image, quad, x, y, flip, redraw, self.frameWidth)
|
||||||
end
|
end
|
||||||
|
|
||||||
-- Blit a loose 16-wide fx tile at screen (x, y) wearing THIS sprite's OBJ
|
-- Blit a loose 16-wide fx tile at screen (x, y) wearing THIS sprite's OBJ
|
||||||
@@ -225,7 +328,7 @@ function SpriteRenderer:drawTile(path, x, y, flip)
|
|||||||
self.tileQuads = self.tileQuads or {}
|
self.tileQuads = self.tileQuads or {}
|
||||||
self.tileQuads[path] = self.tileQuads[path]
|
self.tileQuads[path] = self.tileQuads[path]
|
||||||
or love.graphics.newQuad(0, 0, iw, ih, iw, ih)
|
or love.graphics.newQuad(0, 0, iw, ih, iw, ih)
|
||||||
blitFrame(image, self.tileQuads[path], x, y, flip, redraw)
|
blitFrame(image, self.tileQuads[path], x, y, flip, redraw, iw)
|
||||||
end
|
end
|
||||||
|
|
||||||
return SpriteRenderer
|
return SpriteRenderer
|
||||||
|
|||||||
+13
-1
@@ -253,6 +253,18 @@ local function useOn(game, battle, id, target, list, moveIndex, picker)
|
|||||||
return
|
return
|
||||||
end
|
end
|
||||||
|
|
||||||
|
if result == "kept" then
|
||||||
|
if battle then
|
||||||
|
list:close()
|
||||||
|
showMessages(game, payload, function()
|
||||||
|
battle:itemUsed({})
|
||||||
|
end)
|
||||||
|
else
|
||||||
|
showMessages(game, payload, closePicker)
|
||||||
|
end
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
if result == "consumed" then
|
if result == "consumed" then
|
||||||
consume(game, id)
|
consume(game, id)
|
||||||
-- refresh counts in the list
|
-- refresh counts in the list
|
||||||
@@ -404,7 +416,7 @@ local function useItem(game, battle, id, list)
|
|||||||
showMessages(game, payload)
|
showMessages(game, payload)
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
if ItemEffects.needsTarget(id, def) and not ItemEffects.isBall(id) then
|
if ItemEffects.needsTarget(id, def, game.data) and not ItemEffects.isBall(id) then
|
||||||
-- TMs/HMs boot up and announce their move before the target picker
|
-- TMs/HMs boot up and announce their move before the target picker
|
||||||
-- (ItemUseTMHM: BootedUpTMText / BootedUpHMText + TeachMachineMoveText)
|
-- (ItemUseTMHM: BootedUpTMText / BootedUpHMText + TeachMachineMoveText)
|
||||||
if def and def.machine then
|
if def and def.machine then
|
||||||
|
|||||||
@@ -67,6 +67,7 @@ local function withdraw(game)
|
|||||||
game.stack:push(ListMenu.new(game,
|
game.stack:push(ListMenu.new(game,
|
||||||
Strings("BOX %d (WITHDRAW)", game.save.currentBox), items, {
|
Strings("BOX %d (WITHDRAW)", game.save.currentBox), items, {
|
||||||
noSound = true, -- PCMainMenu holds BIT_NO_MENU_BUTTON_SOUND (#570)
|
noSound = true, -- PCMainMenu holds BIT_NO_MENU_BUTTON_SOUND (#570)
|
||||||
|
kind = "pc_box_withdraw",
|
||||||
onChoose = function(item, list)
|
onChoose = function(item, list)
|
||||||
local mon = box[item.value]
|
local mon = box[item.value]
|
||||||
if not mon then return end
|
if not mon then return end
|
||||||
@@ -114,6 +115,7 @@ local function deposit(game)
|
|||||||
end
|
end
|
||||||
game.stack:push(ListMenu.new(game, "PARTY (DEPOSIT)", items, {
|
game.stack:push(ListMenu.new(game, "PARTY (DEPOSIT)", items, {
|
||||||
noSound = true, -- PCMainMenu holds BIT_NO_MENU_BUTTON_SOUND (#570)
|
noSound = true, -- PCMainMenu holds BIT_NO_MENU_BUTTON_SOUND (#570)
|
||||||
|
kind = "pc_box_deposit",
|
||||||
onChoose = function(item, list)
|
onChoose = function(item, list)
|
||||||
local mon = game.save.party[item.value]
|
local mon = game.save.party[item.value]
|
||||||
if not mon then return end
|
if not mon then return end
|
||||||
@@ -160,6 +162,7 @@ local function release(game)
|
|||||||
game.stack:push(ListMenu.new(game,
|
game.stack:push(ListMenu.new(game,
|
||||||
Strings("BOX %d (RELEASE)", game.save.currentBox), items, {
|
Strings("BOX %d (RELEASE)", game.save.currentBox), items, {
|
||||||
noSound = true, -- PCMainMenu holds BIT_NO_MENU_BUTTON_SOUND (#570)
|
noSound = true, -- PCMainMenu holds BIT_NO_MENU_BUTTON_SOUND (#570)
|
||||||
|
kind = "pc_box_release",
|
||||||
onChoose = function(_, list)
|
onChoose = function(_, list)
|
||||||
local mon = box[list.index]
|
local mon = box[list.index]
|
||||||
if not mon then return end
|
if not mon then return end
|
||||||
@@ -193,6 +196,7 @@ local function changeBox(game)
|
|||||||
end
|
end
|
||||||
game.stack:push(ListMenu.new(game, "CHANGE BOX", items, {
|
game.stack:push(ListMenu.new(game, "CHANGE BOX", items, {
|
||||||
noSound = true, -- PCMainMenu holds BIT_NO_MENU_BUTTON_SOUND (#570)
|
noSound = true, -- PCMainMenu holds BIT_NO_MENU_BUTTON_SOUND (#570)
|
||||||
|
kind = "pc_box_change",
|
||||||
onChoose = function(item, list)
|
onChoose = function(item, list)
|
||||||
-- the original asks BEFORE switching ("When you change a #MON
|
-- the original asks BEFORE switching ("When you change a #MON
|
||||||
-- BOX, data will be saved. OK?"); declining aborts the change
|
-- BOX, data will be saved. OK?"); declining aborts the change
|
||||||
|
|||||||
+65
-59
@@ -1,29 +1,5 @@
|
|||||||
-- Boot splash + attract movie, a faithful port of PlayIntro
|
-- ..(engine/movie/intro.asm ln 8)
|
||||||
-- (engine/movie/intro.asm) and AnimateShootingStar (engine/movie/splash.asm)
|
-- ..(engine/movie/splash.asm ln 27)
|
||||||
-- using the real extracted art (data/generated/field.lua `intro` manifest).
|
|
||||||
--
|
|
||||||
-- Three frame-counted phases:
|
|
||||||
-- 1. copyright card, 180 frames (intro.asm:311-312).
|
|
||||||
-- 2. shooting star: 64 frames of empty letterbox (intro.asm:323-324), then
|
|
||||||
-- the big star streaks down-left for 40 frames while the studio logo
|
|
||||||
-- sits centered in the letterbox band (the GAME FREAK logo + letter
|
|
||||||
-- row it replaces sat at (72,56)/(40,80); splash.asm:27-60, 211-228),
|
|
||||||
-- the logo flashes 3x10 frames (splash.asm:72-82), 4 waves of small
|
|
||||||
-- stars rain from the logo -- 6x24 frames, +1px every 3 frames, lower
|
|
||||||
-- star blinking (splash.asm:97-146, 163-209) -- and a 40 frame hold
|
|
||||||
-- (intro.asm:329-331).
|
|
||||||
-- 3. the Gengar/Nidorino fight (PlayIntroScene, intro.asm:23-141), played
|
|
||||||
-- from FIGHT_SCRIPT below: Music_IntroBattle starts, Gengar (56x56 BG
|
|
||||||
-- pose from a gengar_N.tilemap, at tile 13,7 = x104,y56) scrolls left
|
|
||||||
-- while Nidorino (48x48 OAM at x-8,y72) walks right, then the scripted
|
|
||||||
-- hip/hop hops, Gengar's raise + slash lunge, Nidorino's dodge leap,
|
|
||||||
-- retreat, crouch and final lunge, ending in a 24-frame fade to white
|
|
||||||
-- (GBFadeOutToWhite, home/fade.asm:26-40).
|
|
||||||
--
|
|
||||||
-- Any of A/B/START skips the whole movie (CheckForUserInterruption).
|
|
||||||
-- Pops itself and calls onDone() when finished or skipped. All art loads
|
|
||||||
-- through pcall and every missing graphic degrades to a text/rect
|
|
||||||
-- fallback, so the movie stays headless-safe.
|
|
||||||
|
|
||||||
local Font = require("src.render.Font")
|
local Font = require("src.render.Font")
|
||||||
local Music = require("src.core.Music")
|
local Music = require("src.core.Music")
|
||||||
@@ -76,15 +52,15 @@ local WAVE_FRAMES = 24 -- 8 substeps x 3 frames (splash.asm:186-209)
|
|||||||
local WAVES_END = WAVES_START + 6 * WAVE_FRAMES -- 4 waves + 2 empty
|
local WAVES_END = WAVES_START + 6 * WAVE_FRAMES -- 4 waves + 2 empty
|
||||||
local SPLASH_FRAMES = WAVES_END + 40 -- ld c, 40 (intro.asm:329-331)
|
local SPLASH_FRAMES = WAVES_END + 40 -- ld c, 40 (intro.asm:329-331)
|
||||||
|
|
||||||
-- logo 16x24 at grid (10,9), letters row at grid y=12 cols 6..15
|
-- ..(engine/movie/splash.asm ln 211)
|
||||||
-- (GameFreakLogoOAMData, splash.asm:211-228; screen = grid*8, OAM offsets
|
|
||||||
-- cancel)
|
|
||||||
local LOGO_X, LOGO_Y = 72, 56
|
local LOGO_X, LOGO_Y = 72, 56
|
||||||
|
local TEXT_X, TEXT_Y = 40, 80
|
||||||
|
|
||||||
|
-- ..(engine/movie/title.asm ln 390)
|
||||||
|
local COPY_PREFIX = { 0, 1, 2, 1, 3, 1, 4 }
|
||||||
|
local COPY_NINTENDO = { 5, 6, 7, 8, 9, 10 }
|
||||||
|
local COPY_CREATURES = { 11, 12, 13, 14, 15, 16, 17, 18 }
|
||||||
|
|
||||||
-- The studio logo (assets/logo/minilogo.png) stands in for both the
|
|
||||||
-- GAME FREAK logo and its letter row, so it gets the whole band between
|
|
||||||
-- the letterbox bars (y 32..112) down to where the star waves spawn
|
|
||||||
-- (y=88): fit it inside this box, centered, aspect preserved.
|
|
||||||
local STUDIO_BOX = { w = 128, h = 52, cx = 80, cy = 60 }
|
local STUDIO_BOX = { w = 128, h = 52, cx = 80, cy = 60 }
|
||||||
|
|
||||||
-- the 4 waves of small stars: screen X positions, all spawning at y=88
|
-- the 4 waves of small stars: screen X positions, all spawning at y=88
|
||||||
@@ -155,10 +131,19 @@ function IntroMovie.new(game, onDone)
|
|||||||
self.studio = intro.studio or {}
|
self.studio = intro.studio or {}
|
||||||
self.skipAll = intro.skip and true or false
|
self.skipAll = intro.skip and true or false
|
||||||
local function img(e) return tryImage(e and e.path) end
|
local function img(e) return tryImage(e and e.path) end
|
||||||
self.copyright = tryImage("assets/generated/title/copyright.png")
|
local titleCfg = game.data.field and game.data.field.title or {}
|
||||||
-- studio mark replaces the GAME FREAK logo + splash text entirely; the
|
self.copyright = img(titleCfg.copyright)
|
||||||
-- extracted logo stays as the fallback if the asset is missing
|
or tryImage("assets/generated/title/copyright.png")
|
||||||
self.studioLogo = tryImage(self.studio.logo or "assets/logo/minilogo.png")
|
self.copyQuads = {}
|
||||||
|
if self.copyright then
|
||||||
|
local iw, ih = self.copyright:getDimensions()
|
||||||
|
for t = 0, 18 do
|
||||||
|
self.copyQuads[t] = love.graphics.newQuad(t * 8, 0, 8, 8, iw, ih)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
self.gfInc = img(titleCfg.gamefreakInc)
|
||||||
|
or tryImage("assets/generated/title/gamefreak_inc.png")
|
||||||
|
self.studioLogo = tryImage(self.studio.logo)
|
||||||
if self.studioLogo then
|
if self.studioLogo then
|
||||||
self.studioLogo:setFilter("nearest", "nearest")
|
self.studioLogo:setFilter("nearest", "nearest")
|
||||||
local iw, ih = self.studioLogo:getDimensions()
|
local iw, ih = self.studioLogo:getDimensions()
|
||||||
@@ -306,24 +291,12 @@ function IntroMovie:drawSplash()
|
|||||||
if self.studioLogo then
|
if self.studioLogo then
|
||||||
love.graphics.draw(self.studioLogo, self.studioX, self.studioY,
|
love.graphics.draw(self.studioLogo, self.studioX, self.studioY,
|
||||||
0, self.studioScale, self.studioScale)
|
0, self.studioScale, self.studioScale)
|
||||||
elseif self.logo then
|
else
|
||||||
love.graphics.draw(self.logo, LOGO_X, LOGO_Y)
|
if self.logo then love.graphics.draw(self.logo, LOGO_X, LOGO_Y) end
|
||||||
|
if self.gfText then love.graphics.draw(self.gfText, TEXT_X, TEXT_Y) end
|
||||||
end
|
end
|
||||||
love.graphics.setColor(1, 1, 1, 1)
|
love.graphics.setColor(1, 1, 1, 1)
|
||||||
end
|
end
|
||||||
if t >= STAR_START and t < FLASH_START then
|
|
||||||
-- big star: from OAM (160,0) moving +4Y/-4X per frame
|
|
||||||
-- (GameFreakShootingStarOAMData + .bigStarLoop, splash.asm:32-60)
|
|
||||||
local n = t - STAR_START + 1
|
|
||||||
local sx, sy = 152 - 4 * n, -16 + 4 * n
|
|
||||||
if self.bigStar then
|
|
||||||
love.graphics.draw(self.bigStar, sx, sy)
|
|
||||||
else
|
|
||||||
love.graphics.setColor(0, 0, 0, 1)
|
|
||||||
love.graphics.rectangle("fill", sx + 6, sy + 6, 4, 4)
|
|
||||||
love.graphics.setColor(1, 1, 1, 1)
|
|
||||||
end
|
|
||||||
end
|
|
||||||
if t >= WAVES_START then
|
if t >= WAVES_START then
|
||||||
-- small stars: wave w spawns at y=88 every 24 frames, everything falls
|
-- small stars: wave w spawns at y=88 every 24 frames, everything falls
|
||||||
-- +1px per 3-frame substep until the wave loop ends; the lower star in
|
-- +1px per 3-frame substep until the wave loop ends; the lower star in
|
||||||
@@ -351,6 +324,18 @@ function IntroMovie:drawSplash()
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
drawBars()
|
drawBars()
|
||||||
|
if t >= STAR_START and t < FLASH_START then
|
||||||
|
-- ..(engine/movie/splash.asm ln 32)
|
||||||
|
local n = t - STAR_START + 1
|
||||||
|
local sx, sy = 152 - 4 * n, -16 + 4 * n
|
||||||
|
if self.bigStar then
|
||||||
|
love.graphics.draw(self.bigStar, sx, sy)
|
||||||
|
else
|
||||||
|
love.graphics.setColor(0, 0, 0, 1)
|
||||||
|
love.graphics.rectangle("fill", sx + 6, sy + 6, 4, 4)
|
||||||
|
love.graphics.setColor(1, 1, 1, 1)
|
||||||
|
end
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
function IntroMovie:drawFight()
|
function IntroMovie:drawFight()
|
||||||
@@ -379,17 +364,38 @@ function IntroMovie:drawFight()
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
function IntroMovie:drawCopyright()
|
||||||
|
if self.studio.card or self.studio.credit then
|
||||||
|
local card = self.studio.card or ""
|
||||||
|
local credit = self.studio.credit or ""
|
||||||
|
love.graphics.setColor(0, 0, 0, 1)
|
||||||
|
Font.draw(card, (160 - #card * 8) / 2, 64)
|
||||||
|
Font.draw(credit, (160 - #credit * 8) / 2, 80)
|
||||||
|
elseif self.copyright and self.gfInc then
|
||||||
|
local function row(seq, x, y)
|
||||||
|
for _, t in ipairs(seq) do
|
||||||
|
love.graphics.draw(self.copyright, self.copyQuads[t], x, y)
|
||||||
|
x = x + 8
|
||||||
|
end
|
||||||
|
end
|
||||||
|
for _, y in ipairs({ 56, 72, 88 }) do row(COPY_PREFIX, 16, y) end
|
||||||
|
row(COPY_NINTENDO, 80, 56)
|
||||||
|
row(COPY_CREATURES, 80, 72)
|
||||||
|
love.graphics.draw(self.gfInc, 80, 88)
|
||||||
|
else
|
||||||
|
love.graphics.setColor(0, 0, 0, 1)
|
||||||
|
Font.draw(Strings("Nintendo"), 80, 56)
|
||||||
|
Font.draw(Strings("Creatures inc."), 80, 72)
|
||||||
|
Font.draw(Strings("GAME FREAK inc."), 16, 88)
|
||||||
|
end
|
||||||
|
love.graphics.setColor(1, 1, 1, 1)
|
||||||
|
end
|
||||||
|
|
||||||
function IntroMovie:draw()
|
function IntroMovie:draw()
|
||||||
love.graphics.setColor(1, 1, 1, 1)
|
love.graphics.setColor(1, 1, 1, 1)
|
||||||
love.graphics.rectangle("fill", 0, 0, 160, 144)
|
love.graphics.rectangle("fill", 0, 0, 160, 144)
|
||||||
if self.phase == 1 then
|
if self.phase == 1 then
|
||||||
-- custom boot card (replaces the Nintendo / GAME FREAK copyright
|
self:drawCopyright()
|
||||||
-- card; no (c) glyph in the charmap, keep it ASCII-safe)
|
|
||||||
love.graphics.setColor(0, 0, 0, 1)
|
|
||||||
local credit = self.studio.credit or Strings("bois club")
|
|
||||||
Font.draw("2026", (160 - 4 * 8) / 2, 48)
|
|
||||||
Font.draw(credit, (160 - #credit * 8) / 2, 64)
|
|
||||||
Font.draw("bryanthaboi", (160 - 11 * 8) / 2, 80)
|
|
||||||
elseif self.phase == 2 then
|
elseif self.phase == 2 then
|
||||||
self:drawSplash()
|
self:drawSplash()
|
||||||
else
|
else
|
||||||
|
|||||||
@@ -51,6 +51,7 @@ function ListMenu.new(game, title, items, opts)
|
|||||||
local self = setmetatable({}, ListMenu)
|
local self = setmetatable({}, ListMenu)
|
||||||
self.game = game
|
self.game = game
|
||||||
self.title = title
|
self.title = title
|
||||||
|
self.kind = opts.kind or title
|
||||||
self.items = items
|
self.items = items
|
||||||
self.index = 1
|
self.index = 1
|
||||||
self.scroll = 0
|
self.scroll = 0
|
||||||
|
|||||||
@@ -72,6 +72,7 @@ end
|
|||||||
local function withdraw(game)
|
local function withdraw(game)
|
||||||
local pc = game.save.pcItems
|
local pc = game.save.pcItems
|
||||||
game.stack:push(ListMenu.new(game, "WITHDRAW ITEM", buildItems(game, pc), {
|
game.stack:push(ListMenu.new(game, "WITHDRAW ITEM", buildItems(game, pc), {
|
||||||
|
kind = "pc_item_withdraw",
|
||||||
messageBox = true,
|
messageBox = true,
|
||||||
noSound = true, -- PlayerPCMenu holds BIT_NO_MENU_BUTTON_SOUND (#570)
|
noSound = true, -- PlayerPCMenu holds BIT_NO_MENU_BUTTON_SOUND (#570)
|
||||||
onChoose = function(item, list)
|
onChoose = function(item, list)
|
||||||
@@ -110,6 +111,7 @@ local function deposit(game)
|
|||||||
if not Bag.isBadge(id) then depositable[id] = count end
|
if not Bag.isBadge(id) then depositable[id] = count end
|
||||||
end
|
end
|
||||||
game.stack:push(ListMenu.new(game, "DEPOSIT ITEM", buildItems(game, depositable), {
|
game.stack:push(ListMenu.new(game, "DEPOSIT ITEM", buildItems(game, depositable), {
|
||||||
|
kind = "pc_item_deposit",
|
||||||
messageBox = true,
|
messageBox = true,
|
||||||
noSound = true, -- PlayerPCMenu holds BIT_NO_MENU_BUTTON_SOUND (#570)
|
noSound = true, -- PlayerPCMenu holds BIT_NO_MENU_BUTTON_SOUND (#570)
|
||||||
onChoose = function(item, list)
|
onChoose = function(item, list)
|
||||||
@@ -131,6 +133,7 @@ end
|
|||||||
local function toss(game)
|
local function toss(game)
|
||||||
local pc = game.save.pcItems
|
local pc = game.save.pcItems
|
||||||
game.stack:push(ListMenu.new(game, "TOSS ITEM", buildItems(game, pc), {
|
game.stack:push(ListMenu.new(game, "TOSS ITEM", buildItems(game, pc), {
|
||||||
|
kind = "pc_item_toss",
|
||||||
messageBox = true,
|
messageBox = true,
|
||||||
noSound = true, -- PlayerPCMenu holds BIT_NO_MENU_BUTTON_SOUND (#570)
|
noSound = true, -- PlayerPCMenu holds BIT_NO_MENU_BUTTON_SOUND (#570)
|
||||||
onChoose = function(item, list)
|
onChoose = function(item, list)
|
||||||
|
|||||||
+169
-46
@@ -103,7 +103,31 @@ local YELLOW_CYCLE_SPECIES = {
|
|||||||
"JIGGLYPUFF", "MEOWTH", "PSYDUCK", "VULPIX", "ABRA",
|
"JIGGLYPUFF", "MEOWTH", "PSYDUCK", "VULPIX", "ABRA",
|
||||||
"GROWLITHE", "CUBONE", "GASTLY", "HITMONLEE", "SNORLAX", "DRAGONITE",
|
"GROWLITHE", "CUBONE", "GASTLY", "HITMONLEE", "SNORLAX", "DRAGONITE",
|
||||||
}
|
}
|
||||||
local CYCLE_FRAMES = 240 -- the original waits ~4s between picks
|
-- ..(engine/movie/title.asm ln 227)
|
||||||
|
local HOLD_FRAMES = 200
|
||||||
|
local STARTERS = { CHARMANDER = true, SQUIRTLE = true, BULBASAUR = true }
|
||||||
|
|
||||||
|
-- ..(engine/movie/title2.asm ln 13)
|
||||||
|
local function scrollFrames(steps, offset)
|
||||||
|
local frames = {}
|
||||||
|
for _, step in ipairs(steps) do
|
||||||
|
for _ = 1, step[2] do
|
||||||
|
frames[#frames + 1] = offset
|
||||||
|
offset = offset - step[1]
|
||||||
|
end
|
||||||
|
end
|
||||||
|
return frames
|
||||||
|
end
|
||||||
|
local OUT_FRAMES = scrollFrames(
|
||||||
|
{ { 1, 2 }, { 2, 2 }, { 3, 2 }, { 4, 2 }, { 5, 2 }, { 6, 2 },
|
||||||
|
{ 8, 3 }, { 9, 3 } }, 0)
|
||||||
|
local IN_FRAMES = scrollFrames(
|
||||||
|
{ { 10, 2 }, { 9, 4 }, { 8, 4 }, { 6, 3 }, { 5, 2 }, { 3, 1 },
|
||||||
|
{ 1, 1 } }, 120)
|
||||||
|
|
||||||
|
-- ..(engine/movie/title2.asm ln 85)
|
||||||
|
local BALL_FRAMES = { 97, 95, 94, 93, 92, 93, 94, 95, 97, 100 }
|
||||||
|
local BALL_REST = 100
|
||||||
|
|
||||||
local function tryImage(path)
|
local function tryImage(path)
|
||||||
if not path then return nil end
|
if not path then return nil end
|
||||||
@@ -178,6 +202,27 @@ function TitleState.new(game, opts)
|
|||||||
or "assets/generated/title/red_version.png")
|
or "assets/generated/title/red_version.png")
|
||||||
self.player = tryImage(imagePath(title.player)
|
self.player = tryImage(imagePath(title.player)
|
||||||
or "assets/generated/title/player.png")
|
or "assets/generated/title/player.png")
|
||||||
|
-- ..(engine/movie/title2.asm ln 85)
|
||||||
|
if self.player then
|
||||||
|
local pw, ph = self.player:getDimensions()
|
||||||
|
self.ballQuad = love.graphics.newQuad(0, 16, 8, 8, pw, ph)
|
||||||
|
self.playerQuads = {
|
||||||
|
{ love.graphics.newQuad(0, 0, pw, 16, pw, ph), 0, 0 },
|
||||||
|
{ love.graphics.newQuad(8, 16, pw - 8, 8, pw, ph), 8, 16 },
|
||||||
|
{ love.graphics.newQuad(0, 24, pw, ph - 24, pw, ph), 0, 24 },
|
||||||
|
}
|
||||||
|
end
|
||||||
|
self.copyImg = tryImage(imagePath(title.copyright)
|
||||||
|
or "assets/generated/title/copyright.png")
|
||||||
|
self.copyQuads = {}
|
||||||
|
if self.copyImg then
|
||||||
|
local iw, ih = self.copyImg:getDimensions()
|
||||||
|
for t = 0, 18 do
|
||||||
|
self.copyQuads[t] = love.graphics.newQuad(t * 8, 0, 8, 8, iw, ih)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
self.gfInc = tryImage(imagePath(title.gamefreakInc)
|
||||||
|
or "assets/generated/title/gamefreak_inc.png")
|
||||||
self.blue = GameVersion.isBlue()
|
self.blue = GameVersion.isBlue()
|
||||||
self.yellow = GameVersion.isYellow()
|
self.yellow = GameVersion.isYellow()
|
||||||
or title.layout == "yellow_pikachu"
|
or title.layout == "yellow_pikachu"
|
||||||
@@ -203,7 +248,10 @@ function TitleState.new(game, opts)
|
|||||||
self.blinkTimer = 0
|
self.blinkTimer = 0
|
||||||
self.blinkAt = nil
|
self.blinkAt = nil
|
||||||
else
|
else
|
||||||
self.phase = "loop"
|
-- ..(engine/movie/title.asm ln 28)
|
||||||
|
self.scy = 0x40
|
||||||
|
self.phase = "drop"
|
||||||
|
self.dropStep, self.dropLeft = 1, nil
|
||||||
self.showBubble = true
|
self.showBubble = true
|
||||||
end
|
end
|
||||||
local defaultCycle = self.yellowLayout and { "PIKACHU" }
|
local defaultCycle = self.yellowLayout and { "PIKACHU" }
|
||||||
@@ -216,14 +264,15 @@ function TitleState.new(game, opts)
|
|||||||
self.cycleIndex = 1
|
self.cycleIndex = 1
|
||||||
self.timer = 0
|
self.timer = 0
|
||||||
self.blink = 0
|
self.blink = 0
|
||||||
|
self.scrollPhase = "hold"
|
||||||
|
self.scrollFrame = 1
|
||||||
|
self.monOffset = 0
|
||||||
|
self.ballY = BALL_REST
|
||||||
return self
|
return self
|
||||||
end
|
end
|
||||||
|
|
||||||
function TitleState:enter()
|
function TitleState:enter()
|
||||||
-- Yellow defers the title theme until after the logo drop and
|
if self.phase ~= "loop" then return end
|
||||||
-- Pikachu's cry (title.asm plays MUSIC_TITLE_SCREEN only after
|
|
||||||
-- WaitForSoundToFinish on PikachuCry1)
|
|
||||||
if self.yellowLayout then return end
|
|
||||||
self:startMusic()
|
self:startMusic()
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -240,6 +289,11 @@ end
|
|||||||
local DROP_STEPS = {
|
local DROP_STEPS = {
|
||||||
{ -4, 16 }, { 3, 4 }, { -3, 4 }, { 2, 2 }, { -2, 2 }, { 1, 2 }, { -1, 2 },
|
{ -4, 16 }, { 3, 4 }, { -3, 4 }, { 2, 2 }, { -2, 2 }, { 1, 2 }, { -1, 2 },
|
||||||
}
|
}
|
||||||
|
local SETTLE_FRAMES = 36
|
||||||
|
|
||||||
|
-- ..(engine/movie/title.asm ln 201)
|
||||||
|
local RIBBON_FRAMES = {}
|
||||||
|
for offset = 112, 4, -4 do RIBBON_FRAMES[#RIBBON_FRAMES + 1] = offset end
|
||||||
|
|
||||||
-- the boot cinematic up to the interactive loop; one call per frame
|
-- the boot cinematic up to the interactive loop; one call per frame
|
||||||
function TitleState:updateSequence()
|
function TitleState:updateSequence()
|
||||||
@@ -263,12 +317,23 @@ function TitleState:updateSequence()
|
|||||||
self.dropLeft = nil
|
self.dropLeft = nil
|
||||||
end
|
end
|
||||||
elseif self.phase == "settle" then
|
elseif self.phase == "settle" then
|
||||||
-- ld c, 36 / DelayFrames, then the whoosh and the bubble
|
|
||||||
self.timer = self.timer + 1
|
self.timer = self.timer + 1
|
||||||
if self.timer >= 36 then
|
if self.timer >= SETTLE_FRAMES then
|
||||||
Sound.play(data, "Intro_Whoosh")
|
Sound.play(data, "Intro_Whoosh")
|
||||||
self.showBubble = true
|
self.showBubble = true
|
||||||
self.phase = "bubble"
|
self.phase = self.yellowLayout and "bubble" or "ribbon"
|
||||||
|
self.ribbonOffset = RIBBON_FRAMES[1]
|
||||||
|
self.timer = 0
|
||||||
|
end
|
||||||
|
elseif self.phase == "ribbon" then
|
||||||
|
self.timer = self.timer + 1
|
||||||
|
local offset = RIBBON_FRAMES[self.timer + 1]
|
||||||
|
if offset then
|
||||||
|
self.ribbonOffset = offset
|
||||||
|
else
|
||||||
|
self.ribbonOffset = nil
|
||||||
|
self:startMusic()
|
||||||
|
self.phase = "loop"
|
||||||
self.timer = 0
|
self.timer = 0
|
||||||
end
|
end
|
||||||
elseif self.phase == "bubble" then
|
elseif self.phase == "bubble" then
|
||||||
@@ -432,12 +497,63 @@ function TitleState:openMenu()
|
|||||||
game.stack:push(menu)
|
game.stack:push(menu)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
-- ..(engine/movie/title.asm ln 271)
|
||||||
|
function TitleState:pickNewMon()
|
||||||
|
if #self.cycleSpecies < 2 then return end
|
||||||
|
local pick = self.cycleIndex
|
||||||
|
while pick == self.cycleIndex do
|
||||||
|
pick = love.math.random(1, #self.cycleSpecies)
|
||||||
|
end
|
||||||
|
self.cycleIndex = pick
|
||||||
|
end
|
||||||
|
|
||||||
|
function TitleState:setCyclePhase(phase)
|
||||||
|
self.scrollPhase = phase
|
||||||
|
self.scrollFrame = 1
|
||||||
|
self.timer = 0
|
||||||
|
if phase == "in" then
|
||||||
|
self:pickNewMon()
|
||||||
|
self.monOffset = IN_FRAMES[1]
|
||||||
|
elseif phase == "out" then
|
||||||
|
self.monOffset = OUT_FRAMES[1]
|
||||||
|
elseif phase == "ball" then
|
||||||
|
self.ballY = BALL_FRAMES[1]
|
||||||
|
else
|
||||||
|
self.monOffset = 0
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
function TitleState:updateCycle()
|
||||||
|
local phase = self.scrollPhase
|
||||||
|
if phase == "hold" then
|
||||||
|
if self.timer >= HOLD_FRAMES then self:setCyclePhase("out") end
|
||||||
|
return
|
||||||
|
end
|
||||||
|
local frames = phase == "out" and OUT_FRAMES
|
||||||
|
or phase == "ball" and BALL_FRAMES or IN_FRAMES
|
||||||
|
self.scrollFrame = self.scrollFrame + 1
|
||||||
|
local value = frames[self.scrollFrame]
|
||||||
|
if value then
|
||||||
|
if phase == "ball" then self.ballY = value else self.monOffset = value end
|
||||||
|
return
|
||||||
|
end
|
||||||
|
if phase == "out" then
|
||||||
|
-- ..(engine/movie/title.asm ln 235)
|
||||||
|
self:setCyclePhase(
|
||||||
|
STARTERS[self.cycleSpecies[self.cycleIndex]] and "ball" or "in")
|
||||||
|
elseif phase == "ball" then
|
||||||
|
self:setCyclePhase("in")
|
||||||
|
else
|
||||||
|
self:setCyclePhase("hold")
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
function TitleState:update(dt)
|
function TitleState:update(dt)
|
||||||
|
if self.phase ~= "loop" then
|
||||||
|
self:updateSequence()
|
||||||
|
return
|
||||||
|
end
|
||||||
if self.yellowLayout then
|
if self.yellowLayout then
|
||||||
if self.phase ~= "loop" then
|
|
||||||
self:updateSequence()
|
|
||||||
return -- input is ignored until the cinematic lands (title.asm)
|
|
||||||
end
|
|
||||||
self:updateBlink()
|
self:updateBlink()
|
||||||
local input = self.game.input
|
local input = self.game.input
|
||||||
if input:wasPressed("start") or input:wasPressed("a") then
|
if input:wasPressed("start") or input:wasPressed("a") then
|
||||||
@@ -452,21 +568,7 @@ function TitleState:update(dt)
|
|||||||
end
|
end
|
||||||
self.timer = self.timer + 1
|
self.timer = self.timer + 1
|
||||||
self.blink = (self.blink + 1) % 60
|
self.blink = (self.blink + 1) % 60
|
||||||
if not self.yellowLayout and self.timer >= CYCLE_FRAMES then
|
self:updateCycle()
|
||||||
self.timer = 0
|
|
||||||
-- random pick that never repeats the current one
|
|
||||||
if #self.cycleSpecies > 1 then
|
|
||||||
local pick = self.cycleIndex
|
|
||||||
while pick == self.cycleIndex do
|
|
||||||
pick = love.math.random(1, #self.cycleSpecies)
|
|
||||||
end
|
|
||||||
self.cycleIndex = pick
|
|
||||||
end
|
|
||||||
self.slideIn = 20 -- TitleScreenScrollInMon slides the pic in
|
|
||||||
end
|
|
||||||
if self.slideIn and self.slideIn > 0 then
|
|
||||||
self.slideIn = self.slideIn - 1
|
|
||||||
end
|
|
||||||
local input = self.game.input
|
local input = self.game.input
|
||||||
if input:wasPressed("start") or input:wasPressed("a") then
|
if input:wasPressed("start") or input:wasPressed("a") then
|
||||||
-- the title mon cries when you leave the title (.finishedWaiting);
|
-- the title mon cries when you leave the title (.finishedWaiting);
|
||||||
@@ -478,15 +580,14 @@ function TitleState:update(dt)
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
-- The original tilemap (engine/movie/title.asm): logo at tile (2,1),
|
-- ..(engine/movie/title.asm ln 28)
|
||||||
-- the version ribbon at (7,8), Red's title art as OAM at px (82,80),
|
|
||||||
-- the title mon in the 7x7 box at tile (5,10), copyright on row 17.
|
|
||||||
-- Yellow (title_yellow.asm): logo (2,1), speech bubble (6,4), Pikachu
|
|
||||||
-- (4,8) 12x9 -- no version ribbon, no cycling mon, no Red OAM.
|
|
||||||
function TitleState:draw()
|
function TitleState:draw()
|
||||||
love.graphics.setColor(1, 1, 1, 1)
|
love.graphics.setColor(1, 1, 1, 1)
|
||||||
love.graphics.rectangle("fill", 0, 0, 160, 144)
|
love.graphics.rectangle("fill", 0, 0, 160, 144)
|
||||||
local scrollY = self.yellowLayout and -(self.scy or 0) or 0
|
local scrollY = -(self.scy or 0)
|
||||||
|
-- ..(engine/movie/title.asm ln 28)
|
||||||
|
local preRibbon = not self.yellowLayout
|
||||||
|
and (self.phase == "drop" or self.phase == "settle")
|
||||||
if self.logo then
|
if self.logo then
|
||||||
love.graphics.draw(self.logo, 16, 8 + scrollY)
|
love.graphics.draw(self.logo, 16, 8 + scrollY)
|
||||||
else
|
else
|
||||||
@@ -514,27 +615,29 @@ function TitleState:draw()
|
|||||||
-- Yellow's Version_GFX slot holds a leftover "Blue Version" ribbon
|
-- Yellow's Version_GFX slot holds a leftover "Blue Version" ribbon
|
||||||
-- (pokeyellow gfx/title/blue_version.png, unreferenced by title code);
|
-- (pokeyellow gfx/title/blue_version.png, unreferenced by title code);
|
||||||
-- the Yellow fallback layout draws no ribbon at all.
|
-- the Yellow fallback layout draws no ribbon at all.
|
||||||
if self.version and not self.yellow then
|
if self.version and not self.yellow and not preRibbon then
|
||||||
local iw, ih = self.version:getDimensions()
|
local iw, ih = self.version:getDimensions()
|
||||||
|
local rx = self.ribbonOffset or 0
|
||||||
if self.versionFull then
|
if self.versionFull then
|
||||||
-- a continuous ribbon (versionRibbon) centers as one piece
|
-- a continuous ribbon (versionRibbon) centers as one piece
|
||||||
love.graphics.draw(self.version, math.floor((160 - iw) / 2), 64)
|
love.graphics.draw(self.version, math.floor((160 - iw) / 2) + rx, 64)
|
||||||
elseif self.blue then
|
elseif self.blue then
|
||||||
love.graphics.draw(self.version,
|
love.graphics.draw(self.version,
|
||||||
love.graphics.newQuad(0, 0, 64, 8, iw, ih), 56, 64)
|
love.graphics.newQuad(0, 0, 64, 8, iw, ih), 56 + rx, 64)
|
||||||
else
|
else
|
||||||
love.graphics.draw(self.version,
|
love.graphics.draw(self.version,
|
||||||
love.graphics.newQuad(0, 0, 16, 8, iw, ih), 56, 64)
|
love.graphics.newQuad(0, 0, 16, 8, iw, ih), 56 + rx, 64)
|
||||||
love.graphics.draw(self.version,
|
love.graphics.draw(self.version,
|
||||||
love.graphics.newQuad(40, 0, 40, 8, iw, ih), 80, 64)
|
love.graphics.newQuad(40, 0, 40, 8, iw, ih), 80 + rx, 64)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
local sprite, spriteTrueColor = self:currentSprite()
|
local sprite, spriteTrueColor
|
||||||
|
if self.scrollPhase ~= "ball" then
|
||||||
|
sprite, spriteTrueColor = self:currentSprite()
|
||||||
|
end
|
||||||
if sprite then
|
if sprite then
|
||||||
local w, h = sprite:getDimensions()
|
local w, h = sprite:getDimensions()
|
||||||
local slide = (self.slideIn or 0) * 8 -- scroll in from the right
|
local x = 40 + math.floor((56 - w) / 2) + self.monOffset
|
||||||
-- bottom-aligned and centered in the (5,10)-(11,16) tile box
|
|
||||||
local x = 40 + math.floor((56 - w) / 2) + slide
|
|
||||||
local y = 136 - h
|
local y = 136 - h
|
||||||
love.graphics.draw(sprite, x, y)
|
love.graphics.draw(sprite, x, y)
|
||||||
-- a full-color mon keeps its own palette through the SGB pass, minus
|
-- a full-color mon keeps its own palette through the SGB pass, minus
|
||||||
@@ -550,13 +653,33 @@ function TitleState:draw()
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
-- Red is OAM in the original: he draws over the mon's box edge
|
-- Red is OAM in the original: he draws over the mon's box edge
|
||||||
if self.player then
|
if self.playerQuads then
|
||||||
|
for _, part in ipairs(self.playerQuads) do
|
||||||
|
love.graphics.draw(self.player, part[1], 82 + part[2], 80 + part[3])
|
||||||
|
end
|
||||||
|
love.graphics.draw(self.player, self.ballQuad, 82, self.ballY)
|
||||||
|
elseif self.player then
|
||||||
love.graphics.draw(self.player, 82, 80)
|
love.graphics.draw(self.player, 82, 80)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
self:drawCopyright(136 + (preRibbon and 0 or scrollY))
|
||||||
|
end
|
||||||
|
|
||||||
|
-- ..(engine/movie/title.asm ln 117)
|
||||||
|
local COPY_PREFIX = { 0, 1, 2, 1, 3, 1, 4 }
|
||||||
|
|
||||||
|
function TitleState:drawCopyright(y)
|
||||||
|
if not self.title.copyrightText and self.copyImg and self.gfInc then
|
||||||
|
local x = 16
|
||||||
|
for _, t in ipairs(COPY_PREFIX) do
|
||||||
|
love.graphics.draw(self.copyImg, self.copyQuads[t], x, y)
|
||||||
|
x = x + 8
|
||||||
|
end
|
||||||
|
love.graphics.draw(self.gfInc, x, y)
|
||||||
|
return
|
||||||
|
end
|
||||||
love.graphics.setColor(0, 0, 0, 1)
|
love.graphics.setColor(0, 0, 0, 1)
|
||||||
Font.draw(self.title.copyrightText or Strings("2026 bois club games"),
|
Font.draw(self.title.copyrightText or Strings("GAME FREAK inc."), 16, y)
|
||||||
1, 136 + scrollY)
|
|
||||||
love.graphics.setColor(1, 1, 1, 1)
|
love.graphics.setColor(1, 1, 1, 1)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|||||||
@@ -37,6 +37,10 @@ local mapScripts -- registry of hand-ported map scripts
|
|||||||
local COMPASS = { up = "north", down = "south", left = "west", right = "east" }
|
local COMPASS = { up = "north", down = "south", left = "west", right = "east" }
|
||||||
local DIRVEC = { up = { 0, -1 }, down = { 0, 1 }, left = { -1, 0 }, right = { 1, 0 } }
|
local DIRVEC = { up = { 0, -1 }, down = { 0, 1 }, left = { -1, 0 }, right = { 1, 0 } }
|
||||||
|
|
||||||
|
-- pokered's wNumberOfNoRandomBattleStepsLeft: three completed steps
|
||||||
|
-- after a wild battle before another random battle can start.
|
||||||
|
local WILD_ENCOUNTER_GRACE_STEPS = 3
|
||||||
|
|
||||||
-- Fly animation coord paths (engine/overworld/player_animations.asm):
|
-- Fly animation coord paths (engine/overworld/player_animations.asm):
|
||||||
-- y/x pairs in GB screen pixels, one pair every 3 frames (DoFlyAnimation's
|
-- y/x pairs in GB screen pixels, one pair every 3 frames (DoFlyAnimation's
|
||||||
-- Delay3). The port anchors a path on the player's own position instead
|
-- Delay3). The port anchors a path on the player's own position instead
|
||||||
@@ -81,8 +85,10 @@ local HEAL_FLASH_MAP = { [0] = 0, [1] = 2, [2] = 1, [3] = 3 }
|
|||||||
-- above (screen = tile*8 + pixel - 8/16), measured against the player
|
-- above (screen = tile*8 + pixel - 8/16), measured against the player
|
||||||
-- sprite's fixed screen spot: ResetPlayerSpriteData parks it at $3c/$40
|
-- sprite's fixed screen spot: ResetPlayerSpriteData parks it at $3c/$40
|
||||||
-- (home/reset_player_sprite.asm), i.e. screen (64,60). So what ports over
|
-- (home/reset_player_sprite.asm), i.e. screen (64,60). So what ports over
|
||||||
-- is the delta from the sprite's top-left, which SpriteRenderer:draw puts at
|
-- is the delta from the sprite's top-left, which the vanilla
|
||||||
-- (px, py - 4). `tile` indexes the three stacked 8x8 tiles of
|
-- SpriteRenderer:draw puts at (px, py - 4); custom frame anchors move that
|
||||||
|
-- origin while keeping these offsets frame-relative. `tile` indexes the
|
||||||
|
-- three stacked 8x8 tiles of
|
||||||
-- assets/generated/fx/fishing_rod.png: FishingRodOAM only ever draws $fd
|
-- assets/generated/fx/fishing_rod.png: FishingRodOAM only ever draws $fd
|
||||||
-- (row 0, up/down) and $fe (row 1, left/right), and RIGHT is the LEFT tile
|
-- (row 0, up/down) and $fe (row 1, left/right), and RIGHT is the LEFT tile
|
||||||
-- x-flipped. Blitting the whole 8x24 sheet is what drew the rod as a
|
-- x-flipped. Blitting the whole 8x24 sheet is what drew the rod as a
|
||||||
@@ -224,6 +230,8 @@ function OverworldState:enter(mapId, x, y, facing, opts)
|
|||||||
-- a fresh entry, or a stale flag can freeze player input forever
|
-- a fresh entry, or a stale flag can freeze player input forever
|
||||||
self.engaging = false
|
self.engaging = false
|
||||||
self.emote = nil
|
self.emote = nil
|
||||||
|
-- volatile WRAM state in pokered; never serialize across save/load
|
||||||
|
self.wildEncounterGraceSteps = 0
|
||||||
-- survives save/load: a loaded game may start inside a building whose
|
-- survives save/load: a loaded game may start inside a building whose
|
||||||
-- exit mat is a LAST_MAP warp
|
-- exit mat is a LAST_MAP warp
|
||||||
self.lastOutdoor = Game.save.lastOutdoor
|
self.lastOutdoor = Game.save.lastOutdoor
|
||||||
@@ -451,8 +459,10 @@ function OverworldState:setMap(mapId, x, y, facing, opts)
|
|||||||
local keepMusic = (opts and opts.keepMusic) or self.keepMusicOnce
|
local keepMusic = (opts and opts.keepMusic) or self.keepMusicOnce
|
||||||
self.keepMusicOnce = nil
|
self.keepMusicOnce = nil
|
||||||
if not keepMusic then
|
if not keepMusic then
|
||||||
require("src.core.Music").playMap(Game.data, mapId, Game.save.onBike,
|
-- ..(home/overworld.asm ln 2346)
|
||||||
self.player.surfing)
|
local Music = require("src.core.Music")
|
||||||
|
Music.playMap(Game.data, mapId, Game.save.onBike, self.player.surfing,
|
||||||
|
Music.MAP_FADE)
|
||||||
end
|
end
|
||||||
|
|
||||||
-- forced bike/surf tiles fire the moment the player is placed on the
|
-- forced bike/surf tiles fire the moment the player is placed on the
|
||||||
@@ -1093,8 +1103,10 @@ function OverworldState:update(dt)
|
|||||||
local mapId = self.pendingSeamMusic
|
local mapId = self.pendingSeamMusic
|
||||||
self.pendingSeamMusic = nil
|
self.pendingSeamMusic = nil
|
||||||
if mapId == self.map.id then
|
if mapId == self.map.id then
|
||||||
require("src.core.Music").playMap(Game.data, mapId, Game.save.onBike,
|
-- ..(home/overworld.asm ln 677)
|
||||||
self.player.surfing)
|
local Music = require("src.core.Music")
|
||||||
|
Music.playMap(Game.data, mapId, Game.save.onBike, self.player.surfing,
|
||||||
|
Music.MAP_FADE)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
if stepped and not scripted then
|
if stepped and not scripted then
|
||||||
@@ -3472,6 +3484,10 @@ end
|
|||||||
|
|
||||||
function OverworldState:onStepComplete()
|
function OverworldState:onStepComplete()
|
||||||
local p = self.player
|
local p = self.player
|
||||||
|
local suppressWildEncounter = self.wildEncounterGraceSteps > 0
|
||||||
|
if suppressWildEncounter then
|
||||||
|
self.wildEncounterGraceSteps = self.wildEncounterGraceSteps - 1
|
||||||
|
end
|
||||||
self.todSteps = (self.todSteps or 0) + 1
|
self.todSteps = (self.todSteps or 0) + 1
|
||||||
-- UpdatePikachuHappinessAndMood rides the step counter (poison.asm)
|
-- UpdatePikachuHappinessAndMood rides the step counter (poison.asm)
|
||||||
require("src.world.PikachuFollower").onStep(Game.save)
|
require("src.world.PikachuFollower").onStep(Game.save)
|
||||||
@@ -3584,6 +3600,9 @@ function OverworldState:onStepComplete()
|
|||||||
-- wild encounters in grass, on water while surfing, or -- on indoor
|
-- wild encounters in grass, on water while surfing, or -- on indoor
|
||||||
-- maps whose tileset is not FOREST -- on EVERY tile
|
-- maps whose tileset is not FOREST -- on EVERY tile
|
||||||
-- (wild_encounters.asm: caves, towers, the Mansion, Power Plant)
|
-- (wild_encounters.asm: caves, towers, the Mansion, Power Plant)
|
||||||
|
-- The cooldown is checked after all other step processing so repel and
|
||||||
|
-- movement systems continue to advance during the protected steps.
|
||||||
|
if suppressWildEncounter then return end
|
||||||
local encDef = Game.data.encounters[self.map.id]
|
local encDef = Game.data.encounters[self.map.id]
|
||||||
local enc
|
local enc
|
||||||
local indoor = Game.data.field.indoorEncounters
|
local indoor = Game.data.field.indoorEncounters
|
||||||
@@ -3970,6 +3989,9 @@ end
|
|||||||
-- battle is optional; when given, Oak's Lab OPP_RIVAL1 losses skip the
|
-- battle is optional; when given, Oak's Lab OPP_RIVAL1 losses skip the
|
||||||
-- blackout (pret HandlePlayerBlackOut) so the map script can HealParty.
|
-- blackout (pret HandlePlayerBlackOut) so the map script can HealParty.
|
||||||
function OverworldState:afterBattle(result, battle)
|
function OverworldState:afterBattle(result, battle)
|
||||||
|
if battle and battle.kind == "wild" then
|
||||||
|
self.wildEncounterGraceSteps = WILD_ENCOUNTER_GRACE_STEPS
|
||||||
|
end
|
||||||
local lead = Game.save.party[1]
|
local lead = Game.save.party[1]
|
||||||
Logger.info("battle over: %s (lead %s %d/%d)", tostring(result),
|
Logger.info("battle over: %s (lead %s %d/%d)", tostring(result),
|
||||||
lead and lead.species or "-", lead and lead.hp or 0,
|
lead and lead.species or "-", lead and lead.hp or 0,
|
||||||
@@ -4799,9 +4821,15 @@ function OverworldState:drawWorld()
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
local quad = self.rodQuads[oam.tile]
|
local quad = self.rodQuads[oam.tile]
|
||||||
-- the sprite's top-left is 4px above its cell (SpriteRenderer:draw)
|
-- Place the rod against the active sprite's anchored top-left. The
|
||||||
local rx = p.px - cam.x + oam.dx
|
-- vanilla result is still (px-cam, py-cam-4), while custom larger
|
||||||
local ry = p.py - cam.y - 4 + oam.dy
|
-- sheets keep the rod attached to their feet.
|
||||||
|
-- Fishing always uses the on-foot player sheet; read its fields
|
||||||
|
-- directly so this FX pass does not advance pose-side animation.
|
||||||
|
local sprite, px, py = p.sprite, p.px, p.py
|
||||||
|
local sx, sy = sprite:getScreenOrigin(px, py, cam.x, cam.y)
|
||||||
|
local rx = sx + oam.dx
|
||||||
|
local ry = sy + oam.dy
|
||||||
love.graphics.setColor(1, 1, 1, 1)
|
love.graphics.setColor(1, 1, 1, 1)
|
||||||
if quad and oam.flip then
|
if quad and oam.flip then
|
||||||
love.graphics.draw(self.rodImg, quad, rx + 8, ry, 0, -1, 1)
|
love.graphics.draw(self.rodImg, quad, rx + 8, ry, 0, -1, 1)
|
||||||
|
|||||||
@@ -335,8 +335,13 @@ function Player:draw(camX, camY)
|
|||||||
local fishTile = self.fishing and self.fishTiles and self.fishTiles[facing]
|
local fishTile = self.fishing and self.fishTiles and self.fishTiles[facing]
|
||||||
if fishTile then
|
if fishTile then
|
||||||
sprite:draw(px, py, camX, camY, facing, 0, false, true)
|
sprite:draw(px, py, camX, camY, facing, 0, false, true)
|
||||||
sprite:drawTile(fishTile, math.floor(px - camX),
|
-- The fishing pose replaces the bottom 8-pixel tile. Use the sprite's
|
||||||
math.floor(py - camY) - 4 + 8, facing == "right")
|
-- actual anchored frame origin so larger/custom sheets keep the pose at
|
||||||
|
-- their feet instead of falling back to the vanilla 16x16 top-left.
|
||||||
|
local sx, sy = sprite:getScreenOrigin(px, py, camX, camY)
|
||||||
|
sprite:drawTile(fishTile, sx,
|
||||||
|
sy + math.max(0, sprite.frameHeight - 8),
|
||||||
|
facing == "right")
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
sprite:draw(px, py, camX, camY, facing, phase, flip)
|
sprite:draw(px, py, camX, camY, facing, phase, flip)
|
||||||
|
|||||||
+97
-15
@@ -8,6 +8,7 @@
|
|||||||
local Logger = require("src.core.Logger")
|
local Logger = require("src.core.Logger")
|
||||||
local Assets = require("src.render.Assets")
|
local Assets = require("src.render.Assets")
|
||||||
local MapLoader = require("src.world.MapLoader")
|
local MapLoader = require("src.world.MapLoader")
|
||||||
|
local Party = require("src.pokemon.Party")
|
||||||
local Runtime = require("src.mods.Runtime")
|
local Runtime = require("src.mods.Runtime")
|
||||||
|
|
||||||
local WorldAPI = {}
|
local WorldAPI = {}
|
||||||
@@ -18,6 +19,11 @@ local overviewShades = {}
|
|||||||
|
|
||||||
Assets.register(function() overviewShades = {} end)
|
Assets.register(function() overviewShades = {} end)
|
||||||
|
|
||||||
|
local function shadeDigit(sum, pixelCount)
|
||||||
|
return tostring(math.max(0, math.min(3,
|
||||||
|
math.floor((1 - sum / pixelCount) * 3 + 0.5))))
|
||||||
|
end
|
||||||
|
|
||||||
local function mapTileRows(map)
|
local function mapTileRows(map)
|
||||||
local tileset = map.tileset
|
local tileset = map.tileset
|
||||||
if not (tileset and tileset.image and tileset.tilesPerRow) then return nil end
|
if not (tileset and tileset.image and tileset.tilesPerRow) then return nil end
|
||||||
@@ -28,30 +34,39 @@ local function mapTileRows(map)
|
|||||||
cached = { pixels = pixels, shades = {} }
|
cached = { pixels = pixels, shades = {} }
|
||||||
overviewShades[tileset.image] = cached
|
overviewShades[tileset.image] = cached
|
||||||
end
|
end
|
||||||
local rows, perRow = {}, tileset.tilesPerRow
|
local rows, detailRows, perRow = {}, {}, tileset.tilesPerRow
|
||||||
for ty = 0, map.heightCells * 2 - 1 do
|
for ty = 0, map.heightCells * 2 - 1 do
|
||||||
local row = {}
|
local row, detailTop, detailBottom = {}, {}, {}
|
||||||
for tx = 0, map.widthCells * 2 - 1 do
|
for tx = 0, map.widthCells * 2 - 1 do
|
||||||
local tile = map:tileAt(tx, ty)
|
local tile = map:tileAt(tx, ty)
|
||||||
local shade = cached.shades[tile]
|
local shades = cached.shades[tile]
|
||||||
if shade == nil then
|
if shades == nil then
|
||||||
local sum = 0
|
local sums = { 0, 0, 0, 0 }
|
||||||
local ox, oy = (tile % perRow) * 8, math.floor(tile / perRow) * 8
|
local ox, oy = (tile % perRow) * 8, math.floor(tile / perRow) * 8
|
||||||
for py = 0, 7 do
|
for py = 0, 7 do
|
||||||
for px = 0, 7 do
|
for px = 0, 7 do
|
||||||
local r, g, b = cached.pixels:getPixel(ox + px, oy + py)
|
local r, g, b = cached.pixels:getPixel(ox + px, oy + py)
|
||||||
sum = sum + r * 0.2126 + g * 0.7152 + b * 0.0722
|
local quadrant = math.floor(py / 4) * 2 + math.floor(px / 4) + 1
|
||||||
|
sums[quadrant] = sums[quadrant]
|
||||||
|
+ r * 0.2126 + g * 0.7152 + b * 0.0722
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
shade = tostring(math.max(0, math.min(3,
|
shades = {
|
||||||
math.floor((1 - sum / 64) * 3 + 0.5))))
|
shadeDigit(sums[1] + sums[2] + sums[3] + sums[4], 64),
|
||||||
cached.shades[tile] = shade
|
shadeDigit(sums[1], 16), shadeDigit(sums[2], 16),
|
||||||
|
shadeDigit(sums[3], 16), shadeDigit(sums[4], 16),
|
||||||
|
}
|
||||||
|
cached.shades[tile] = shades
|
||||||
end
|
end
|
||||||
row[#row + 1] = shade
|
row[#row + 1] = shades[1]
|
||||||
|
detailTop[#detailTop + 1] = shades[2] .. shades[3]
|
||||||
|
detailBottom[#detailBottom + 1] = shades[4] .. shades[5]
|
||||||
end
|
end
|
||||||
rows[#rows + 1] = table.concat(row)
|
rows[#rows + 1] = table.concat(row)
|
||||||
|
detailRows[#detailRows + 1] = table.concat(detailTop)
|
||||||
|
detailRows[#detailRows + 1] = table.concat(detailBottom)
|
||||||
end
|
end
|
||||||
return rows
|
return rows, detailRows
|
||||||
end
|
end
|
||||||
|
|
||||||
function WorldAPI.new(game, modId)
|
function WorldAPI.new(game, modId)
|
||||||
@@ -86,10 +101,12 @@ end
|
|||||||
-- A compact, read-only view of the active map for minimaps and companion UIs.
|
-- A compact, read-only view of the active map for minimaps and companion UIs.
|
||||||
-- `rows` describes collision terrain; optional `tileRows` reduces each real
|
-- `rows` describes collision terrain; optional `tileRows` reduces each real
|
||||||
-- 8x8 map tile to its average Game Boy shade ("0" lightest, "3" darkest).
|
-- 8x8 map tile to its average Game Boy shade ("0" lightest, "3" darkest).
|
||||||
|
-- `tileDetailRows` preserves one shade per 4x4 quadrant. Markers identify
|
||||||
|
-- exits and item spots that are still active without exposing world internals.
|
||||||
function WorldAPI:mapOverview()
|
function WorldAPI:mapOverview()
|
||||||
local ow = self:overworld()
|
local ow = self:overworld()
|
||||||
if not ow or not ow.map then return nil, NO_OVERWORLD end
|
if not ow or not ow.map then return nil, NO_OVERWORLD end
|
||||||
local map, rows = ow.map, {}
|
local map, rows, markers = ow.map, {}, {}
|
||||||
for y = 0, map.heightCells - 1 do
|
for y = 0, map.heightCells - 1 do
|
||||||
local row = {}
|
local row = {}
|
||||||
for x = 0, map.widthCells - 1 do
|
for x = 0, map.widthCells - 1 do
|
||||||
@@ -99,11 +116,33 @@ function WorldAPI:mapOverview()
|
|||||||
end
|
end
|
||||||
rows[#rows + 1] = table.concat(row)
|
rows[#rows + 1] = table.concat(row)
|
||||||
end
|
end
|
||||||
local tileRows = mapTileRows(map)
|
local def = map.def or {}
|
||||||
|
for _, warp in ipairs(def.warps or {}) do
|
||||||
|
markers[#markers + 1] = { kind = "warp", x = warp.x, y = warp.y }
|
||||||
|
end
|
||||||
|
local game, save = self.game, self.game.save or {}
|
||||||
|
for _, obj in ipairs(def.objects or {}) do
|
||||||
|
if obj.item and obj.item ~= "0" and obj.item ~= 0
|
||||||
|
and ow.objectVisible(save, map.id, obj) then
|
||||||
|
markers[#markers + 1] = { kind = "item", x = obj.x, y = obj.y }
|
||||||
|
end
|
||||||
|
end
|
||||||
|
local hidden = game.data and game.data.field and game.data.field.hiddenItems
|
||||||
|
for _, item in ipairs(hidden and hidden[map.id] or {}) do
|
||||||
|
local key = map.id .. "_" .. item.x .. "_" .. item.y
|
||||||
|
if not (save.hiddenTaken and save.hiddenTaken[key]) then
|
||||||
|
markers[#markers + 1] = { kind = "hidden", x = item.x, y = item.y }
|
||||||
|
end
|
||||||
|
end
|
||||||
|
local tileRows, tileDetailRows = mapTileRows(map)
|
||||||
return { mapId = map.id, width = map.widthCells,
|
return { mapId = map.id, width = map.widthCells,
|
||||||
height = map.heightCells, rows = rows, tileRows = tileRows,
|
height = map.heightCells, rows = rows, markers = markers,
|
||||||
|
tileRows = tileRows,
|
||||||
tileWidth = tileRows and map.widthCells * 2,
|
tileWidth = tileRows and map.widthCells * 2,
|
||||||
tileHeight = tileRows and map.heightCells * 2 }
|
tileHeight = tileRows and map.heightCells * 2,
|
||||||
|
tileDetailRows = tileDetailRows,
|
||||||
|
tileDetailWidth = tileDetailRows and map.widthCells * 4,
|
||||||
|
tileDetailHeight = tileDetailRows and map.heightCells * 4 }
|
||||||
end
|
end
|
||||||
|
|
||||||
-- opts.arrive = "fly" | "teleport" picks the arrival FX; anything else
|
-- opts.arrive = "fly" | "teleport" picks the arrival FX; anything else
|
||||||
@@ -227,6 +266,49 @@ function WorldAPI:queueScript(rows, extra)
|
|||||||
return true
|
return true
|
||||||
end
|
end
|
||||||
|
|
||||||
|
-- The supported way to start a wild encounter. Hand-rolling this -- build a
|
||||||
|
-- BattleState, push it -- silently costs evolutions and blackout-on-loss
|
||||||
|
-- (both hang off onFinish -> afterBattle) plus the entry wipe and battle
|
||||||
|
-- theme (both owned by pushBattle). Nothing raises when they are missing.
|
||||||
|
function WorldAPI:startWildBattle(species, level)
|
||||||
|
local ow = self:overworld()
|
||||||
|
if not ow then return nil, NO_OVERWORLD end
|
||||||
|
if not self.game.data.pokemon[species] then
|
||||||
|
return nil, "unknown species: " .. tostring(species)
|
||||||
|
end
|
||||||
|
-- Pokemon.new writes the level through verbatim -- into level, the stat
|
||||||
|
-- calc and the exp curve -- so a fraction has to be refused here rather
|
||||||
|
-- than round somewhere downstream. The % test also catches NaN, which
|
||||||
|
-- passes both range comparisons.
|
||||||
|
level = tonumber(level)
|
||||||
|
if not level or level % 1 ~= 0 or level < 1 or level > 100 then
|
||||||
|
return nil, "level must be a whole number 1..100"
|
||||||
|
end
|
||||||
|
-- overworld() resolves the world from UNDER whatever sits on top of it,
|
||||||
|
-- so from a battle hook this would otherwise stack a second battle over
|
||||||
|
-- the live one -- and on a loss its afterBattle blacks out and warps
|
||||||
|
-- with the outer battle still on the stack.
|
||||||
|
local BattleTransition = require("src.render.BattleTransition")
|
||||||
|
for _, state in ipairs(self.game.stack and self.game.stack.states or {}) do
|
||||||
|
if state.awardExp or getmetatable(state) == BattleTransition then
|
||||||
|
return nil, "a battle is already running"
|
||||||
|
end
|
||||||
|
end
|
||||||
|
if ow.transitioning then return nil, "the world is mid-warp" end
|
||||||
|
-- BattleState.newWild marks the species SEEN before it reports an empty
|
||||||
|
-- party, so the party check comes first: a refused call must not leave a
|
||||||
|
-- Pokedex entry behind.
|
||||||
|
local save = self.game.save
|
||||||
|
if not (save and Party.firstHealthy(save.party or {})) then
|
||||||
|
return nil, "no healthy party"
|
||||||
|
end
|
||||||
|
local battle = require("src.battle.BattleState")
|
||||||
|
.newWild(self.game, species, level)
|
||||||
|
battle.onFinish = function(result) ow:afterBattle(result, battle) end
|
||||||
|
ow:pushBattle(battle)
|
||||||
|
return true
|
||||||
|
end
|
||||||
|
|
||||||
-- drop a map's cached instance so the next load re-reads its record; when
|
-- drop a map's cached instance so the next load re-reads its record; when
|
||||||
-- it is the active map the world reloads around the player in place
|
-- it is the active map the world reloads around the player in place
|
||||||
function WorldAPI:invalidateMap(mapId)
|
function WorldAPI:invalidateMap(mapId)
|
||||||
|
|||||||
@@ -195,7 +195,7 @@ return function(game)
|
|||||||
ow:queueScript(slice, { npc = rival })
|
ow:queueScript(slice, { npc = rival })
|
||||||
|
|
||||||
local startY = ow.player.cellY
|
local startY = ow.player.cellY
|
||||||
local minY, walkShot = startY, false
|
local minY, walkShot, sharedCell = startY, false, false
|
||||||
local hof
|
local hof
|
||||||
for i = 1, 5000 do
|
for i = 1, 5000 do
|
||||||
local top = game.stack:top()
|
local top = game.stack:top()
|
||||||
@@ -205,8 +205,9 @@ return function(game)
|
|||||||
end
|
end
|
||||||
local w = game.overworld
|
local w = game.overworld
|
||||||
if w and w.map and w.map.id == "CHAMPIONS_ROOM" then
|
if w and w.map and w.map.id == "CHAMPIONS_ROOM" then
|
||||||
local y = w.player.cellY
|
local x, y = w.player.cellX, w.player.cellY
|
||||||
if y < minY then minY = y end
|
if y < minY then minY = y end
|
||||||
|
if x == rival.cellX and y == rival.cellY then sharedCell = true end
|
||||||
if y <= 2 and not walkShot then
|
if y <= 2 and not walkShot then
|
||||||
walkShot = U.shot(game, DIR .. "/hof704_follows_oak.png")
|
walkShot = U.shot(game, DIR .. "/hof704_follows_oak.png")
|
||||||
end
|
end
|
||||||
@@ -215,6 +216,7 @@ return function(game)
|
|||||||
end
|
end
|
||||||
check("the player walked out of the room before the warp (#704)",
|
check("the player walked out of the room before the warp (#704)",
|
||||||
minY < startY)
|
minY < startY)
|
||||||
|
check("the player routed around the rival", not sharedCell)
|
||||||
check("walk-out screenshot", walkShot)
|
check("walk-out screenshot", walkShot)
|
||||||
check("the induction started", hof ~= nil)
|
check("the induction started", hof ~= nil)
|
||||||
if not hof then
|
if not hof then
|
||||||
|
|||||||
@@ -0,0 +1,66 @@
|
|||||||
|
-- ..(engine/movie/title.asm ln 28)
|
||||||
|
-- ..(engine/movie/title2.asm ln 13)
|
||||||
|
-- POKEPORT_DRIVER=tests/drivers/title_cycle_test.lua POKEPORT_TOUCH=0 SHOT_DIR=/tmp/shots love .
|
||||||
|
return function(game)
|
||||||
|
local U = dofile("tests/drivers/util.lua")
|
||||||
|
local DIR = os.getenv("SHOT_DIR") or "/tmp/shots"
|
||||||
|
local shot = 0
|
||||||
|
local function grab(tag)
|
||||||
|
shot = shot + 1
|
||||||
|
U.shot(game, ("%s/title_%02d_%s.png"):format(DIR, shot, tag))
|
||||||
|
end
|
||||||
|
|
||||||
|
U.wait(30)
|
||||||
|
grab("copyright")
|
||||||
|
-- ..(engine/movie/splash.asm ln 230)
|
||||||
|
local movie = game.stack:top()
|
||||||
|
while movie.phase ~= 2 or movie.timer < 70 do U.wait(1) end
|
||||||
|
grab("star_topbar")
|
||||||
|
while movie.timer < 88 do U.wait(1) end
|
||||||
|
grab("star_middle")
|
||||||
|
while movie.timer < 100 do U.wait(1) end
|
||||||
|
grab("star_lowbar")
|
||||||
|
while movie.timer < 130 do U.wait(1) end
|
||||||
|
grab("gamefreak")
|
||||||
|
|
||||||
|
U.tap(game, "start")
|
||||||
|
U.wait(2)
|
||||||
|
local title = game.stack:top()
|
||||||
|
U.log("top is", tostring(title and title.screenId))
|
||||||
|
if not (title and title.scrollPhase) then
|
||||||
|
U.log("no TitleState on top; nothing below can run")
|
||||||
|
while true do coroutine.yield() end
|
||||||
|
end
|
||||||
|
|
||||||
|
grab("drop_early")
|
||||||
|
U.wait(14)
|
||||||
|
grab("drop_late")
|
||||||
|
while title.phase == "drop" do U.wait(1) end
|
||||||
|
grab("settle")
|
||||||
|
while title.phase == "settle" do U.wait(1) end
|
||||||
|
grab("ribbon_start")
|
||||||
|
U.wait(10)
|
||||||
|
U.shot(game, DIR .. "/title_ribbon_mid.png")
|
||||||
|
while title.phase ~= "loop" do U.wait(1) end
|
||||||
|
grab("landed")
|
||||||
|
|
||||||
|
title.cycleIndex = 1
|
||||||
|
title.scrollPhase, title.scrollFrame, title.timer = "hold", 1, 0
|
||||||
|
title.monOffset = 0
|
||||||
|
while title.scrollPhase == "hold" do U.wait(1) end
|
||||||
|
grab("out_a")
|
||||||
|
U.wait(6)
|
||||||
|
grab("out_b")
|
||||||
|
while title.scrollPhase == "out" do U.wait(1) end
|
||||||
|
U.log("after the scroll out the phase is", title.scrollPhase)
|
||||||
|
for _ = 1, 5 do
|
||||||
|
grab("ball")
|
||||||
|
U.wait(1)
|
||||||
|
end
|
||||||
|
while title.scrollPhase == "ball" do U.wait(1) end
|
||||||
|
grab("in")
|
||||||
|
U.wait(30)
|
||||||
|
grab("next_mon")
|
||||||
|
U.log("captured", DIR)
|
||||||
|
while true do coroutine.yield() end
|
||||||
|
end
|
||||||
@@ -65,8 +65,12 @@ function U.newGame(game)
|
|||||||
U.wait(5)
|
U.wait(5)
|
||||||
U.tap(game, "start") -- skip intro movie
|
U.tap(game, "start") -- skip intro movie
|
||||||
U.wait(10)
|
U.wait(10)
|
||||||
U.tap(game, "a") -- title -> menu
|
local title = game.stack:top()
|
||||||
U.wait(5)
|
for _ = 1, 60 do
|
||||||
|
U.tap(game, "a")
|
||||||
|
U.wait(5)
|
||||||
|
if game.stack:top() ~= title then break end
|
||||||
|
end
|
||||||
-- menu: CONTINUE may or may not exist; NEW GAME is first without a save
|
-- menu: CONTINUE may or may not exist; NEW GAME is first without a save
|
||||||
U.tap(game, "a")
|
U.tap(game, "a")
|
||||||
U.wait(10)
|
U.wait(10)
|
||||||
|
|||||||
@@ -0,0 +1,103 @@
|
|||||||
|
package.path = "./?.lua;./?/init.lua;" .. package.path
|
||||||
|
|
||||||
|
local T = require("tests.harness")
|
||||||
|
local check = T.check
|
||||||
|
local eq = T.eq
|
||||||
|
|
||||||
|
love = require("tests.love_stub")
|
||||||
|
|
||||||
|
local ChipAsm = require("src.audio.ChipAsm")
|
||||||
|
local ChipSynth = require("src.core.ChipSynth")
|
||||||
|
|
||||||
|
local data = { audio = {} }
|
||||||
|
|
||||||
|
local function pulseSong()
|
||||||
|
return ChipAsm.song{
|
||||||
|
channels = { { hw = 1, program = {
|
||||||
|
{ duty = 2 },
|
||||||
|
{ notetype = { speed = 12, volume = 15, fade = 0 } },
|
||||||
|
{ octave = 4 },
|
||||||
|
{ note = "C", len = 15 },
|
||||||
|
} } },
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
local function noiseSong()
|
||||||
|
return ChipAsm.sfx{
|
||||||
|
channels = { { hw = 4, program = {
|
||||||
|
{ noiseNote = { len = 8, volume = 15, fade = 1, parameter = 0x34 } },
|
||||||
|
} } },
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
do
|
||||||
|
local engine = ChipSynth.newEngine(data, pulseSong(), { allowLoops = false })
|
||||||
|
local sawNeg, sawPos = false, false
|
||||||
|
for _ = 1, 512 do
|
||||||
|
local v = engine.channels[1]:sample()
|
||||||
|
if v < -1e-12 then sawNeg = true end
|
||||||
|
if v > 1e-12 then sawPos = true end
|
||||||
|
end
|
||||||
|
check(sawPos and not sawNeg,
|
||||||
|
"pulse DAC is unipolar (high = volume, low = 0)")
|
||||||
|
end
|
||||||
|
|
||||||
|
do
|
||||||
|
local engine = ChipSynth.newEngine(data, noiseSong(), {
|
||||||
|
sfx = true, allowLoops = false,
|
||||||
|
})
|
||||||
|
local sawNeg, sawPos = false, false
|
||||||
|
for _ = 1, 2048 do
|
||||||
|
local v = engine.channels[1]:sample()
|
||||||
|
if v < -1e-12 then sawNeg = true end
|
||||||
|
if v > 1e-12 then sawPos = true end
|
||||||
|
end
|
||||||
|
check(sawPos and not sawNeg,
|
||||||
|
"noise DAC is unipolar (LFSR high = volume, low = 0)")
|
||||||
|
end
|
||||||
|
|
||||||
|
local function crossingsAndSign(engine, frames)
|
||||||
|
local count, prev = 0, nil
|
||||||
|
local sawNeg, sawPos = false, false
|
||||||
|
for _ = 1, frames do
|
||||||
|
local sample = engine:sample()
|
||||||
|
if sample < -1e-12 then sawNeg = true end
|
||||||
|
if sample > 1e-12 then sawPos = true end
|
||||||
|
if prev and prev * sample < 0 then count = count + 1 end
|
||||||
|
prev = sample
|
||||||
|
end
|
||||||
|
return count, sawNeg, sawPos
|
||||||
|
end
|
||||||
|
|
||||||
|
do
|
||||||
|
local engine = ChipSynth.newEngine(data, pulseSong(), { allowLoops = false })
|
||||||
|
local count, sawNeg, sawPos = crossingsAndSign(engine, 4000)
|
||||||
|
check(sawNeg and sawPos, "HPF centers a unipolar pulse around analog 0")
|
||||||
|
check(count > 20, ("HPF'd pulse crosses zero (%d crossings)"):format(count))
|
||||||
|
end
|
||||||
|
|
||||||
|
do
|
||||||
|
local engine = ChipSynth.newEngine(data, noiseSong(), {
|
||||||
|
sfx = true, allowLoops = false,
|
||||||
|
})
|
||||||
|
local count, sawNeg, sawPos = crossingsAndSign(engine, 8000)
|
||||||
|
check(sawNeg and sawPos, "HPF centers noise / drums around analog 0")
|
||||||
|
check(count > 50, ("HPF'd noise crosses zero (%d crossings)"):format(count))
|
||||||
|
end
|
||||||
|
|
||||||
|
do
|
||||||
|
local song = pulseSong()
|
||||||
|
ChipSynth.setChannelVolumes({ 1, 1, 1, 1 })
|
||||||
|
local a = ChipSynth.newEngine(data, song, { allowLoops = false })
|
||||||
|
local base = a.channels[1]:sample()
|
||||||
|
ChipSynth.setChannelVolume(1, 0.25)
|
||||||
|
local b = ChipSynth.newEngine(data, song, { allowLoops = false })
|
||||||
|
local quarter = b.channels[1]:sample()
|
||||||
|
ChipSynth.setChannelVolumes({ 1, 1, 1, 1 })
|
||||||
|
check(base > 0 and math.abs(quarter - base * 0.25) < 1e-9,
|
||||||
|
"channelVolume still quarters the unipolar DAC level")
|
||||||
|
end
|
||||||
|
|
||||||
|
eq(type(ChipSynth.newEngine), "function", "engine factory still exported")
|
||||||
|
|
||||||
|
T.finish("chip analog path")
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
-- ..(audio/engine_1.asm ln 197)
|
||||||
|
-- ..(audio/sfx/noise_instrument01_1.asm ln 1)
|
||||||
|
-- luajit tests/engine/drum_envelope_ring.lua
|
||||||
|
|
||||||
|
package.path = "./?.lua;./?/init.lua;" .. package.path
|
||||||
|
|
||||||
|
local T = require("tests.harness")
|
||||||
|
local check = T.check
|
||||||
|
|
||||||
|
love = require("tests.love_stub")
|
||||||
|
|
||||||
|
local ChipAsm = require("src.audio.ChipAsm")
|
||||||
|
local ChipSynth = require("src.core.ChipSynth")
|
||||||
|
|
||||||
|
local snare = ChipAsm.song{
|
||||||
|
channels = {
|
||||||
|
{ hw = 4, program = {
|
||||||
|
{ notetype = { speed = 12 } },
|
||||||
|
{ drum = 1, len = 2 },
|
||||||
|
{ rest = 16 },
|
||||||
|
} },
|
||||||
|
},
|
||||||
|
drums = {
|
||||||
|
[1] = {
|
||||||
|
{ len = 1, volume = 12, fade = 1, parameter = 0x33 },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
local engine = ChipSynth.newEngine({ audio = {} }, snare, { allowLoops = false })
|
||||||
|
local segs = engine:noiseInstrument(1)
|
||||||
|
local last = segs[#segs]
|
||||||
|
local ringMs = (last.endSample - last.startSample) / ChipSynth.SAMPLE_RATE * 1000
|
||||||
|
check(ringMs > 150 and ringMs < 220,
|
||||||
|
("snare instrument rings ~188ms, not the 17ms note (%0.1fms)"):format(ringMs))
|
||||||
|
|
||||||
|
local energyEarly, energyLate, energyEnd = 0, 0, 0
|
||||||
|
local total = math.floor(ChipSynth.SAMPLE_RATE * 0.25)
|
||||||
|
for i = 1, total do
|
||||||
|
local s = engine:sample()
|
||||||
|
local e = s * s
|
||||||
|
local t = i / ChipSynth.SAMPLE_RATE
|
||||||
|
if t < 0.02 then
|
||||||
|
energyEarly = energyEarly + e
|
||||||
|
elseif t > 0.05 and t < 0.12 then
|
||||||
|
energyLate = energyLate + e
|
||||||
|
elseif t > 0.20 then
|
||||||
|
energyEnd = energyEnd + e
|
||||||
|
end
|
||||||
|
end
|
||||||
|
check(energyEarly > 0, "snare attack is audible")
|
||||||
|
check(energyLate > energyEarly * 0.05,
|
||||||
|
("snare body still sounds at 50-120ms (early=%.4f late=%.4f)")
|
||||||
|
:format(energyEarly, energyLate))
|
||||||
|
check(energyEnd < energyLate * 0.1,
|
||||||
|
"snare has decayed by 200ms")
|
||||||
|
|
||||||
|
local hats = ChipAsm.song{
|
||||||
|
channels = {
|
||||||
|
{ hw = 4, program = {
|
||||||
|
{ notetype = { speed = 12 } },
|
||||||
|
{ drum = 1, len = 2 },
|
||||||
|
{ drum = 1, len = 2 },
|
||||||
|
} },
|
||||||
|
},
|
||||||
|
drums = {
|
||||||
|
[1] = {
|
||||||
|
{ len = 1, volume = 8, fade = 1, parameter = 0x10 },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
local hatEngine = ChipSynth.newEngine({ audio = {} }, hats, { allowLoops = false })
|
||||||
|
local hits = 0
|
||||||
|
local prev = 0
|
||||||
|
for _ = 1, math.floor(ChipSynth.SAMPLE_RATE * 0.3) do
|
||||||
|
local s = math.abs(hatEngine:sample())
|
||||||
|
if prev < 0.01 and s >= 0.01 then hits = hits + 1 end
|
||||||
|
prev = s
|
||||||
|
end
|
||||||
|
check(hits >= 2, ("two rapid drum_notes both trigger (%d onsets)"):format(hits))
|
||||||
|
|
||||||
|
T.finish("drum envelope ring")
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
-- ..(home/audio.asm ln 9)
|
||||||
|
-- ..(home/fade_audio.asm ln 36)
|
||||||
|
-- luajit tests/engine/map_music_fade.lua
|
||||||
|
|
||||||
|
package.path = "./?.lua;./?/init.lua;" .. package.path
|
||||||
|
|
||||||
|
local T = require("tests.harness")
|
||||||
|
local check = T.check
|
||||||
|
local eq = T.eq
|
||||||
|
|
||||||
|
love = require("tests.love_stub")
|
||||||
|
|
||||||
|
local Source = {}
|
||||||
|
Source.__index = Source
|
||||||
|
function Source:play() self.playing = true end
|
||||||
|
function Source:stop() self.playing = false end
|
||||||
|
function Source:pause() self.playing = false end
|
||||||
|
function Source:isPlaying() return self.playing end
|
||||||
|
function Source:setLooping() end
|
||||||
|
function Source:setVolume(v) self.volume = v end
|
||||||
|
function Source:setPitch() end
|
||||||
|
function Source:setFilter() end
|
||||||
|
function Source:getDuration() return 1 end
|
||||||
|
|
||||||
|
local made = {} -- file -> the last source built for it
|
||||||
|
love.audio = {
|
||||||
|
newSource = function(file, mode)
|
||||||
|
made[file] = setmetatable({ file = file, mode = mode }, Source)
|
||||||
|
return made[file]
|
||||||
|
end,
|
||||||
|
}
|
||||||
|
|
||||||
|
local Music = require("src.core.Music")
|
||||||
|
|
||||||
|
local data = { audio = {
|
||||||
|
songs = {
|
||||||
|
Music_Pallet = { file = "pallet.wav" },
|
||||||
|
Music_Routes1 = { file = "routes1.wav" },
|
||||||
|
Music_Pewter = { file = "pewter.wav" },
|
||||||
|
},
|
||||||
|
mapSongs = {
|
||||||
|
PALLET_TOWN = "Music_Pallet",
|
||||||
|
ROUTE_1 = "Music_Routes1",
|
||||||
|
PEWTER_CITY = "Music_Pewter",
|
||||||
|
},
|
||||||
|
} }
|
||||||
|
|
||||||
|
local function frames(n)
|
||||||
|
for _ = 1, n do Music.update(data) end
|
||||||
|
end
|
||||||
|
|
||||||
|
local function playing()
|
||||||
|
for file, src in pairs(made) do
|
||||||
|
if src.playing then return file end
|
||||||
|
end
|
||||||
|
return "(silence)"
|
||||||
|
end
|
||||||
|
|
||||||
|
local FADE = 7 * Music.MAP_FADE -- 7 volume levels x 10 frames
|
||||||
|
|
||||||
|
Music.stop()
|
||||||
|
Music.playMap(data, "PALLET_TOWN", false, false, Music.MAP_FADE)
|
||||||
|
eq(playing(), "pallet.wav", "the first map after boot starts at once")
|
||||||
|
|
||||||
|
local fullVolume = made["pallet.wav"].volume
|
||||||
|
|
||||||
|
Music.playMap(data, "ROUTE_1", false, false, Music.MAP_FADE)
|
||||||
|
eq(playing(), "pallet.wav", "the new theme waits while the old one fades")
|
||||||
|
frames(FADE - 1)
|
||||||
|
eq(playing(), "pallet.wav", "still fading one frame short of silence")
|
||||||
|
check(made["pallet.wav"].volume < fullVolume,
|
||||||
|
"the old theme has been ramped down by then")
|
||||||
|
frames(1)
|
||||||
|
eq(playing(), "routes1.wav", "the queued theme takes over after 7 * 10 frames")
|
||||||
|
|
||||||
|
eq(made["routes1.wav"].volume, fullVolume,
|
||||||
|
"the new theme starts at full volume, not where the ramp ended")
|
||||||
|
|
||||||
|
Music.playMap(data, "ROUTE_1", false, false, Music.MAP_FADE)
|
||||||
|
eq(playing(), "routes1.wav", "the same theme keeps playing")
|
||||||
|
frames(FADE)
|
||||||
|
eq(playing(), "routes1.wav", "and no fade was armed for it")
|
||||||
|
|
||||||
|
Music.playMap(data, "PALLET_TOWN", false, false, Music.MAP_FADE)
|
||||||
|
frames(3 * Music.MAP_FADE)
|
||||||
|
Music.playMap(data, "PEWTER_CITY", false, false, Music.MAP_FADE)
|
||||||
|
eq(playing(), "routes1.wav", "the retargeted fade keeps ramping the old theme")
|
||||||
|
frames(4 * Music.MAP_FADE)
|
||||||
|
eq(playing(), "pewter.wav", "the ramp lands on the newest map's theme")
|
||||||
|
|
||||||
|
Music.playMap(data, "PALLET_TOWN", false, false)
|
||||||
|
eq(playing(), "pallet.wav", "a fadeless map cue swaps immediately")
|
||||||
|
|
||||||
|
T.finish("map_music_fade")
|
||||||
@@ -37,7 +37,7 @@ end
|
|||||||
|
|
||||||
local Y_TITLE = {
|
local Y_TITLE = {
|
||||||
"pikachu.png", "pika_bubble.png", "eyes_half.png", "eyes_closed.png",
|
"pikachu.png", "pika_bubble.png", "eyes_half.png", "eyes_closed.png",
|
||||||
"player.png", "copyright.png", "yellow_version.png",
|
"player.png", "copyright.png", "gamefreak_inc.png", "yellow_version.png",
|
||||||
}
|
}
|
||||||
for _, name in ipairs(Y_TITLE) do
|
for _, name in ipairs(Y_TITLE) do
|
||||||
seed("yellow/assets/generated/title/" .. name)
|
seed("yellow/assets/generated/title/" .. name)
|
||||||
@@ -266,6 +266,7 @@ GameVersion.set("blue")
|
|||||||
seed("blue/assets/generated/title/blue_version.png", "blue-version-bytes")
|
seed("blue/assets/generated/title/blue_version.png", "blue-version-bytes")
|
||||||
seed("blue/assets/generated/title/player.png")
|
seed("blue/assets/generated/title/player.png")
|
||||||
seed("blue/assets/generated/title/copyright.png")
|
seed("blue/assets/generated/title/copyright.png")
|
||||||
|
seed("blue/assets/generated/title/gamefreak_inc.png")
|
||||||
local B_INTRO = {
|
local B_INTRO = {
|
||||||
"gf_logo.png", "gf_text.png", "big_star.png",
|
"gf_logo.png", "gf_text.png", "big_star.png",
|
||||||
"falling_star.png", "falling_star_blink.png", "studio_logo.png",
|
"falling_star.png", "falling_star_blink.png", "studio_logo.png",
|
||||||
|
|||||||
@@ -0,0 +1,48 @@
|
|||||||
|
-- Stable ListMenu identities for screen.render_visible and companion UIs.
|
||||||
|
package.path = "./?.lua;./?/init.lua;" .. package.path
|
||||||
|
|
||||||
|
local T = require("tests.modkit")
|
||||||
|
local Data = T.fixtures.load()
|
||||||
|
|
||||||
|
local SaveData = require("src.core.SaveData")
|
||||||
|
local BoxMenu = require("src.ui.BoxMenu")
|
||||||
|
local ListMenu = require("src.ui.ListMenu")
|
||||||
|
local PlayerPC = require("src.ui.PlayerPC")
|
||||||
|
local Boxes = require("src.pokemon.Boxes")
|
||||||
|
|
||||||
|
local pushed
|
||||||
|
local game = {
|
||||||
|
data = Data,
|
||||||
|
save = SaveData.newGame(),
|
||||||
|
stack = { push = function(_, state) pushed = state end },
|
||||||
|
}
|
||||||
|
|
||||||
|
local species = T.fixtures.ids.species[1]
|
||||||
|
Boxes.ensure(game.save)[1][1] = { species = species, level = 5 }
|
||||||
|
game.save.party[1] = { species = species, level = 5 }
|
||||||
|
game.save.party[2] = { species = species, level = 6 }
|
||||||
|
game.save.pcItems = { FIX_POTION = 2 }
|
||||||
|
game.save.inventory.FIX_POTION = 2
|
||||||
|
|
||||||
|
local generic = ListMenu.new(game, "VISIBLE TITLE", {}, {})
|
||||||
|
T.eq(generic.kind, "VISIBLE TITLE", "generic lists fall back to their title")
|
||||||
|
local explicit = ListMenu.new(game, "Localized title", {}, { kind = "stable_id" })
|
||||||
|
T.eq(explicit.kind, "stable_id", "explicit list kind is preserved")
|
||||||
|
|
||||||
|
local box = BoxMenu.new(game)
|
||||||
|
for i, kind in ipairs({ "pc_box_withdraw", "pc_box_deposit",
|
||||||
|
"pc_box_release", "pc_box_change" }) do
|
||||||
|
pushed = nil
|
||||||
|
box.items[i].onSelect()
|
||||||
|
T.eq(pushed and pushed.kind, kind, kind .. " is stable")
|
||||||
|
end
|
||||||
|
|
||||||
|
local items = PlayerPC.new(game)
|
||||||
|
for i, kind in ipairs({ "pc_item_withdraw", "pc_item_deposit",
|
||||||
|
"pc_item_toss" }) do
|
||||||
|
pushed = nil
|
||||||
|
items.items[i].onSelect()
|
||||||
|
T.eq(pushed and pushed.kind, kind, kind .. " is stable")
|
||||||
|
end
|
||||||
|
|
||||||
|
T.finish("pc_list_kinds")
|
||||||
@@ -0,0 +1,109 @@
|
|||||||
|
-- ..(engine/movie/title.asm ln 227)
|
||||||
|
-- ..(engine/movie/title2.asm ln 13)
|
||||||
|
-- luajit tests/engine/title_mon_cycle.lua
|
||||||
|
|
||||||
|
package.path = "./?.lua;./?/init.lua;" .. package.path
|
||||||
|
|
||||||
|
local T = require("tests.harness")
|
||||||
|
local check = T.check
|
||||||
|
local eq = T.eq
|
||||||
|
|
||||||
|
love = require("tests.love_stub")
|
||||||
|
love.math = love.math or {}
|
||||||
|
local nextPick = 1
|
||||||
|
love.math.random = function(lo, hi)
|
||||||
|
nextPick = nextPick % hi + 1
|
||||||
|
return math.max(lo, nextPick)
|
||||||
|
end
|
||||||
|
|
||||||
|
local TitleState = require("src.ui.TitleState")
|
||||||
|
|
||||||
|
local title = TitleState.new(
|
||||||
|
{ data = {}, input = { wasPressed = function() return false end } }, {})
|
||||||
|
title.sprites = setmetatable({}, { __index = function() return false end })
|
||||||
|
|
||||||
|
eq(title.phase, "drop", "Red/Blue boot into the logo drop, not the loop")
|
||||||
|
local ribbonSeen = {}
|
||||||
|
for _ = 1, 400 do
|
||||||
|
if title.phase == "loop" then break end
|
||||||
|
title:update(1 / 60)
|
||||||
|
if title.phase == "ribbon" then
|
||||||
|
ribbonSeen[#ribbonSeen + 1] = title.ribbonOffset
|
||||||
|
end
|
||||||
|
end
|
||||||
|
eq(title.phase, "loop", "the cinematic lands within 400 frames")
|
||||||
|
eq(ribbonSeen[1], 112,
|
||||||
|
"the ribbon is parked off the right edge on its first drawn frame")
|
||||||
|
eq(ribbonSeen[#ribbonSeen], 4, "and walks in 4px a frame to its rest")
|
||||||
|
|
||||||
|
title.cycleIndex = 1 -- CHARMANDER: a starter, so the ball juggle runs
|
||||||
|
title.scrollPhase, title.scrollFrame, title.timer = "hold", 1, 0
|
||||||
|
title.monOffset = 0
|
||||||
|
|
||||||
|
local frames = {}
|
||||||
|
for _ = 1, 260 do
|
||||||
|
title:update(1 / 60)
|
||||||
|
frames[#frames + 1] = {
|
||||||
|
phase = title.scrollPhase, offset = title.monOffset,
|
||||||
|
ball = title.ballY, mon = title.cycleSpecies[title.cycleIndex],
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
local HOLD_FRAMES = 200
|
||||||
|
|
||||||
|
local function span(phase)
|
||||||
|
local first, count = nil, 0
|
||||||
|
for i, f in ipairs(frames) do
|
||||||
|
if f.phase == phase then
|
||||||
|
if not first then first = i end
|
||||||
|
if first + count == i then count = count + 1 end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
return first, count
|
||||||
|
end
|
||||||
|
|
||||||
|
local holdAt, holdLen = span("hold")
|
||||||
|
local outAt, outLen = span("out")
|
||||||
|
local ballAt, ballLen = span("ball")
|
||||||
|
local inAt, inLen = span("in")
|
||||||
|
eq(holdAt, 1, "the cycle opens on the hold")
|
||||||
|
eq(holdLen, HOLD_FRAMES - 1, "ld c, 200 / CheckForUserInterruption")
|
||||||
|
eq(outAt, HOLD_FRAMES, "the scroll out begins as the 200th hold frame ends")
|
||||||
|
eq(outLen, 18, "TitleScroll_Out is 2+2+2+2+2+2+3+3 frames")
|
||||||
|
eq(ballLen, 10, "TitleScroll_WaitBall is two runs of 5")
|
||||||
|
eq(inLen, 17, "TitleScroll_In is 2+4+4+3+2+1+1 frames")
|
||||||
|
check(outAt < ballAt and ballAt < inAt, "out, then the ball, then in")
|
||||||
|
|
||||||
|
local OUT = { 0, -1, -2, -4, -6, -9, -12, -16, -20, -25, -30, -36, -42,
|
||||||
|
-50, -58, -66, -75, -84 }
|
||||||
|
for i, want in ipairs(OUT) do
|
||||||
|
eq(frames[outAt + i - 1].offset, want,
|
||||||
|
"TitleScroll_Out offset at frame " .. i)
|
||||||
|
end
|
||||||
|
|
||||||
|
local IN = { 120, 110, 100, 91, 82, 73, 64, 56, 48, 40, 32, 26, 20, 14, 9,
|
||||||
|
4, 1 }
|
||||||
|
for i, want in ipairs(IN) do
|
||||||
|
eq(frames[inAt + i - 1].offset, want, "TitleScroll_In offset at frame " .. i)
|
||||||
|
end
|
||||||
|
|
||||||
|
local BALL = { 97, 95, 94, 93, 92, 93, 94, 95, 97, 100 }
|
||||||
|
for i, want in ipairs(BALL) do
|
||||||
|
eq(frames[ballAt + i - 1].ball, want, "TitleBallYTable entry " .. i)
|
||||||
|
end
|
||||||
|
|
||||||
|
local outgoing = frames[outAt].mon
|
||||||
|
eq(outgoing, "CHARMANDER", "the starter is the one that scrolls out")
|
||||||
|
for i = outAt, inAt - 1 do
|
||||||
|
eq(frames[i].mon, outgoing,
|
||||||
|
"the pick does not change before the scroll in, at frame " .. i)
|
||||||
|
end
|
||||||
|
local incoming = frames[inAt].mon
|
||||||
|
check(incoming ~= outgoing, "TitleScreenPickNewMon never repeats the pick")
|
||||||
|
for i = inAt, inAt + inLen - 1 do
|
||||||
|
check(frames[i].offset > 0,
|
||||||
|
"the incoming mon is only ever drawn right of rest, at frame " .. i)
|
||||||
|
end
|
||||||
|
eq(frames[inAt + inLen].offset, 0, "and settles at its resting column")
|
||||||
|
|
||||||
|
T.finish("title_mon_cycle")
|
||||||
@@ -40,6 +40,27 @@ T.eq(Font.advanceOf(0x80), 8, "and the pen stays 8px monospace")
|
|||||||
|
|
||||||
-- ------------------------------------------------- the ttf takes over
|
-- ------------------------------------------------- the ttf takes over
|
||||||
|
|
||||||
|
-- Font rasterizers must use the same 1x pixel grid as PixelCanvas, rather
|
||||||
|
-- than inheriting Android's window density. Keep this local fake so the
|
||||||
|
-- contract is testable without requiring a real LÖVE window.
|
||||||
|
do
|
||||||
|
local g, oldNewFont = love.graphics, love.graphics.newFont
|
||||||
|
local args
|
||||||
|
g.newFont = function(...)
|
||||||
|
args = { ... }
|
||||||
|
return oldNewFont(Font.PLAINPIXEL, Font.PLAINPIXEL_SIZE)
|
||||||
|
end
|
||||||
|
local loaded, err = pcall(Font.load, {
|
||||||
|
font = { charmap = CHARMAP, ttf = { file = "custom.ttf", size = 13 } },
|
||||||
|
})
|
||||||
|
g.newFont = oldNewFont
|
||||||
|
if not loaded then error(err, 0) end
|
||||||
|
T.eq(args[1], "custom.ttf", "rasterizer uses the configured file")
|
||||||
|
T.eq(args[2], 13, "rasterizer uses the configured size")
|
||||||
|
T.eq(args[3], "mono", "rasterizer keeps the pixel hinting mode")
|
||||||
|
T.eq(args[4], 1, "rasterizer stays on the pixel canvas scale")
|
||||||
|
end
|
||||||
|
|
||||||
Font.load({ font = { charmap = CHARMAP, ttf = {} } })
|
Font.load({ font = { charmap = CHARMAP, ttf = {} } })
|
||||||
T.check(Font.ttfActive(), "an empty ttf table loads the bundled font")
|
T.check(Font.ttfActive(), "an empty ttf table loads the bundled font")
|
||||||
|
|
||||||
|
|||||||
@@ -5,8 +5,9 @@ local Assets = require("src.render.Assets")
|
|||||||
local WorldAPI = require("src.world.WorldAPI")
|
local WorldAPI = require("src.world.WorldAPI")
|
||||||
|
|
||||||
Assets.imageData = function()
|
Assets.imageData = function()
|
||||||
return { getPixel = function(_, x)
|
return { getPixel = function(_, x, y)
|
||||||
local shade = x < 8 and 1 or 0
|
local shade = x >= 8 and 0 or ({ 1, 0, 2 / 3, 1 / 3 })[
|
||||||
|
math.floor(y / 4) * 2 + math.floor(x / 4) + 1]
|
||||||
return shade, shade, shade, 1
|
return shade, shade, shade, 1
|
||||||
end }
|
end }
|
||||||
end
|
end
|
||||||
@@ -16,15 +17,34 @@ local overview, err = api:mapOverview()
|
|||||||
T.eq(overview, nil, "map overview is unavailable outside the overworld")
|
T.eq(overview, nil, "map overview is unavailable outside the overworld")
|
||||||
T.eq(err, "no overworld", "map overview reports why it is unavailable")
|
T.eq(err, "no overworld", "map overview reports why it is unavailable")
|
||||||
|
|
||||||
local map = { id = "TEST_MAP", widthCells = 2, heightCells = 2 }
|
local map = {
|
||||||
|
id = "TEST_MAP", widthCells = 2, heightCells = 2,
|
||||||
|
def = {
|
||||||
|
warps = { { x = 1, y = 0 } },
|
||||||
|
objects = { { index = 1, x = 0, y = 1, item = "POTION" } },
|
||||||
|
},
|
||||||
|
}
|
||||||
function map:isWarpTileCell(x, y) return x == 1 and y == 0 end
|
function map:isWarpTileCell(x, y) return x == 1 and y == 0 end
|
||||||
function map:isWaterCell(x, y) return x == 0 and y == 1 end
|
function map:isWaterCell(x, y) return x == 0 and y == 1 end
|
||||||
function map:isWalkableCell(x, y) return x == 0 and y == 0 end
|
function map:isWalkableCell(x, y) return x == 0 and y == 0 end
|
||||||
function map:tileAt(x) return x % 2 end
|
function map:tileAt(x) return x % 2 end
|
||||||
|
|
||||||
api = WorldAPI.new({ stack = { states = {
|
local save = {}
|
||||||
{ isOverworld = true, map = map },
|
local world = {
|
||||||
} } }, "tester")
|
isOverworld = true,
|
||||||
|
map = map,
|
||||||
|
objectVisible = function(s, mapId, obj)
|
||||||
|
return not (s.itemsTaken and s.itemsTaken[mapId .. "_obj_" .. obj.index])
|
||||||
|
end,
|
||||||
|
}
|
||||||
|
local game = {
|
||||||
|
save = save,
|
||||||
|
data = { field = { hiddenItems = {
|
||||||
|
TEST_MAP = { { x = 1, y = 1, item = "NUGGET" } },
|
||||||
|
} } },
|
||||||
|
stack = { states = { world } },
|
||||||
|
}
|
||||||
|
api = WorldAPI.new(game, "tester")
|
||||||
overview = api:mapOverview()
|
overview = api:mapOverview()
|
||||||
T.eq(overview.mapId, "TEST_MAP", "map overview identifies the active map")
|
T.eq(overview.mapId, "TEST_MAP", "map overview identifies the active map")
|
||||||
T.eq(overview.width, 2, "map overview reports its width")
|
T.eq(overview.width, 2, "map overview reports its width")
|
||||||
@@ -32,11 +52,27 @@ T.eq(overview.height, 2, "map overview reports its height")
|
|||||||
T.eq(overview.rows[1], ".+", "walkable land and warps are distinct")
|
T.eq(overview.rows[1], ".+", "walkable land and warps are distinct")
|
||||||
T.eq(overview.rows[2], "~ ", "water and blocked terrain are distinct")
|
T.eq(overview.rows[2], "~ ", "water and blocked terrain are distinct")
|
||||||
T.eq(overview.tileRows, nil, "tile overview is optional")
|
T.eq(overview.tileRows, nil, "tile overview is optional")
|
||||||
|
T.eq(#overview.markers, 3, "active exits and untaken items are marked")
|
||||||
|
T.eq(overview.markers[1].kind, "warp", "warp marker is semantic")
|
||||||
|
T.eq(overview.markers[2].kind, "item", "visible item marker is semantic")
|
||||||
|
T.eq(overview.markers[3].kind, "hidden", "hidden item marker is semantic")
|
||||||
|
|
||||||
|
save.itemsTaken = { TEST_MAP_obj_1 = true }
|
||||||
|
save.hiddenTaken = { TEST_MAP_1_1 = true }
|
||||||
|
overview = api:mapOverview()
|
||||||
|
T.eq(#overview.markers, 1, "collected items disappear from the overview")
|
||||||
|
T.eq(overview.markers[1].kind, "warp", "exits remain after collecting items")
|
||||||
|
|
||||||
map.tileset = { image = "test.png", tilesPerRow = 2 }
|
map.tileset = { image = "test.png", tilesPerRow = 2 }
|
||||||
overview = api:mapOverview()
|
overview = api:mapOverview()
|
||||||
T.eq(overview.tileWidth, 4, "tile overview reports its width")
|
T.eq(overview.tileWidth, 4, "tile overview reports its width")
|
||||||
T.eq(overview.tileHeight, 4, "tile overview reports its height")
|
T.eq(overview.tileHeight, 4, "tile overview reports its height")
|
||||||
T.eq(overview.tileRows[1], "0303", "tile overview preserves map shading")
|
T.eq(overview.tileRows[1], "2323", "tile overview preserves average shading")
|
||||||
|
T.eq(overview.tileDetailWidth, 8, "detail overview reports its width")
|
||||||
|
T.eq(overview.tileDetailHeight, 8, "detail overview reports its height")
|
||||||
|
T.eq(overview.tileDetailRows[1], "03330333",
|
||||||
|
"detail overview preserves top tile quadrants")
|
||||||
|
T.eq(overview.tileDetailRows[2], "12331233",
|
||||||
|
"detail overview preserves bottom tile quadrants")
|
||||||
|
|
||||||
T.finish("world map overview")
|
T.finish("world map overview")
|
||||||
|
|||||||
@@ -428,16 +428,42 @@ local spriteReg = Registry.new("sprites", Schemas.REGISTRIES.sprites)
|
|||||||
spriteReg:register("SPRITE_TITLE_LOGO",
|
spriteReg:register("SPRITE_TITLE_LOGO",
|
||||||
{ image = "mods/logo/logo.png", frames = 1,
|
{ image = "mods/logo/logo.png", frames = 1,
|
||||||
trueColor = true }, "logo_mod")
|
trueColor = true }, "logo_mod")
|
||||||
|
spriteReg:register("SPRITE_LARGE_ACTOR",
|
||||||
|
{ image = "mods/actor/actor.png", frames = 6,
|
||||||
|
walker = true, frameWidth = 32, frameHeight = 24,
|
||||||
|
anchorX = 16, anchorY = 24, trueColor = true },
|
||||||
|
"actor_mod")
|
||||||
local logoDef = spriteReg:get("SPRITE_TITLE_LOGO")
|
local logoDef = spriteReg:get("SPRITE_TITLE_LOGO")
|
||||||
check(Schemas.check(Schemas.REGISTRIES.sprites, "sprites", "SPRITE_TITLE_LOGO",
|
check(Schemas.check(Schemas.REGISTRIES.sprites, "sprites", "SPRITE_TITLE_LOGO",
|
||||||
logoDef, "register"),
|
logoDef, "register"),
|
||||||
"a trueColor sprites record validates against the catalog schema")
|
"a trueColor sprites record validates against the catalog schema")
|
||||||
check(logoDef.trueColor == true, "and keeps the flag through the merge")
|
check(logoDef.trueColor == true, "and keeps the flag through the merge")
|
||||||
|
local largeDef = spriteReg:get("SPRITE_LARGE_ACTOR")
|
||||||
|
check(Schemas.check(Schemas.REGISTRIES.sprites, "sprites", "SPRITE_LARGE_ACTOR",
|
||||||
|
largeDef, "register"),
|
||||||
|
"a variable-size sprites record validates against the catalog schema")
|
||||||
|
|
||||||
Renderer:init()
|
Renderer:init()
|
||||||
local plainSprite = SpriteRenderer.new(
|
local plainSprite = SpriteRenderer.new(
|
||||||
{ image = "assets/generated/sprites/red.png", frames = 1 })
|
{ image = "assets/generated/sprites/red.png", frames = 1 })
|
||||||
local litSprite = SpriteRenderer.new(logoDef)
|
local litSprite = SpriteRenderer.new(logoDef)
|
||||||
|
local largeSprite = SpriteRenderer.new(largeDef)
|
||||||
|
check(plainSprite.frameWidth == 16 and plainSprite.frameHeight == 16
|
||||||
|
and plainSprite.anchorX == 8 and plainSprite.anchorY == 16,
|
||||||
|
"legacy sprite definitions keep the vanilla frame geometry")
|
||||||
|
local frameGeometry = largeSprite:getFrameGeometry(5)
|
||||||
|
check(frameGeometry.frame == 5 and frameGeometry.x == 0
|
||||||
|
and frameGeometry.y == 120 and frameGeometry.width == 32
|
||||||
|
and frameGeometry.height == 24 and frameGeometry.anchorX == 16
|
||||||
|
and frameGeometry.anchorY == 24,
|
||||||
|
"frame geometry exposes a larger sheet rectangle and anchor")
|
||||||
|
local poseGeometry = largeSprite:getPoseGeometry("right", 1, true)
|
||||||
|
check(poseGeometry.frame == 5 and poseGeometry.mirror == true
|
||||||
|
and poseGeometry.quad == largeSprite.frames[5],
|
||||||
|
"pose geometry follows walker frame selection and right mirroring")
|
||||||
|
local originX, originY = largeSprite:getScreenOrigin(32, 32, 0, 0)
|
||||||
|
check(originX == 24 and originY == 20,
|
||||||
|
"a custom anchor keeps a larger sprite grounded at its cell")
|
||||||
|
|
||||||
Renderer:beginFrame(true)
|
Renderer:beginFrame(true)
|
||||||
check(#PaletteFX.trueColorRects("ui") == 0
|
check(#PaletteFX.trueColorRects("ui") == 0
|
||||||
@@ -471,6 +497,27 @@ check(#worldDrawn == 2, "the reported zone joins the world list endFrame blits")
|
|||||||
check(worldDrawn[1].shader and worldDrawn[2].shader == false,
|
check(worldDrawn[1].shader and worldDrawn[2].shader == false,
|
||||||
"the colorized pass runs first, then the sprite's rect with no shader")
|
"the colorized pass runs first, then the sprite's rect with no shader")
|
||||||
|
|
||||||
|
-- Larger true-color frames claim their actual extent, and fishing's top-half
|
||||||
|
-- path reserves only the bottom 8-pixel tile for the overlay.
|
||||||
|
Renderer:beginFrame(true)
|
||||||
|
Renderer:beginWorldPass()
|
||||||
|
largeSprite:draw(32, 32, 0, 0, "down", 0, false)
|
||||||
|
local largeRects = PaletteFX.trueColorRects("world")
|
||||||
|
check(#largeRects == 1 and largeRects[1].x == 24 and largeRects[1].y == 20
|
||||||
|
and largeRects[1].w == 32 and largeRects[1].h == 24,
|
||||||
|
"a larger trueColor sprite reports its full anchored extent")
|
||||||
|
Renderer:endWorldPass()
|
||||||
|
|
||||||
|
Renderer:beginFrame(true)
|
||||||
|
Renderer:beginWorldPass()
|
||||||
|
largeSprite:draw(32, 32, 0, 0, "down", 0, false, true)
|
||||||
|
local topRects = PaletteFX.trueColorRects("world")
|
||||||
|
check(#topRects == 1 and topRects[1].h == 16
|
||||||
|
and largeSprite.halfFrames[0].y == 0
|
||||||
|
and largeSprite.halfFrames[0].h == 16,
|
||||||
|
"the fishing overlay keeps a larger frame's bottom tile clear")
|
||||||
|
Renderer:endWorldPass()
|
||||||
|
|
||||||
-- the same path on the UI canvas, which is where a full-color title logo
|
-- the same path on the UI canvas, which is where a full-color title logo
|
||||||
-- or menu portrait lands
|
-- or menu portrait lands
|
||||||
Renderer:beginFrame(false)
|
Renderer:beginFrame(false)
|
||||||
|
|||||||
@@ -271,6 +271,38 @@ do
|
|||||||
"and nothing is disabled off a failed migration")
|
"and nothing is disabled off a failed migration")
|
||||||
end
|
end
|
||||||
|
|
||||||
|
-- ------- force_enable_env: an env var can override a saved disable
|
||||||
|
-- (src/mods/Loader.lua's enable-resolution block, added for a mod that
|
||||||
|
-- cannot function disabled on the one build where its env var is set --
|
||||||
|
-- e.g. a platform-launcher bridge mod).
|
||||||
|
do
|
||||||
|
local forceFiles = {
|
||||||
|
["options.lua"] = "return { mods = { forced = false } }",
|
||||||
|
["mods/forced/manifest.json"] =
|
||||||
|
[[{"id":"forced","name":"forced","version":"1.0.0","entry":"main.lua",]]
|
||||||
|
.. [["force_enable_env":"SOME_TEST_ENV"}]],
|
||||||
|
["mods/forced/main.lua"] = "return function(mod) end",
|
||||||
|
}
|
||||||
|
|
||||||
|
local realGetenv = os.getenv
|
||||||
|
os.getenv = function(name)
|
||||||
|
if name == "SOME_TEST_ENV" then return "1" end
|
||||||
|
return realGetenv(name)
|
||||||
|
end
|
||||||
|
local onLoader = Loader.new({ fs = memfs(forceFiles) })
|
||||||
|
check(onLoader:load({ pokemon = {} }) == true,
|
||||||
|
"force_enable_env: load succeeds with the env var set")
|
||||||
|
check(onLoader.mods.forced.enabled == true,
|
||||||
|
"a matching force_enable_env re-enables a mod saved as disabled")
|
||||||
|
os.getenv = realGetenv
|
||||||
|
|
||||||
|
local offLoader = Loader.new({ fs = memfs(forceFiles) })
|
||||||
|
check(offLoader:load({ pokemon = {} }) == true,
|
||||||
|
"force_enable_env: load succeeds with the env var unset")
|
||||||
|
check(offLoader.mods.forced.enabled == false,
|
||||||
|
"with the env var unset, the saved disable is left alone")
|
||||||
|
end
|
||||||
|
|
||||||
-- leave shared singletons the way we found them for later chained tests
|
-- leave shared singletons the way we found them for later chained tests
|
||||||
local StateStack = require("src.core.StateStack")
|
local StateStack = require("src.core.StateStack")
|
||||||
while StateStack:top() do StateStack:pop() end
|
while StateStack:top() do StateStack:pop() end
|
||||||
|
|||||||
@@ -105,6 +105,7 @@ local full = Manifest.validate({
|
|||||||
api = 2, profile = "overhaul", permissions = { "network" },
|
api = 2, profile = "overhaul", permissions = { "network" },
|
||||||
dependencies = { "colorlib@^1.2" }, conflicts = { "always_noon" },
|
dependencies = { "colorlib@^1.2" }, conflicts = { "always_noon" },
|
||||||
options_schema = "options.lua", assets_transforms = "transforms.lua",
|
options_schema = "options.lua", assets_transforms = "transforms.lua",
|
||||||
|
force_enable_env = "SOME_ENV",
|
||||||
}, "mods/full")
|
}, "mods/full")
|
||||||
check(full.api == 2 and full.profile == "overhaul", "api and profile parse")
|
check(full.api == 2 and full.profile == "overhaul", "api and profile parse")
|
||||||
check(full.affects_link == true, "overhaul defaults to affecting link play")
|
check(full.affects_link == true, "overhaul defaults to affecting link play")
|
||||||
@@ -115,6 +116,7 @@ check(full.conflictSpecs[1].id == "always_noon" and full.conflictSpecs[1].range
|
|||||||
"a bare conflict entry has no range")
|
"a bare conflict entry has no range")
|
||||||
check(full.options_schema == "options.lua"
|
check(full.options_schema == "options.lua"
|
||||||
and full.assets_transforms == "transforms.lua", "declared files are kept")
|
and full.assets_transforms == "transforms.lua", "declared files are kept")
|
||||||
|
check(full.force_enable_env == "SOME_ENV", "force_enable_env is kept")
|
||||||
|
|
||||||
-- ------- github / experimental / incompatible
|
-- ------- github / experimental / incompatible
|
||||||
local gh = Manifest.validate({
|
local gh = Manifest.validate({
|
||||||
|
|||||||
@@ -823,6 +823,29 @@ do
|
|||||||
and caught.enemy.mon.species == "TANGELA",
|
and caught.enemy.mon.species == "TANGELA",
|
||||||
"and it is the species the authored encounter table names")
|
"and it is the species the authored encounter table names")
|
||||||
|
|
||||||
|
-- Trainer battles do not arm the wild-encounter cooldown.
|
||||||
|
walker.wildEncounterGraceSteps = 0
|
||||||
|
walker:afterBattle("win", { kind = "trainer" })
|
||||||
|
check(walker.wildEncounterGraceSteps == 0,
|
||||||
|
"trainer battles do not start the wild encounter grace period")
|
||||||
|
|
||||||
|
-- pokered grants three completed steps after a wild battle before the
|
||||||
|
-- next random battle can start (end_of_battle.asm + home/overworld.asm).
|
||||||
|
local finishedWild = caught
|
||||||
|
walker:afterBattle("run", finishedWild)
|
||||||
|
caught = nil
|
||||||
|
withBuses(function(_, hooks)
|
||||||
|
hooks:wrap("encounter.roll", function()
|
||||||
|
return { species = "TANGELA", level = 5 }
|
||||||
|
end, 0, "grace-period")
|
||||||
|
for step = 1, 3 do
|
||||||
|
pcall(walker.onStepComplete, walker)
|
||||||
|
check(caught == nil, "wild encounter grace period blocks step " .. step)
|
||||||
|
end
|
||||||
|
pcall(walker.onStepComplete, walker)
|
||||||
|
check(caught ~= nil, "wild encounter is eligible on step 4")
|
||||||
|
end)
|
||||||
|
|
||||||
-- the same walk with an encounter.roll wrapper never starts a battle
|
-- the same walk with an encounter.roll wrapper never starts a battle
|
||||||
withBuses(function(_, hooks)
|
withBuses(function(_, hooks)
|
||||||
hooks:wrap("encounter.roll", function() return nil end, 0, "nuzlocke")
|
hooks:wrap("encounter.roll", function() return nil end, 0, "nuzlocke")
|
||||||
@@ -893,6 +916,76 @@ do
|
|||||||
check(value == nil and err == "no overworld", "npc() off the world")
|
check(value == nil and err == "no overworld", "npc() off the world")
|
||||||
value, err = api:queueScript({})
|
value, err = api:queueScript({})
|
||||||
check(value == nil and err == "no overworld", "queueScript() off the world")
|
check(value == nil and err == "no overworld", "queueScript() off the world")
|
||||||
|
value, err = api:startWildBattle("PIDGEY", 5)
|
||||||
|
check(value == nil and err == "no overworld", "startWildBattle() off the world")
|
||||||
|
end
|
||||||
|
|
||||||
|
-- startWildBattle: what regresses is the handoff, not the battle. A mod that
|
||||||
|
-- builds a BattleState and pushes it itself still fights and still levels; it
|
||||||
|
-- silently loses onFinish -> afterBattle (evolutions, blackout-on-loss) and
|
||||||
|
-- pushBattle (entry wipe, battle theme). Shipped mods have hit exactly this.
|
||||||
|
do
|
||||||
|
-- the real dataset, not fixture(): a battle reaches for type_chart, items,
|
||||||
|
-- battle_anims and more, and this block only reads
|
||||||
|
local data = Data
|
||||||
|
local state, game = liveWorld(data)
|
||||||
|
-- the handoff runs through these three, so each needs the live game
|
||||||
|
for _, fn in ipairs({ "pushBattle", "isDungeonTransitionMap", "afterBattle" }) do
|
||||||
|
check(bindGame(OW[fn], game), fn .. " binds Game")
|
||||||
|
end
|
||||||
|
state:setMap("PALLET_TOWN", 5, 6, "down", { via = "boot" })
|
||||||
|
|
||||||
|
local Pokemon = require("src.pokemon.Pokemon")
|
||||||
|
local api = WorldAPI.new(game, "tester")
|
||||||
|
|
||||||
|
local value, err = api:startWildBattle("NOT_A_MON", 5)
|
||||||
|
check(value == nil and err:find("unknown species", 1, true),
|
||||||
|
"an unknown species refuses and names it")
|
||||||
|
-- Pokemon.new writes the level through into the stat calc and the exp curve
|
||||||
|
-- verbatim, so a fraction has to be refused rather than rounded downstream
|
||||||
|
for _, lv in ipairs({ 0, 101, "nope", 5.5 }) do
|
||||||
|
check(api:startWildBattle("PIDGEY", lv) == nil,
|
||||||
|
"level " .. tostring(lv) .. " refuses")
|
||||||
|
end
|
||||||
|
|
||||||
|
local caterpie = Pokemon.new(data, "CATERPIE", 6)
|
||||||
|
game.save.party = { caterpie }
|
||||||
|
check(api:startWildBattle("PIDGEY", 25) == true, "a wild battle starts")
|
||||||
|
|
||||||
|
-- pushBattle pushes the entry transition, which pushes the battle from its
|
||||||
|
-- own callback; awardExp is the BattleState marker (screenId would not work,
|
||||||
|
-- only Screens.push stamps that and pushBattle pushes the battle directly)
|
||||||
|
check(game.stack:top() ~= nil and game.stack:top().awardExp == nil,
|
||||||
|
"the entry transition goes on first")
|
||||||
|
-- overworld() resolves the world from UNDER the battle, so a second call
|
||||||
|
-- while one is up has to refuse rather than stack another
|
||||||
|
check(api:startWildBattle("PIDGEY", 5) == nil,
|
||||||
|
"a battle already running refuses")
|
||||||
|
|
||||||
|
local battle
|
||||||
|
for _ = 1, 400 do
|
||||||
|
local t = game.stack:top()
|
||||||
|
if t and t.awardExp then battle = t break end
|
||||||
|
if t and t.update then t:update(1 / 60) else break end
|
||||||
|
end
|
||||||
|
check(battle ~= nil, "the transition hands off to the battle")
|
||||||
|
|
||||||
|
battle.participants = { [caterpie] = true }
|
||||||
|
battle:awardExp()
|
||||||
|
check(caterpie.level >= 7, "the mon levels past its evolution threshold")
|
||||||
|
check(battle.leveledUp and battle.leveledUp[caterpie],
|
||||||
|
"awardExp records the level-up for EvolveAfterBattle")
|
||||||
|
|
||||||
|
game.stack:pop()
|
||||||
|
battle.onFinish("win")
|
||||||
|
for _ = 1, 12 do
|
||||||
|
local t = game.stack:top()
|
||||||
|
if not t or t.screenId == "EvolutionState" then break end
|
||||||
|
game.stack:pop()
|
||||||
|
if t.onDone then t.onDone() end
|
||||||
|
end
|
||||||
|
check(game.stack:top() and game.stack:top().screenId == "EvolutionState",
|
||||||
|
"the win reaches the evolution screen")
|
||||||
end
|
end
|
||||||
|
|
||||||
do
|
do
|
||||||
|
|||||||
@@ -0,0 +1,93 @@
|
|||||||
|
-- core.update / core.quit_to_launcher through the public mod API: a
|
||||||
|
-- platform-launcher integration can pause the simulation and veto the
|
||||||
|
-- return-to-launcher decision from a mod, with no main.lua patch.
|
||||||
|
|
||||||
|
package.path = "./?.lua;./?/init.lua;" .. package.path
|
||||||
|
|
||||||
|
local T = require("tests.modkit")
|
||||||
|
local PlatformHooks = require("src.core.PlatformHooks")
|
||||||
|
|
||||||
|
local FIXTURE = {
|
||||||
|
["mods/fix_platform_bridge/manifest.json"] = [[{
|
||||||
|
"id": "fix_platform_bridge",
|
||||||
|
"name": "Fixture Platform Bridge",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"entry": "main.lua",
|
||||||
|
"api": 2
|
||||||
|
}]],
|
||||||
|
["mods/fix_platform_bridge/main.lua"] = [[
|
||||||
|
local mod = ...
|
||||||
|
local paused = false
|
||||||
|
local extraPolls = 0
|
||||||
|
mod.hooks:wrap("core.update", function(nextFn, game, dt)
|
||||||
|
extraPolls = extraPolls + 1
|
||||||
|
if not paused then nextFn(game, dt) end
|
||||||
|
end)
|
||||||
|
mod.hooks:wrap("core.quit_to_launcher", function(nextFn)
|
||||||
|
if os.getenv("FIXTURE_VETO_QUIT") == "1" then return false end
|
||||||
|
return nextFn()
|
||||||
|
end)
|
||||||
|
-- test-only knobs, read back through mod.storage-free globals since
|
||||||
|
-- this fixture never leaves the process
|
||||||
|
_G.__fixturePlatformBridge = {
|
||||||
|
setPaused = function(v) paused = v end,
|
||||||
|
extraPolls = function() return extraPolls end,
|
||||||
|
}
|
||||||
|
]],
|
||||||
|
}
|
||||||
|
|
||||||
|
-- core.update: a subscriber can pause (skip vanilla) and still run every frame
|
||||||
|
do
|
||||||
|
local run = T.sdk.loadMods({ "mods/fix_platform_bridge" },
|
||||||
|
{ fs = T.sdk.memfs(FIXTURE) })
|
||||||
|
T.eq(#run.errors, 0,
|
||||||
|
"the fixture mod loads clean (" .. tostring(run.errors[1]) .. ")")
|
||||||
|
|
||||||
|
local calls = 0
|
||||||
|
local fakeGame = { update = function(self, dt) calls = calls + 1 end }
|
||||||
|
|
||||||
|
_G.__fixturePlatformBridge.setPaused(false)
|
||||||
|
PlatformHooks.update(fakeGame, 1 / 60)
|
||||||
|
T.eq(calls, 1, "unpaused: vanilla Game:update runs")
|
||||||
|
T.eq(_G.__fixturePlatformBridge.extraPolls(), 1,
|
||||||
|
"the subscriber's wrapper runs every frame")
|
||||||
|
|
||||||
|
_G.__fixturePlatformBridge.setPaused(true)
|
||||||
|
PlatformHooks.update(fakeGame, 1 / 60)
|
||||||
|
T.eq(calls, 1, "paused: vanilla Game:update is skipped")
|
||||||
|
T.eq(_G.__fixturePlatformBridge.extraPolls(), 2,
|
||||||
|
"the subscriber keeps polling every frame while paused")
|
||||||
|
|
||||||
|
run.release()
|
||||||
|
_G.__fixturePlatformBridge = nil
|
||||||
|
end
|
||||||
|
|
||||||
|
-- core.quit_to_launcher: a subscriber can veto without the vanilla
|
||||||
|
-- condition ever running, or pass it through unchanged
|
||||||
|
do
|
||||||
|
local run = T.sdk.loadMods({ "mods/fix_platform_bridge" },
|
||||||
|
{ fs = T.sdk.memfs(FIXTURE) })
|
||||||
|
T.eq(#run.errors, 0,
|
||||||
|
"the fixture mod loads clean (" .. tostring(run.errors[1]) .. ")")
|
||||||
|
|
||||||
|
local realGetenv = os.getenv
|
||||||
|
os.getenv = function(name)
|
||||||
|
if name == "FIXTURE_VETO_QUIT" then return "1" end
|
||||||
|
return realGetenv(name)
|
||||||
|
end
|
||||||
|
local vanillaCalls = 0
|
||||||
|
local vetoed = PlatformHooks.quitToLauncher(function()
|
||||||
|
vanillaCalls = vanillaCalls + 1
|
||||||
|
return true
|
||||||
|
end)
|
||||||
|
T.eq(vetoed, false, "a subscriber can veto the return-to-launcher decision")
|
||||||
|
T.eq(vanillaCalls, 0, "a veto never evaluates the vanilla condition")
|
||||||
|
os.getenv = realGetenv
|
||||||
|
|
||||||
|
local passed = PlatformHooks.quitToLauncher(function() return true end)
|
||||||
|
T.eq(passed, true, "with no veto, the vanilla decision passes through unchanged")
|
||||||
|
|
||||||
|
run.release()
|
||||||
|
end
|
||||||
|
|
||||||
|
T.finish("platform_lifecycle_hooks")
|
||||||
@@ -60,6 +60,17 @@ for _, r in ipairs(rows) do
|
|||||||
end
|
end
|
||||||
check(not hasRecord, "CHAMPIONS_ROOM rival script no longer calls record_hall_of_fame")
|
check(not hasRecord, "CHAMPIONS_ROOM rival script no longer calls record_hall_of_fame")
|
||||||
|
|
||||||
|
-- The post-battle walk takes the right-hand detour before heading north, so
|
||||||
|
-- the player does not visibly pass through the rival at (4,2).
|
||||||
|
local route = {}
|
||||||
|
for _, r in ipairs(rows) do
|
||||||
|
if r[1] == "move_player" then route[#route + 1] = r end
|
||||||
|
end
|
||||||
|
eq(route[#route - 1] and route[#route - 1][2], "right",
|
||||||
|
"walk-out route first moves right around the rival")
|
||||||
|
eq(route[#route] and route[#route][2], "up",
|
||||||
|
"walk-out route then heads north to Hall of Fame")
|
||||||
|
|
||||||
-- (3) Commands.face_player_dir sets the player's facing
|
-- (3) Commands.face_player_dir sets the player's facing
|
||||||
local Commands = require("src.script.Commands")
|
local Commands = require("src.script.Commands")
|
||||||
check(type(Commands.face_player_dir) == "function", "Commands.face_player_dir is a function")
|
check(type(Commands.face_player_dir) == "function", "Commands.face_player_dir is a function")
|
||||||
|
|||||||
@@ -75,6 +75,19 @@ do
|
|||||||
check(anyText(tb, "SUBSTITUTE"), "the failure text still prints")
|
check(anyText(tb, "SUBSTITUTE"), "the failure text still prints")
|
||||||
end
|
end
|
||||||
|
|
||||||
|
-- Exact quarter HP is also not enough: accepting it would leave the user at
|
||||||
|
-- 0 HP with substituteHP set, so the next trainer-battle turn cannot advance.
|
||||||
|
do
|
||||||
|
local tb = freshBattle()
|
||||||
|
local cost = math.floor(tb.enemy.mon.stats.hp / 4)
|
||||||
|
tb.enemy.mon.hp = cost
|
||||||
|
tb:performMove(tb.enemy, tb.player, { id = "SUBSTITUTE", pp = 10 }, false)
|
||||||
|
check(tb.enemy.substituteHP == nil and tb.enemy.mon.hp == cost,
|
||||||
|
"exact quarter HP cannot create a zero-HP substitute")
|
||||||
|
check(not anyAnim(tb, "SUBSTITUTE"),
|
||||||
|
"the exact-boundary failure plays no animation")
|
||||||
|
end
|
||||||
|
|
||||||
-- .alreadyHasSubstitute: same, with a doll already standing
|
-- .alreadyHasSubstitute: same, with a doll already standing
|
||||||
do
|
do
|
||||||
local tb = freshBattle()
|
local tb = freshBattle()
|
||||||
|
|||||||
@@ -93,6 +93,7 @@ local titleGame = {
|
|||||||
field = { title = { cycleSpecies = { "PIKACHU" } } } },
|
field = { title = { cycleSpecies = { "PIKACHU" } } } },
|
||||||
}
|
}
|
||||||
local title = TitleState.new(titleGame, {})
|
local title = TitleState.new(titleGame, {})
|
||||||
|
title.phase, title.scy = "loop", 0
|
||||||
local titleSprite, titleTrueColor = title:currentSprite()
|
local titleSprite, titleTrueColor = title:currentSprite()
|
||||||
check(titleSprite and titleTrueColor,
|
check(titleSprite and titleTrueColor,
|
||||||
"title cache keeps a Pokemon sprite's trueColor flag")
|
"title cache keeps a Pokemon sprite's trueColor flag")
|
||||||
|
|||||||
+9
-8
@@ -446,16 +446,17 @@ check(misted2.stages.attack == nil
|
|||||||
and mistMsgs[1]:find("MIST", 1, true) ~= nil,
|
and mistMsgs[1]:find("MIST", 1, true) ~= nil,
|
||||||
"primary stat drop still blocked by MIST")
|
"primary stat drop still blocked by MIST")
|
||||||
|
|
||||||
-- Substitute boundary: built at exactly 1/4 max HP, leaving 0 HP
|
-- Substitute boundary: the move must fail when its quarter-HP cost would
|
||||||
-- (substitute.asm only fails on subtraction underflow)
|
-- consume all current HP, preventing a zero-HP user with a live substitute.
|
||||||
local subUser = { mon = { stats = { hp = 40 }, hp = 10 }, name = "SUBBY" }
|
local subUser = { mon = { stats = { hp = 40 }, hp = 10 }, name = "SUBBY" }
|
||||||
MoveEffects.primary.SUBSTITUTE_EFFECT(sideRng, subUser)
|
local subMsgs = MoveEffects.primary.SUBSTITUTE_EFFECT(sideRng, subUser)
|
||||||
check(subUser.substituteHP ~= nil and subUser.mon.hp == 0,
|
check(subUser.substituteHP == nil and subUser.mon.hp == 10
|
||||||
"substitute built at exactly 1/4 max HP leaves 0 HP")
|
|
||||||
local subUser2 = { mon = { stats = { hp = 40 }, hp = 9 }, name = "SUBBY" }
|
|
||||||
local subMsgs = MoveEffects.primary.SUBSTITUTE_EFFECT(sideRng, subUser2)
|
|
||||||
check(subUser2.substituteHP == nil
|
|
||||||
and subMsgs[1]:find("weak", 1, true) ~= nil,
|
and subMsgs[1]:find("weak", 1, true) ~= nil,
|
||||||
|
"substitute fails at exactly 1/4 max HP")
|
||||||
|
local subUser2 = { mon = { stats = { hp = 40 }, hp = 9 }, name = "SUBBY" }
|
||||||
|
local subMsgs2 = MoveEffects.primary.SUBSTITUTE_EFFECT(sideRng, subUser2)
|
||||||
|
check(subUser2.substituteHP == nil and subUser2.mon.hp == 9
|
||||||
|
and subMsgs2[1]:find("weak", 1, true) ~= nil,
|
||||||
"substitute fails below 1/4 max HP")
|
"substitute fails below 1/4 max HP")
|
||||||
|
|
||||||
-- Haze clears Disable/X ACCURACY on both sides and forfeits the turn of
|
-- Haze clears Disable/X ACCURACY on both sides and forfeits the turn of
|
||||||
|
|||||||
+3
-3
@@ -716,7 +716,7 @@ def run_loader(repo, mod_dir, findings, base="fixture", notes=None):
|
|||||||
driver_path = handle.name
|
driver_path = handle.name
|
||||||
try:
|
try:
|
||||||
proc = subprocess.run([LUAJIT, driver_path], cwd=repo,
|
proc = subprocess.run([LUAJIT, driver_path], cwd=repo,
|
||||||
capture_output=True, text=True, timeout=120)
|
capture_output=True, text=True, encoding="utf-8", timeout=120)
|
||||||
except FileNotFoundError:
|
except FileNotFoundError:
|
||||||
findings.append(Finding("MK100", "error",
|
findings.append(Finding("MK100", "error",
|
||||||
f"cannot run {LUAJIT} (install luajit or "
|
f"cannot run {LUAJIT} (install luajit or "
|
||||||
@@ -1056,7 +1056,7 @@ def check_data_dump(repo, path, base, rel):
|
|||||||
driver = DUMP_DRIVER % (lua_quote(path), lua_quote(vanilla))
|
driver = DUMP_DRIVER % (lua_quote(path), lua_quote(vanilla))
|
||||||
try:
|
try:
|
||||||
proc = subprocess.run([LUAJIT, "-e", driver], cwd=repo,
|
proc = subprocess.run([LUAJIT, "-e", driver], cwd=repo,
|
||||||
capture_output=True, text=True, timeout=60)
|
capture_output=True, text=True, encoding="utf-8", timeout=60)
|
||||||
except FileNotFoundError:
|
except FileNotFoundError:
|
||||||
# the gate must fail closed: a missing interpreter is a broken
|
# the gate must fail closed: a missing interpreter is a broken
|
||||||
# environment, not a clean mod
|
# environment, not a clean mod
|
||||||
@@ -1416,7 +1416,7 @@ def dump_dataset(repo, base):
|
|||||||
handle.close()
|
handle.close()
|
||||||
try:
|
try:
|
||||||
proc = subprocess.run([os.environ.get("LUA", "luajit"), handle.name],
|
proc = subprocess.run([os.environ.get("LUA", "luajit"), handle.name],
|
||||||
cwd=repo, capture_output=True, text=True)
|
cwd=repo, capture_output=True, text=True, encoding="utf-8")
|
||||||
finally:
|
finally:
|
||||||
os.unlink(handle.name)
|
os.unlink(handle.name)
|
||||||
if proc.returncode != 0:
|
if proc.returncode != 0:
|
||||||
|
|||||||
Reference in New Issue
Block a user