Merge branch 'fix1037' into fix1038

# Conflicts:
#	docs/modding.md
This commit is contained in:
bryanthaboi
2026-08-10 14:12:21 -04:00
46 changed files with 1851 additions and 285 deletions
+1 -1
View File
@@ -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 |
| 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/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/TextBox.lua` | dialogue box: typewriter, `\n` line, `\v` scroll, `\f` page |
| | `src/render/Camera.lua`, `Transition.lua` | follow camera, warp fades |
+86
View File
@@ -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
`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
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
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
renderers frame selection and mirror conventions.
## Battle sprite scaling
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
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
(once per frame). Vanilla behavior resolves the per-category GAME SPEED
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
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.
+107
View File
@@ -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`.