Add input.key, input.gamepad, and input.wheel hooks (RFC 0020)

Kanto Companion's Edit Mode can't run during battle -- there's no way
to stop a D-pad press from also driving the battle menu, so the only
option today is pausing the game outright. This adds the same input
precedence a mod had before the sandbox changes: vanilla is the whole
existing callback body, and the hook fires before any of it runs.
input.wheel stays a plain observer like input.pointer, since nothing
depends on suppressing it.
This commit is contained in:
BountyHunterKanden
2026-09-06 11:33:44 -05:00
parent fdd1d61ea6
commit 975dc40d41
7 changed files with 767 additions and 198 deletions
+12 -4
View File
@@ -592,13 +592,21 @@ gains a field instead of the name gaining a prefix.
each row's decision in `evolution.check`. The hook passes `data` where Gen 1
passes `game`; positions 2-4 (mon, row, trigger) match.
- *The frame (`src/core/Game2.lua`):* hooks `input.step`, `input.pointer`,
`render.zones`, `render.compose`, `render.output_enabled`, `render.output`,
`input.key`, `input.gamepad`, `input.wheel` (RFC 0020), `render.zones`,
`render.compose`, `render.output_enabled`, `render.output`,
`render.letterbox`, `render.hud`, `render.viewport`, `render.window`. Each sits
at the same moment `src/core/Game.lua` and `src/render/Renderer.lua` raise it
-- the logic tick before the pad is read, a pointer the touch overlay gets
first refusal on, the palette zone list handed to the present pass, the
composed frame before ShaderFX, the letterbox, and the finished playfield rect
-- and carries the same payload.
first refusal on, a key/gamepad/wheel event before any of this method's own
hotkey or capture logic runs, the palette zone list handed to the present
pass, the composed frame before ShaderFX, the letterbox, and the finished
playfield rect -- and carries the same payload. `input.key`/`input.gamepad`
differ from the others in one way worth flagging: their `vanilla` argument
is this method's *entire* pre-existing body, not a stub, so a wrapper that
never calls `next` prevents that body from running for this event
(top-of-stack capture, hotkeys, `Input:*`, all of it) -- see RFC 0020 for
why that's a deliberate departure from `input.pointer`'s (inert) consume
contract, with real precedent from before the sandbox changes.
`render.hud`'s `gameX` / `gameY` really is where Gold's dialogue boxes and
menus land, because `Chrome.fitScale` / `fitOrigin` and `World:fitScale`
compute the same number. `render.zones` is handed `nil` in GBC mode (Gold
+31
View File
@@ -1035,6 +1035,37 @@ a mobile touch fires once), and focus or visibility loss and input recovery
deliver a `"cancelled"` for every pointer the hook saw pressed but not yet
released. Return `true` without calling `next` to consume the event.
`input.key` (RFC 0020) and `input.gamepad` (RFC 0020) deliver raw keyboard
and gamepad events *before* anything else in the engine sees them --
before the top-of-stack capture a screen like the bindings menu uses,
before every built-in hotkey (save/load, zoom, speed, color/tilt display
cycling), before the button reaches `Input`. The callback receives
`(next, game, ev)`. For `input.key`, `ev` is `{ phase, key }` with `phase`
`"pressed"` or `"released"` and `key` the same string `love.keypressed`/
`love.keyreleased` reports. For `input.gamepad`, `ev` is `{ phase, joystick,
button, axis, value }` with `phase` `"pressed"`, `"released"`, or `"axis"`;
`button` is set on `"pressed"`/`"released"` and nil on `"axis"`; `axis` and
`value` are set on `"axis"` and nil otherwise. Unlike `input.pointer`,
calling `next(game, ev)` here is not optional decoration: `next` runs the
engine's own handling for this event in full (the capture, the hotkeys,
`Input:keypressed`/`Input:gamepadpressed`), and a wrapper that returns
without calling it prevents all of that -- the key or button never
reaches the game this frame. This mirrors the precedence a mod always had
before the sandbox changes, when a mod could assign `love.keypressed`/
`love.gamepadpressed` directly; there is no separate "vanilla ran anyway"
outcome to opt out of, so wrap sparingly and only while you actually need
to own the input (e.g. while your own UI is capturing keyboard/gamepad
navigation), calling `next` immediately otherwise.
`input.wheel` (RFC 0020) delivers the mouse wheel. The callback receives
`(next, game, dy)`, where `dy` is the same vertical delta
`love.wheelmoved`'s second argument carries (the engine does not currently
read the horizontal delta either). Contract-identical to `input.pointer`:
return `true` without calling `next` to consume. As with `input.pointer`,
no current engine behavior reads what the hook chain returns -- "consume"
here means "stop other mods in the chain from also seeing this event," not
"suppress the zoom," which today has no way to be gated at all.
`mod.input` presses GB buttons source-safely. `mod.input:tap(game, btn)`
queues exactly one `wasPressed` edge for the next fixed step and holds
nothing; `local token = mod.input:press(game, btn)` holds the button until
@@ -0,0 +1,214 @@
# RFC 0020: Keyboard, gamepad, and wheel input hooks (`input.key`, `input.gamepad`, `input.wheel`)
## Status
Proposed.
## Motivation
`input.pointer` gives a mod one consume-capable seam for mouse and touch.
Keyboard, gamepad, and the scroll wheel have no equivalent. A mod can
already *read* them (`love.keyboard`/`love.joystick` stay open under the
sandbox), but polling can only read: it can never stop the base game from
also acting on the same key or button.
The immediate consumer is Kanto Companion's overlay Edit Mode, which drags
and resizes panels with the D-pad, the left stick, and A, the same inputs
that move the player and drive the battle menu. Before the sandbox
changes (83682f01), a mod ran in the real global environment and could
just reassign `love.gamepadpressed` and friends directly, giving its own
dispatch first refusal before calling the saved original: real
suppression, not just observation. The sandbox changes closed off
writable `love.*` globals, rightly, but nothing replaced that one
capability. The only way left to keep a mod's nav input from also
reaching the game is to pause it outright, which is what Edit Mode does
today, gated to the overworld only since pausing a live battle would
freeze it. That gate isn't a design choice: Edit Mode can't run during
battle because there's no other way to stop a D-pad press from also being
read as a battle-menu press.
## The decision it extends
This finishes the `input.pointer` family for mouse and touch, giving
keyboard, gamepad, and wheel the same kind of seam. It doesn't change
`input.pointer`'s contract, `input.step`'s contract, or `Input.lua`'s
existing key/button remapping.
There is no in-repo D-number registry to amend.
## Exact API delta
### Where these differ from `input.pointer`, and why that's deliberate
`input.pointer`'s "return true to consume" is, today, provably inert: none
of its 14 real call sites read what `Game:pointerEvent` returns, because
its vanilla callback is a no-op stub, so there's no gameplay behavior to
gate.
Keyboard and gamepad are the opposite: they drive real, load-bearing
behavior (movement, menus, battle input, save/load, zoom, speed, tilt,
display hotkeys). Mirroring `input.pointer` mechanically (firing the hook
as a side observation and then running `Input:keypressed`/
`Input:gamepadpressed` regardless) would make "consume" just as inert
here, and wouldn't restore what Edit Mode actually needs: stopping a D-pad
press from also walking the player. So `input.key` and `input.gamepad` use
a stronger contract instead. `vanilla` is a closure over the entire
existing method body: top-of-stack capture, the hotkey ladder, the final
`Input:*` call, all of it. The hook fires as the first thing the method
does, and a wrapper that returns without calling `next(...)` stops all of
that from running for this event, same precedence a mod's own global
reassignment always had before the sandbox changes. Nothing new is being
granted, just the old precedence, reachable through a hook instead of a
global clobber.
`input.wheel` has no such tension: it only ever drives camera zoom, and
nothing depends on suppressing it, so it stays a plain observer,
contract-identical to `input.pointer`.
### New hook: `input.key`
```lua
mod.hooks:wrap("input.key", function(next, game, ev)
-- ev = { phase, key } -- phase: "pressed" | "released"
if editingPanel and ev.phase == "pressed" and NAV_KEYS[ev.key] then
handlePanelNav(ev.key)
return -- swallowed: the player's own keypressed handling never runs
end
return next(game, ev)
end)
```
`ev` carries exactly what `Game:keypressed`/`Game:keyreleased` already
receive: `main.lua` already drops LÖVE's `scancode`/`isrepeat` before
either method sees them, so this doesn't add them. A later RFC can widen
the payload if something needs those two fields.
Raised from the very first line of `Game:keypressed`/`Game:keyreleased` and
their `Game2` equivalents, before the top-of-stack capture
(`stack:top().onKeyPressed`), before the engine's own hotkey ladder (F1/F2/
F5/F10, `-`/`=` zoom, 1-4 display hotkeys), and before `Input:keypressed`/
`Input:keyreleased`. `vanilla` is a closure over that entire existing method
body. `game` is the live `Game`/`Game2` instance, same object `input.pointer`
already hands a mod.
### New hook: `input.gamepad`
```lua
mod.hooks:wrap("input.gamepad", function(next, game, ev)
-- ev = { phase, joystick, button, axis, value }
-- phase: "pressed" | "released" | "axis"
-- button is nil on "axis"; axis/value are nil on "pressed"/"released"
if editingPanel then
if ev.phase == "axis" and (ev.axis == "leftx" or ev.axis == "lefty") then
updateStickCursor(ev.axis, ev.value)
return
end
if ev.phase == "pressed" and ev.button == "a" then
grabOrDropPanel()
return
end
end
return next(game, ev)
end)
```
Unlike `input.pointer`, this covers three raw LÖVE callbacks, not one:
`gamepadpressed`, `gamepadreleased`, and `gamepadaxis`, because the
motivating use case (a stick-driven cursor for touch-free panel dragging)
needs raw analog axis values, which `stack:top().onGamepadPressed`-style
button capture cannot deliver at all. Each of the three raw callbacks in
`Game.lua`/`Game2.lua` raises `input.gamepad` as its first line, `vanilla`
wrapping that callback's entire existing body (the Select-chord intercept,
the shoulder-button speed cycling, the top-of-stack capture, `Input:*`),
the same "mod runs before everything, including the engine's own
top-of-stack capture" precedence as `input.key`, for the same reason.
### New hook: `input.wheel`
```lua
mod.hooks:wrap("input.wheel", function(next, game, dy)
if scrollingOwnList then
scrollList(dy)
return
end
return next(game, dy)
end)
```
Raised from `Game:wheelmoved`/`Game2:wheelmoved`, `vanilla` wrapping the
existing zoom-step body. Contract-identical to `input.pointer`: return
`true` without calling `next` to consume, which only arbitrates between
mods in the chain; no engine behavior reads the outer return value.
`dy` is the raw LÖVE wheel delta, matching `love.wheelmoved`'s own second
argument (the engine's callback already drops the first, horizontal,
argument, and this RFC doesn't change that).
### Call-site summary
| Hook | Files | Existing callbacks wrapped |
|---|---|---|
| `input.key` | `Game.lua`, `Game2.lua` | `keypressed`, `keyreleased` |
| `input.gamepad` | `Game.lua`, `Game2.lua` | `gamepadpressed`, `gamepadreleased`, `gamepadaxis` |
| `input.wheel` | `Game.lua`, `Game2.lua` | `wheelmoved` |
All three are guarded by `ModRuntime.wantsHook(name)` before any payload
table is built, same as `input.pointer`, so a mod-free boot allocates
nothing new here.
### On overriding the engine's own top-of-stack capture
Because `input.key`/`input.gamepad` fire before `stack:top().onKeyPressed`/
`onGamepadPressed`, a mod can swallow input an engine screen like
`BindingsMenu` would otherwise have captured: the same precedence every
mod already had before the sandbox changes. Nothing uses either hook yet,
so nothing regresses; reviewing a specific mod's use of it just means
checking it isn't swallowing input it shouldn't.
## Migration and compatibility
Nothing changes for existing mods, manifests, or any currently-shipping
hook. `input.key`, `input.gamepad`, and `input.wheel` are additive only:
new hook names with no prior meaning. `mod.hooks:wrap("input.key", fn)` is
already mechanically callable today (`Hooks:wrap` validates only that `name`
is a non-empty string); it simply never fires until this RFC's engine-side
call sites land.
## Verification
- `tests/modkit/cases/key_gamepad_wheel_input.lua` (new), through the public
mod API via `T.sdk.loadMods(...)` against a `fakeGame` the way
`tests/modkit/cases/pointer_input.lua` already does for `input.pointer`:
- `input.key`: pressed/released sequences with the `phase`/`key` payload
shape confirmed, a mod returning without calling `next` prevents a
synthetic `Input:keypressed` spy from being called; a mod calling
`next` lets it through; no-mod-installed still calls it
(vanilla-through).
- `input.gamepad`: press/release/axis all reach the hook; a swallowed
press never reaches `Input:gamepadpressed`; an axis event carries
`axis`/`value` and no `button`.
- `input.wheel`: delta reaches the hook; consuming it stops the zoom-step
spy from firing; no-mod-installed still zooms.
- A no-mod-installed pass for all three confirming `wantsHook(...) ==
false` and nothing new gets allocated (mirrors `pointer_input.lua`'s own
first case).
- `tests/engine/gate_hooks.lua`: no edits needed, it auto-covers any hook
name the source-scanning catalog discovers, provided the vanilla callback
follows the same call-once/return-through/error-propagate contract every
existing hook already gets from `Hooks:call`, which these do.
- `tests/engine/gate_gen2_mod_api.lua`: `input.key`, `input.gamepad`, and
`input.wheel` each have call sites in both a `gen2`-pathed file
(`Game2.lua`) and a non-`gen2` file (`Game.lua`), so all three need an
explicit entry in that gate's `GEN2_HOOKS` list (next to `"input.step",
"input.pointer"`), or the gate fails naming them.
- `docs/mod-api-gen2-compat.md`: a line in "Hooks and events that fire on
Gold" → "The frame" bullet, next to the existing `input.step`/
`input.pointer` entry.
- `docs/modding.md`: new paragraphs in the existing "Tool input and
title-menu hooks" section (it already mixes `input.step`, `input.pointer`,
`mod.input`, and `ui.title_menu.items`; these three are the same family
of raw-input seam), each citing "(RFC 0020)" per the convention
`pokemon.level_visible (RFC 0019)` established.
## Deprecation etiquette
Nothing is removed, renamed, or superseded.
+198 -138
View File
@@ -767,12 +767,19 @@ function Game:zoomStep(delta)
end
end
-- RFC 0020: input.wheel is a plain observer, contract-identical to
-- input.pointer -- nothing else depends on suppressing a wheel event, so
-- there is no real behavior to gate here, only to watch.
function Game:wheelmoved(_, dy)
if dy > 0 then
self:zoomStep(1)
elseif dy < 0 then
self:zoomStep(-1)
local function vanilla()
if dy > 0 then
self:zoomStep(1)
elseif dy < 0 then
self:zoomStep(-1)
end
end
if not ModRuntime.wantsHook("input.wheel") then return vanilla() end
return ModRuntime.call("input.wheel", vanilla, self, dy)
end
function Game:_cycleSpeed(dir)
@@ -800,102 +807,119 @@ function Game:_cycleSpeed(dir)
self:writeOptions()
end
-- RFC 0020: input.key fires before ANY of this method's own logic runs --
-- including the top-of-stack capture two lines down, which is itself an
-- engine-owned "first refusal" system (BindingsMenu et al). This mirrors
-- the exact precedence a mod had before the sandbox changes (commit
-- 83682f01), when a mod's Lua chunk ran with the real, unsandboxed love.*
-- globals and could simply reassign love.keypressed itself -- there was
-- nothing an engine-internal capture could do to go first, because the mod
-- WAS the entry point. `vanilla` below is this method's entire pre-RFC-0020
-- body, unchanged; a mod that returns without calling `next(...)` prevents
-- every bit of it -- the stack capture, every hotkey, Input:keypressed --
-- from running for this key at all, same as the old global override always
-- could. See RFC 0020's Motivation section for the comparison this was
-- cross-referenced against.
function Game:keypressed(key)
if self.stack and self.stack:top() and self.stack:top().onKeyPressed then
self.stack:top():onKeyPressed(key)
return
end
if devMode and key == "f5" then
require("src.dev.HotReload").run(self)
return
end
if devMode and key == "`" then
self.stack:push(require("src.dev.Console").new(self))
return
end
if key == "f10" then
-- toggle: the manager no longer swallows the keyboard, so a second
-- press reaches this branch and closes it instead of stacking another
local top = self.stack:top()
if top and top.screenId == "ManagerState" then
self.stack:pop()
else
Screens.push(self, "ManagerState")
local function vanilla()
if self.stack and self.stack:top() and self.stack:top().onKeyPressed then
self.stack:top():onKeyPressed(key)
return
end
return
end
if key == "f1" then
if not self:quickSaveAllowed() then return end
self:writeSave()
return
elseif key == "f2" then
if not self:quickSaveAllowed() then return end
local loaded, recovered = SaveData.load()
if loaded then
-- F2 jumps straight to the loaded save's map/position, with no
-- walking transition -- a hard state teleport like Continue, not a
-- smooth warp -- whether pressed at the title screen or mid-session.
self:restoreSave(loaded, recovered, { freshBoot = true })
if devMode and key == "f5" then
require("src.dev.HotReload").run(self)
return
end
return
elseif key == "-" then
self:zoomStep(-1)
return
elseif key == "=" then
self:zoomStep(1)
return
elseif key == "1" then
-- cycle GAME SPEED (0.25X → 200X, logic only; audio unaffected);
-- shoulders/triggers on gamepad do the same (see gamepadpressed)
self:_cycleSpeed(1)
return
elseif key == "2" then
-- cycle COLORS (GBC / OG / OG INV / GBC INV / CLASSIC); the pack change
-- forces Game.overworld:reloadMap, which rebuilds the live NPC array, so
-- hold it while a warp/transition or an on-screen scripted cutscene is
-- driving the overworld rather than tear the escort's NPCs out mid-move
local ow = self.overworld
local top = self.stack:top()
local busy = ow and (ow.transitioning
or (top == ow and (
(ow.runner and ow.runner.isRunning and ow.runner:isRunning())
or (ow.scriptMoves and #ow.scriptMoves > 0)
or ow.engaging or ow.emote)))
if not busy then
local PaletteFX = require("src.render.PaletteFX")
self.save.options.colors = PaletteFX.cycleMode()
if devMode and key == "`" then
self.stack:push(require("src.dev.Console").new(self))
return
end
if key == "f10" then
-- toggle: the manager no longer swallows the keyboard, so a second
-- press reaches this branch and closes it instead of stacking another
local top = self.stack:top()
if top and top.screenId == "ManagerState" then
self.stack:pop()
else
Screens.push(self, "ManagerState")
end
return
end
if key == "f1" then
if not self:quickSaveAllowed() then return end
self:writeSave()
return
elseif key == "f2" then
if not self:quickSaveAllowed() then return end
local loaded, recovered = SaveData.load()
if loaded then
-- F2 jumps straight to the loaded save's map/position, with no
-- walking transition -- a hard state teleport like Continue, not a
-- smooth warp -- whether pressed at the title screen or mid-session.
self:restoreSave(loaded, recovered, { freshBoot = true })
end
return
elseif key == "-" then
self:zoomStep(-1)
return
elseif key == "=" then
self:zoomStep(1)
return
elseif key == "1" then
-- cycle GAME SPEED (0.25X → 200X, logic only; audio unaffected);
-- shoulders/triggers on gamepad do the same (see gamepadpressed)
self:_cycleSpeed(1)
return
elseif key == "2" then
-- cycle COLORS (GBC / OG / OG INV / GBC INV / CLASSIC); the pack change
-- forces Game.overworld:reloadMap, which rebuilds the live NPC array, so
-- hold it while a warp/transition or an on-screen scripted cutscene is
-- driving the overworld rather than tear the escort's NPCs out mid-move
local ow = self.overworld
local top = self.stack:top()
local busy = ow and (ow.transitioning
or (top == ow and (
(ow.runner and ow.runner.isRunning and ow.runner:isRunning())
or (ow.scriptMoves and #ow.scriptMoves > 0)
or ow.engaging or ow.emote)))
if not busy then
local PaletteFX = require("src.render.PaletteFX")
self.save.options.colors = PaletteFX.cycleMode()
self:writeOptions()
end
return
elseif key == "3" then
-- cycle TILT OFF → 15 → 35 → 50 → OFF (mnemonic: 3D), free-roam only
local Tilt = require("src.render.Tilt")
if Tilt.gateOK(self.stack:top(), self.overworld) then
self.save.options.tilt = Tilt.cycle()
self:writeOptions()
end
return
elseif key == "4" then
-- cycle ZOOM through every integer level (survey → FIT → close-up → wrap)
local Zoom = require("src.render.Zoom")
if Zoom.gateOK(self.stack:top(), self.overworld) then
self.save.options.zoom = Zoom.cycle(Renderer:fitScale())
self:writeOptions()
end
return
end
-- Mod render pipelines claim their hotkeys last, so one can never shadow
-- an engine display key however a mod declares it (12 §rendering
-- pipelines). syncOptions writes the whole ladder back, including the
-- tilt exclusion a world pipeline forces.
local Pipelines = require("src.render.Pipelines")
if Pipelines.hotkey(key, self.stack:top(), self.overworld) then
Pipelines.syncOptions(self.save.options)
require("src.render.Tilt").setLevel(self.save.options.tilt or 0)
self:writeOptions()
return
end
return
elseif key == "3" then
-- cycle TILT OFF → 15 → 35 → 50 → OFF (mnemonic: 3D), free-roam only
local Tilt = require("src.render.Tilt")
if Tilt.gateOK(self.stack:top(), self.overworld) then
self.save.options.tilt = Tilt.cycle()
self:writeOptions()
end
return
elseif key == "4" then
-- cycle ZOOM through every integer level (survey → FIT → close-up → wrap)
local Zoom = require("src.render.Zoom")
if Zoom.gateOK(self.stack:top(), self.overworld) then
self.save.options.zoom = Zoom.cycle(Renderer:fitScale())
self:writeOptions()
end
return
Input:keypressed(key)
end
-- Mod render pipelines claim their hotkeys last, so one can never shadow
-- an engine display key however a mod declares it (12 §rendering
-- pipelines). syncOptions writes the whole ladder back, including the
-- tilt exclusion a world pipeline forces.
local Pipelines = require("src.render.Pipelines")
if Pipelines.hotkey(key, self.stack:top(), self.overworld) then
Pipelines.syncOptions(self.save.options)
require("src.render.Tilt").setLevel(self.save.options.tilt or 0)
self:writeOptions()
return
end
Input:keypressed(key)
if not ModRuntime.wantsHook("input.key") then return vanilla() end
return ModRuntime.call("input.key", vanilla, self, { phase = "pressed", key = key })
end
-- Mod enablement is stored with persistent options. Restarting the actual
@@ -911,64 +935,100 @@ end
-- exists for). The top state only OBSERVES the release afterwards,
-- unlike onKeyPressed above which owns the press, so BindingsMenu can
-- commit a capture on the key-up (#589).
-- RFC 0020: same "fires before everything, vanilla is the untouched whole
-- body" contract as Game:keypressed above -- including here, before
-- Input:keyreleased. Before the sandbox changes, a mod's love.keyreleased
-- override had this same absolute precedence; #589's own "release must
-- still reach Input even under a top-state capture" hazard was about the
-- ENGINE's internal capture ladder, not about a mod -- mods could already
-- swallow a release outright before the sandbox changes (kanto_companion's own
-- Game:gamepadreleased-era override did exactly this for its active-drag
-- case, see RFC 0020), so a mod choosing to do the same here via
-- input.key is restoring that power, not introducing a new hazard class.
function Game:keyreleased(key)
Input:keyreleased(key)
local top = self.stack and self.stack:top()
if top and top.onKeyReleased then top:onKeyReleased(key) end
local function vanilla()
Input:keyreleased(key)
local top = self.stack and self.stack:top()
if top and top.onKeyReleased then top:onKeyReleased(key) end
end
if not ModRuntime.wantsHook("input.key") then return vanilla() end
return ModRuntime.call("input.key", vanilla, self, { phase = "released", key = key })
end
-- RFC 0020: input.gamepad covers press/release/axis (see this method,
-- Game:gamepadreleased, and Game:gamepadaxis below) -- three raw callbacks
-- feeding one hook, because the motivating use case (a stick-driven
-- cursor) needs raw axis values a button-only hook could never deliver.
-- Same "fires first, vanilla is the whole untouched body" contract as
-- Game:keypressed; see its comment and RFC 0020 for the precedent this
-- restores.
function Game:gamepadpressed(joystick, button)
-- a controller is being used: the touch overlay steps aside until the
-- next screen touch (mobile only; a no-op elsewhere)
TouchControls:noteGamepad()
-- Select held? Needed both to suppress shoulder speed hotkeys (Select+L
-- is a display chord on NX) and for the chord path below.
local selectHeld = Input:isDown("select")
if not selectHeld and joystick and joystick.isGamepadDown then
local ok, down = pcall(function()
return joystick:isGamepadDown("back")
end)
selectHeld = ok and down == true
end
local top = self.stack and self.stack:top()
if top and top.onGamepadPressed then
top:onGamepadPressed(button)
return
end
if not selectHeld then
local action = Input:padAction(button)
if action == "speedUp" then
self:_cycleSpeed(1)
return
elseif action == "speedDown" then
self:_cycleSpeed(-1)
local function vanilla()
-- a controller is being used: the touch overlay steps aside until the
-- next screen touch (mobile only; a no-op elsewhere)
TouchControls:noteGamepad()
-- Select held? Needed both to suppress shoulder speed hotkeys (Select+L
-- is a display chord on NX) and for the chord path below.
local selectHeld = Input:isDown("select")
if not selectHeld and joystick and joystick.isGamepadDown then
local ok, down = pcall(function()
return joystick:isGamepadDown("back")
end)
selectHeld = ok and down == true
end
local top = self.stack and self.stack:top()
if top and top.onGamepadPressed then
top:onGamepadPressed(button)
return
end
end
-- Select+face display chords → same digit path as Game:keypressed
-- (COLORS/TILT/pipelines). Intercept before Input so face does not
-- also fire GB A/B. Dual-path: raw already ignored when isGamepad().
if selectHeld then
local digit = GamepadMap.displayChordDigit(button)
if digit then
self:keypressed(digit)
return
if not selectHeld then
local action = Input:padAction(button)
if action == "speedUp" then
self:_cycleSpeed(1)
return
elseif action == "speedDown" then
self:_cycleSpeed(-1)
return
end
end
-- Select+face display chords → same digit path as Game:keypressed
-- (COLORS/TILT/pipelines). Intercept before Input so face does not
-- also fire GB A/B. Dual-path: raw already ignored when isGamepad().
if selectHeld then
local digit = GamepadMap.displayChordDigit(button)
if digit then
self:keypressed(digit)
return
end
end
Input:gamepadpressed(joystick, button)
end
Input:gamepadpressed(joystick, button)
if not ModRuntime.wantsHook("input.gamepad") then return vanilla() end
return ModRuntime.call("input.gamepad", vanilla, self,
{ phase = "pressed", joystick = joystick, button = button })
end
function Game:gamepadreleased(joystick, button)
-- same observe-after-Input contract as Game:keyreleased (#589)
Input:gamepadreleased(joystick, button)
local top = self.stack and self.stack:top()
if top and top.onGamepadReleased then top:onGamepadReleased(button) end
local function vanilla()
-- same observe-after-Input contract as Game:keyreleased (#589)
Input:gamepadreleased(joystick, button)
local top = self.stack and self.stack:top()
if top and top.onGamepadReleased then top:onGamepadReleased(button) end
end
if not ModRuntime.wantsHook("input.gamepad") then return vanilla() end
return ModRuntime.call("input.gamepad", vanilla, self,
{ phase = "released", joystick = joystick, button = button })
end
function Game:gamepadaxis(joystick, axis, value)
-- past-deadzone only, so resting-stick drift can't hide the overlay
if math.abs(value) > 0.5 then TouchControls:noteGamepad() end
Input:gamepadaxis(joystick, axis, value)
local function vanilla()
-- past-deadzone only, so resting-stick drift can't hide the overlay
if math.abs(value) > 0.5 then TouchControls:noteGamepad() end
Input:gamepadaxis(joystick, axis, value)
end
if not ModRuntime.wantsHook("input.gamepad") then return vanilla() end
return ModRuntime.call("input.gamepad", vanilla, self,
{ phase = "axis", joystick = joystick, axis = axis, value = value })
end
local isAccelerometer = GamepadMap.isAccelerometer
+91 -56
View File
@@ -2052,32 +2052,49 @@ function Game2:pipelineHotkey(key, options, persist)
return true
end
-- RFC 0020: see Game:keypressed's own comment (src/core/Game.lua) for the
-- full precedent this restores -- fires before self:hotkey,
-- before Input:keypressed, before anything else in this method.
function Game2:keypressed(key)
-- Escape is NOT a quit key: src/core/Input.lua binds it to START, which is
-- how the start menu opens on a desktop keyboard. Quitting is the start
-- menu's QUIT row and the intro menu's EXIT GAME.
-- A screen that is open owns the keyboard, the same way Game hands the top
-- state first refusal -- except for the display ladder, which is a host
-- control rather than a game button. It runs during the boot cinema too:
-- the title screen and the intro menu are exactly where someone tries the
-- COLOR key, and the ladder's world-only rungs already refuse themselves
-- when there is no map.
if self:hotkey(key) then return end
Input:keypressed(key)
local function vanilla()
-- Escape is NOT a quit key: src/core/Input.lua binds it to START, which is
-- how the start menu opens on a desktop keyboard. Quitting is the start
-- menu's QUIT row and the intro menu's EXIT GAME.
-- A screen that is open owns the keyboard, the same way Game hands the top
-- state first refusal -- except for the display ladder, which is a host
-- control rather than a game button. It runs during the boot cinema too:
-- the title screen and the intro menu are exactly where someone tries the
-- COLOR key, and the ladder's world-only rungs already refuse themselves
-- when there is no map.
if self:hotkey(key) then return end
Input:keypressed(key)
end
if not ModRuntime.wantsHook("input.key") then return vanilla() end
return ModRuntime.call("input.key", vanilla, self, { phase = "pressed", key = key })
end
function Game2:keyreleased(key)
Input:keyreleased(key)
local function vanilla()
Input:keyreleased(key)
end
if not ModRuntime.wantsHook("input.key") then return vanilla() end
return ModRuntime.call("input.key", vanilla, self, { phase = "released", key = key })
end
-- RFC 0020: input.wheel is a plain observer -- see Game:wheelmoved's own
-- comment (src/core/Game.lua).
function Game2:wheelmoved(_x, dy)
if self.phase == "boot" or self.stack:top() then return end
if not (self.world and self.world.map) then return end
if dy > 0 then
self.world:zoomStep(1)
elseif dy < 0 then
self.world:zoomStep(-1)
local function vanilla()
if self.phase == "boot" or self.stack:top() then return end
if not (self.world and self.world.map) then return end
if dy > 0 then
self.world:zoomStep(1)
elseif dy < 0 then
self.world:zoomStep(-1)
end
end
if not ModRuntime.wantsHook("input.wheel") then return vanilla() end
return ModRuntime.call("input.wheel", vanilla, self, dy)
end
-- ---- the gameplay pointer seam (#807) --------------------------------------
@@ -2304,55 +2321,73 @@ end
-- the PACK's move-item, the party menu's reorder and half the soft-reset chord
-- (A+B+SELECT+START) were all unreachable from a pad, and pressing the button
-- to find out killed the process. It reaches Input like every other button now.
-- RFC 0020: input.gamepad covers press/release/axis, see Game:gamepadpressed's
-- own comment (src/core/Game.lua) for why, and for the precedent this
-- restores.
function Game2:gamepadpressed(joystick, button)
-- a controller is being used: the touch overlay steps aside until the next
-- screen touch (mobile only; a no-op elsewhere)
TouchControls:noteGamepad()
local selectHeld = Input:isDown("select")
if not selectHeld and joystick and joystick.isGamepadDown then
local ok, down = pcall(function()
return joystick:isGamepadDown("back")
end)
selectHeld = ok and down == true
end
local top = self.stack and self.stack:top()
if top and top.onGamepadPressed then
top:onGamepadPressed(button)
return
end
if not selectHeld then
local action = Input:padAction(button)
if action == "speedUp" then
self:_cycleSpeed(1)
return
elseif action == "speedDown" then
self:_cycleSpeed(-1)
local function vanilla()
-- a controller is being used: the touch overlay steps aside until the next
-- screen touch (mobile only; a no-op elsewhere)
TouchControls:noteGamepad()
local selectHeld = Input:isDown("select")
if not selectHeld and joystick and joystick.isGamepadDown then
local ok, down = pcall(function()
return joystick:isGamepadDown("back")
end)
selectHeld = ok and down == true
end
local top = self.stack and self.stack:top()
if top and top.onGamepadPressed then
top:onGamepadPressed(button)
return
end
end
if selectHeld then
local digit = GamepadMap.displayChordDigit(button)
if digit then
self:keypressed(digit)
return
if not selectHeld then
local action = Input:padAction(button)
if action == "speedUp" then
self:_cycleSpeed(1)
return
elseif action == "speedDown" then
self:_cycleSpeed(-1)
return
end
end
end
-- START opens the start menu in the overworld; it used to quit, from before
-- there was a menu to open.
if selectHeld then
local digit = GamepadMap.displayChordDigit(button)
if digit then
self:keypressed(digit)
return
end
end
-- START opens the start menu in the overworld; it used to quit, from before
-- there was a menu to open.
Input:gamepadpressed(joystick, button)
Input:gamepadpressed(joystick, button)
end
if not ModRuntime.wantsHook("input.gamepad") then return vanilla() end
return ModRuntime.call("input.gamepad", vanilla, self,
{ phase = "pressed", joystick = joystick, button = button })
end
function Game2:gamepadreleased(joystick, button)
Input:gamepadreleased(joystick, button)
local top = self.stack and self.stack:top()
if top and top.onGamepadReleased then top:onGamepadReleased(button) end
local function vanilla()
Input:gamepadreleased(joystick, button)
local top = self.stack and self.stack:top()
if top and top.onGamepadReleased then top:onGamepadReleased(button) end
end
if not ModRuntime.wantsHook("input.gamepad") then return vanilla() end
return ModRuntime.call("input.gamepad", vanilla, self,
{ phase = "released", joystick = joystick, button = button })
end
function Game2:gamepadaxis(joystick, axis, value)
-- past-deadzone only, so resting-stick drift cannot hide the overlay
if math.abs(value) > 0.5 then TouchControls:noteGamepad() end
Input:gamepadaxis(joystick, axis, value)
local function vanilla()
-- past-deadzone only, so resting-stick drift cannot hide the overlay
if math.abs(value) > 0.5 then TouchControls:noteGamepad() end
Input:gamepadaxis(joystick, axis, value)
end
if not ModRuntime.wantsHook("input.gamepad") then return vanilla() end
return ModRuntime.call("input.gamepad", vanilla, self,
{ phase = "axis", joystick = joystick, axis = axis, value = value })
end
-- The raw joystick road, same bodies as src/core/Game.lua:935 (#620, #632, #1570).
+4
View File
@@ -445,6 +445,10 @@ local GEN2_HOOKS = {
-- pointer with the touch overlay given first refusal, the palette zone list
-- handed to the present pass, the letterbox and the HUD rect.
"input.step", "input.pointer",
-- RFC 0020: keyboard, gamepad (press/release/axis), and wheel -- raised
-- from Game2.lua's keypressed/keyreleased/gamepadpressed/gamepadreleased/
-- gamepadaxis/wheelmoved, same call-site shape as input.pointer above.
"input.key", "input.gamepad", "input.wheel",
"render.zones", "render.compose", "render.output_enabled", "render.output",
"render.letterbox", "render.hud",
}
@@ -0,0 +1,217 @@
-- RFC 0020: input.key, input.gamepad, and input.wheel, through the public
-- mod API. Driven the way main.lua drives them -- through Game's own
-- keypressed/keyreleased/gamepadpressed/gamepadreleased/gamepadaxis/
-- wheelmoved, against the real Input singleton -- so a green run means the
-- wiring, not just the buses. Structured after tests/modkit/cases/
-- pointer_input.lua, the direct precedent for a raw-input hook family.
--
-- The one property that matters beyond "the hook fires": input.key and
-- input.gamepad give a mod REAL suppression, unlike input.pointer's own
-- (inert) consume contract -- a wrapper that returns without calling
-- `next` must stop the vanilla body (Input:keypressed/gamepadpressed/
-- gamepadaxis, the engine's own hotkey ladder) from running at all. That
-- is asserted directly below, not just that the payload looks right.
package.path = "./?.lua;./?/init.lua;" .. package.path
local T = require("tests.modkit")
local Game = require("src.core.Game")
local Input = require("src.core.Input")
local Runtime = require("src.mods.Runtime")
-- Minimal stand-in stack: Game:keypressed's vanilla body reaches
-- self.stack:top() unconditionally near its end (feeding Pipelines.hotkey),
-- even for a key no hotkey claims, so this needs to resolve, not just be
-- absent the way pointer_input.lua's fakeGame leaves it.
local function fakeGame(loader, data)
return setmetatable(
{ input = Input, mods = loader, data = data,
stack = { top = function() return nil end } },
{ __index = Game })
end
-- ------- fixture mod: journals every event, and swallows the specific
-- keys/buttons/axes an "edit mode" would grab, proving real suppression
local FIXTURES = {
["mods/fix_key_gamepad_wheel/manifest.json"] = [[{
"id": "fix_key_gamepad_wheel",
"name": "Fixture Key Gamepad Wheel Watcher",
"version": "1.0.0",
"entry": "main.lua",
"api": 2
}]],
["mods/fix_key_gamepad_wheel/main.lua"] = [[
local mod = ...
local seenKey, seenPad, seenWheel = {}, {}, {}
mod.exports.seenKey, mod.exports.seenPad, mod.exports.seenWheel =
seenKey, seenPad, seenWheel
local swallow = false
mod.exports.setSwallow = function(v) swallow = v end
mod.hooks:wrap("input.key", function(nextFn, game, ev)
seenKey[#seenKey + 1] = { phase = ev.phase, key = ev.key }
if swallow and ev.key == "left" then return "SWALLOWED" end
return nextFn(game, ev)
end)
mod.hooks:wrap("input.gamepad", function(nextFn, game, ev)
seenPad[#seenPad + 1] = { phase = ev.phase, button = ev.button,
axis = ev.axis, value = ev.value }
if swallow and ev.phase == "pressed" and ev.button == "a" then
return "SWALLOWED"
end
if swallow and ev.phase == "axis" and ev.axis == "leftx" then
return "SWALLOWED"
end
return nextFn(game, ev)
end)
mod.hooks:wrap("input.wheel", function(nextFn, game, dy)
seenWheel[#seenWheel + 1] = dy
if swallow then return "SWALLOWED" end
return nextFn(game, dy)
end)
]],
}
-- ------- no-mod parity: all three cost nothing unsubscribed, and vanilla
-- really runs (checked via the real Input singleton, not just "no crash")
do
local run = T.sdk.loadNone({})
Input:init()
local game = fakeGame(run.loader, run.data)
T.eq(Runtime.wantsHook("input.key"), false,
"no subscriber: wantsHook(\"input.key\") is false")
T.eq(Runtime.wantsHook("input.gamepad"), false,
"no subscriber: wantsHook(\"input.gamepad\") is false")
T.eq(Runtime.wantsHook("input.wheel"), false,
"no subscriber: wantsHook(\"input.wheel\") is false")
-- "up" is a DEFAULT_BINDINGS identity mapping (raw key "up" -> GB
-- button "up"), unlike most letter keys, which remap to a differently
-- named GB button (e.g. raw "z" -> GB "a") -- using an identity-mapped
-- key here means Input:isDown can check the exact name just pressed.
Game.keypressed(game, "up")
T.eq(Input:isDown("up"), true,
"no subscriber: keypressed still reaches Input (vanilla ran)")
Game.keyreleased(game, "up")
T.eq(Input:isDown("up"), false,
"no subscriber: keyreleased still reaches Input")
Game.gamepadpressed(game, nil, "b")
T.eq(Input:isDown("b"), true,
"no subscriber: gamepadpressed still reaches Input")
Game.gamepadreleased(game, nil, "b")
T.eq(Input:isDown("b"), false,
"no subscriber: gamepadreleased still reaches Input")
Game.gamepadaxis(game, nil, "leftx", 0.9) -- must not error with no chain
local zoomedBefore = game.save
Game.wheelmoved(game, 0, 1) -- must not error; Game.wheelmoved needs no save
run.release()
end
-- ------- the subscribed path, through the loader-created mod API
local run = T.sdk.loadMods(
{ "mods/fix_key_gamepad_wheel" }, { fs = T.sdk.memfs(FIXTURES) })
T.eq(#run.errors, 0,
"the fixture mod loads clean (" .. tostring(run.errors[1]) .. ")")
local fx = run.loader.exports.fix_key_gamepad_wheel
local game = fakeGame(run.loader, run.data)
Input:init()
local function wipe()
for _, t in ipairs({ fx.seenKey, fx.seenPad, fx.seenWheel }) do
for i = #t, 1, -1 do t[i] = nil end
end
end
-- input.key: payload shape, and observing (calling next) changes nothing
do
wipe()
fx.setSwallow(false)
Game.keypressed(game, "up")
Game.keyreleased(game, "up")
T.eq(#fx.seenKey, 2, "keypressed+keyreleased each reach the hook once")
T.eq(fx.seenKey[1].phase, "pressed", "...pressed first")
T.eq(fx.seenKey[1].key, "up", "...carrying the key")
T.eq(fx.seenKey[2].phase, "released", "...released second")
T.eq(Input:isDown("up"), false, "calling next() lets the release reach Input")
end
-- input.key: NOT calling next is real suppression -- the game never sees it
do
wipe()
fx.setSwallow(true)
Game.keypressed(game, "left")
T.eq(#fx.seenKey, 1, "the swallowed key still reaches the hook once")
T.eq(Input:isDown("left"), false,
"a wrapper that returns without calling next prevents Input:keypressed "
.. "from ever running -- real suppression, not observation")
fx.setSwallow(false)
Game.keyreleased(game, "left") -- clean up any stray state
end
-- input.key: a key the mod doesn't care about still falls all the way
-- through to Input, even while swallow is armed for a different key
do
wipe()
fx.setSwallow(true)
Game.keypressed(game, "right")
T.eq(#fx.seenKey, 1, "an unrelated key still reaches the hook")
T.eq(Input:isDown("right"), true,
"...and still reaches Input, since this wrapper only swallows \"left\"")
Game.keyreleased(game, "right")
fx.setSwallow(false)
end
-- input.gamepad: press/release/axis all reach the hook with the right shape
do
wipe()
fx.setSwallow(false)
Game.gamepadpressed(game, nil, "b")
Game.gamepadreleased(game, nil, "b")
Game.gamepadaxis(game, nil, "lefty", 0.4)
T.eq(#fx.seenPad, 3, "press, release, and axis each reach the hook once")
T.eq(fx.seenPad[1].phase, "pressed", "...pressed first")
T.eq(fx.seenPad[1].button, "b", "...carrying the button")
T.eq(fx.seenPad[2].phase, "released", "...released second")
T.eq(fx.seenPad[3].phase, "axis", "...axis third")
T.eq(fx.seenPad[3].button, nil, "axis carries no button")
T.eq(fx.seenPad[3].axis, "lefty", "...carrying the axis name")
T.eq(fx.seenPad[3].value, 0.4, "...and its value")
T.eq(Input:isDown("b"), false, "observed release still reaches Input")
end
-- input.gamepad: real suppression on a pressed button
do
wipe()
fx.setSwallow(true)
Game.gamepadpressed(game, nil, "a")
T.eq(#fx.seenPad, 1, "the swallowed press still reaches the hook once")
T.eq(Input:isDown("a"), false,
"a wrapper that returns without calling next prevents "
.. "Input:gamepadpressed from ever running")
fx.setSwallow(false)
end
-- input.wheel: reaches the hook, and consuming it stops the zoom call from
-- happening (checked as "no error and only one event seen", since Game's
-- own zoomStep needs a real self.save this fixture does not provide --
-- the point under test is whether `next` runs, not what zoomStep does)
do
wipe()
fx.setSwallow(false)
Game.wheelmoved(game, 0, 1)
T.eq(#fx.seenWheel, 1, "a wheel event reaches the hook")
T.eq(fx.seenWheel[1], 1, "...carrying the raw delta")
end
run.release()
T.finish("key_gamepad_wheel_input")