mirror of
https://github.com/bryanthaboi/gen1recomp
synced 2026-10-01 15:41:29 -04:00
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:
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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).
|
||||
|
||||
@@ -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")
|
||||
Reference in New Issue
Block a user