Game-file footprint unchanged: still one line in main.lua. - describe.lua: shared spatial formatting. 8-point compass and Chebyshev distance, since the game moves 8-directionally and Euclidean would overstate diagonal travel. Verbosity setting, because sweeping eight neighbours and inspecting a single tile want different amounts of detail. - states/ingame_observe.lua: narrates the game's existing examine cursor. - surroundings.lua: probe one direction, scan all eight, or report location, without moving anything or touching game state. - patches/popups.lua: wraps inventoryCursorPopup.set, covering inventory, craft, refine and trade in a single patch. Q3 resolved as augment rather than relay: the game's descriptions carry no position, which is fine on screen and useless by ear, so distance and direction are appended. Examine mode is polled rather than driven by levelCursorPopup. That popup is set from an animation-completion callback, so unless the player has enabled the game's quickExaminationPopup option the text arrives a full keypress late -- in testing you heard the previous tile while standing on the next. Polling level.cursorX/Y is same-frame and setting-independent. hotkeys.isCtrlDown/isShiftDown are now overridable fields rather than direct love.keyboard calls: real hardware state cannot be faked by love.event.push, so without that seam every modifier-gated hotkey would be untestable. 11/11 testbed checks, history unit tests passing. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
143 lines
4.6 KiB
Lua
143 lines
4.6 KiB
Lua
-- hotkeys.lua — the mod's global key layer.
|
|
--
|
|
-- Sits in front of states.keypressed. Keys we own are handled and CONSUMED (the
|
|
-- game never sees them); everything else falls straight through.
|
|
--
|
|
-- Consuming matters more than it looks. keybindings.getCommand() dispatches on
|
|
-- scancode alone and ignores modifiers, checking held modifiers separately via
|
|
-- isCommandDown. So Ctrl+Up is seen by the game as plain "up" — a movement — with
|
|
-- moveCamera held. If we bound anything to Ctrl+Up and passed it through, the
|
|
-- player would move. Hence: our keys are chosen from scancodes the game does not
|
|
-- bind at all, and we still consume them.
|
|
--
|
|
-- Keys the game claims by default (do not use):
|
|
-- . 0-9 ` a b backspace c d delete down e escape f g i j kp1-kp9 kpenter
|
|
-- lalt lctrl left lshift mouse3 q r ralt rctrl return right rshift s space
|
|
-- t tab up x y z
|
|
--
|
|
-- Note Ctrl = moveCamera and Shift = repeatAction/adjustByFive are HELD modifiers
|
|
-- in game. Combining them with an unbound scancode (e.g. Ctrl+PageUp) is safe,
|
|
-- because the game maps the scancode to no command and does nothing.
|
|
|
|
local speech = require("accessibility.speech")
|
|
local history = require("accessibility.history")
|
|
|
|
local hotkeys = {}
|
|
|
|
--------------------------------------------------------------------------------
|
|
-- Where our keys must stay out of the way
|
|
--------------------------------------------------------------------------------
|
|
|
|
-- States that accept free text. Swallowing "[" while the player is naming a
|
|
-- character or typing in the developer console would be a real bug.
|
|
local textEntryStates = {
|
|
ingame_say = true,
|
|
new_game = true,
|
|
keybindings_menu = true, -- captures raw keys for rebinding
|
|
}
|
|
|
|
local function isTextEntryState()
|
|
local ok, states = pcall(require, "states")
|
|
if not ok then
|
|
return false
|
|
end
|
|
return textEntryStates[states.getCurrent()] == true
|
|
end
|
|
|
|
--------------------------------------------------------------------------------
|
|
-- Modifier helpers
|
|
--------------------------------------------------------------------------------
|
|
|
|
-- Indirected through overridable fields so the testbed can drive modified
|
|
-- hotkeys. love.keyboard.isScancodeDown reads real hardware state, which a
|
|
-- synthetic love.event.push cannot influence -- without this seam, every
|
|
-- modifier-gated key would be untestable.
|
|
hotkeys.isCtrlDown = function()
|
|
return love.keyboard.isScancodeDown("lctrl", "rctrl")
|
|
end
|
|
|
|
hotkeys.isShiftDown = function()
|
|
return love.keyboard.isScancodeDown("lshift", "rshift")
|
|
end
|
|
|
|
local function ctrlDown()
|
|
return hotkeys.isCtrlDown()
|
|
end
|
|
|
|
local function shiftDown()
|
|
return hotkeys.isShiftDown()
|
|
end
|
|
|
|
--------------------------------------------------------------------------------
|
|
-- Bindings
|
|
--
|
|
-- Each entry: scancode -> { {mods, action}, ... }, checked in order.
|
|
-- mods: "ctrl", "shift", or nil for "no modifier required".
|
|
--------------------------------------------------------------------------------
|
|
|
|
local bindings = {}
|
|
|
|
local function bind(scancode, mods, action)
|
|
bindings[scancode] = bindings[scancode] or {}
|
|
table.insert(bindings[scancode], {mods = mods, action = action})
|
|
end
|
|
|
|
-- Console review
|
|
bind("[", nil, history.reviewPrevious)
|
|
bind("]", nil, history.reviewNext)
|
|
bind("[", "shift", history.reviewFirst)
|
|
bind("]", "shift", history.reviewLast)
|
|
bind("\\", nil, history.repeatCurrent)
|
|
bind("\\", "shift", history.copyCurrent)
|
|
|
|
function hotkeys.bind(scancode, mods, action)
|
|
bind(scancode, mods, action)
|
|
end
|
|
|
|
local function matches(entry)
|
|
if entry.mods == "ctrl" then
|
|
return ctrlDown()
|
|
elseif entry.mods == "shift" then
|
|
return shiftDown()
|
|
end
|
|
-- No modifier required: reject if one is held, so Shift+[ doesn't also fire [.
|
|
return not ctrlDown() and not shiftDown()
|
|
end
|
|
|
|
--------------------------------------------------------------------------------
|
|
-- Dispatch
|
|
--------------------------------------------------------------------------------
|
|
|
|
-- Returns true if the key was ours and should NOT reach the game.
|
|
function hotkeys.keypressed(_key, scancode)
|
|
if isTextEntryState() then
|
|
return false
|
|
end
|
|
|
|
local candidates = bindings[scancode]
|
|
if not candidates then
|
|
return false
|
|
end
|
|
|
|
for _, entry in ipairs(candidates) do
|
|
if matches(entry) then
|
|
local ok, err = pcall(entry.action)
|
|
if not ok then
|
|
print("[accessibility] hotkey '" .. tostring(scancode) .. "' error: " .. tostring(err))
|
|
end
|
|
return true
|
|
end
|
|
end
|
|
|
|
return false
|
|
end
|
|
|
|
-- Any key that reaches the game is an explicit player action, so it cancels the
|
|
-- pending speech backlog. Whatever that action produces is then spoken fresh.
|
|
-- This is the "speak everything queued, unless explicitly stopped" policy.
|
|
function hotkeys.onGameKey()
|
|
speech.silence()
|
|
end
|
|
|
|
return hotkeys
|