Game-file footprint unchanged: still one line in main.lua.
Three-level navigation as specified in D2: Ctrl+PageUp/Down cycles categories,
PageUp/Down cycles kinds, Shift+PageUp/Down cycles instances of that kind, Home
announces where it is.
- categories.lua: flag-first classification into Creatures, Items, Alchemy,
Interactables, Exits and Terrain features, reusing the game's own 'exit' tag
and excluding the six nameless mechanical templates.
- tracker.lua: scan, cache, navigation, announcements.
Caching keys on level name, player position and turn: position because
everything sorts by distance to the player, turn because creatures move and
containers empty. Scans run on keypress, never per frame.
FoV is respected throughout -- creatures only when currently visible, tiles when
visible or remembered -- so the tracker never becomes a cheat.
Home reports path length alongside the offset, via the game's own
calculatePathFromPlayer. Straight-line distance is misleading indoors, where a
chest three tiles north through a wall can be twenty steps away.
Corrects D4 in docs/05-decisions.md. I had claimed tile.tryInteract cleanly
separates 'the player can do something here' from scenery. It does not: 'wall'
carries a flavour-only tryInteractFunction ('You touch the wall. It's cold and
rough.'), so every wall segment was promoted into Interactables -- 13 in the
starting house, burying the chest and cauldron. Structural tiles
(blocksMovement and blocksVision) are now excluded before the tryInteract test;
containers and furniture block movement but not sight, so they survive.
12 kinds/19 instances instead of 13/32.
13/13 testbed checks, history unit tests passing.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
250 lines
7.8 KiB
Lua
250 lines
7.8 KiB
Lua
-- accessibility/init.lua — mod entry point.
|
|
--
|
|
-- This is the ONLY thing the game calls into. main.lua gains exactly one line:
|
|
--
|
|
-- require("accessibility").initialize()
|
|
--
|
|
-- placed after console.prepare() and before the first states.switch(). By that
|
|
-- point templates are preprocessed, keybindings are loaded, states.setup() has
|
|
-- run, and the console canvas exists — everything we patch is ready.
|
|
--
|
|
-- Everything here is wrapped so that a bug in the mod degrades to "no speech"
|
|
-- rather than taking the game down with it.
|
|
|
|
local accessibility = {}
|
|
|
|
local speech = require("accessibility.speech")
|
|
local stateNames = require("accessibility.stateNames")
|
|
local hotkeys = require("accessibility.hotkeys")
|
|
local walltones = require("accessibility.walltones")
|
|
local menus = require("accessibility.menus")
|
|
local testbed = require("accessibility.testbed")
|
|
|
|
local testbedActive = false
|
|
|
|
-- States we ship focus descriptors for. Also the list the self-test walks.
|
|
local describedStates = {
|
|
"main_menu",
|
|
"settings_menu",
|
|
"load_game",
|
|
"new_game",
|
|
"narration",
|
|
"ingame_dialogue",
|
|
"ingame_observe",
|
|
}
|
|
|
|
local initialized = false
|
|
|
|
--------------------------------------------------------------------------------
|
|
-- Patch registry
|
|
--
|
|
-- Later phases register their patches here. Each entry is {name, fn}; fn is called
|
|
-- inside a pcall during initialize(), and a failure disables only that patch.
|
|
--------------------------------------------------------------------------------
|
|
|
|
local patches = {}
|
|
|
|
function accessibility.registerPatch(name, fn)
|
|
patches[#patches + 1] = {name = name, fn = fn}
|
|
end
|
|
|
|
local patchResults = {}
|
|
|
|
local function applyPatches()
|
|
for _, patch in ipairs(patches) do
|
|
local ok, err = pcall(patch.fn)
|
|
patchResults[patch.name] = ok and "ok" or tostring(err)
|
|
if not ok then
|
|
print("[accessibility] patch '" .. patch.name .. "' FAILED: " .. tostring(err))
|
|
end
|
|
end
|
|
end
|
|
|
|
-- Startup self-test output. This is the version-bump canary: when the game
|
|
-- updates and something we hook has moved, it shows up here immediately rather
|
|
-- than as silence three screens into a playthrough.
|
|
function accessibility.selfTest()
|
|
print("[accessibility] --- self test ---")
|
|
print("[accessibility] TTS: " .. (speech.isAvailable() and "available"
|
|
or ("UNAVAILABLE (" .. tostring(speech.getFailureReason()) .. ")")))
|
|
if speech.isAvailable() then
|
|
-- Distinguishes "DLL loaded" from "actually reached a screen reader or SAPI".
|
|
print("[accessibility] speech channel: " .. (speech.hasSpeech() and "yes" or "NO — nothing will be audible"))
|
|
end
|
|
for name, result in pairs(patchResults) do
|
|
print("[accessibility] patch " .. name .. ": " .. result)
|
|
end
|
|
|
|
-- Menu focus probes. A renamed local in a future game version surfaces here at
|
|
-- startup rather than as silence mid-playthrough.
|
|
local ok, results = pcall(menus.selfTest, describedStates)
|
|
if ok then
|
|
for _, stateName in ipairs(describedStates) do
|
|
print("[accessibility] menu " .. stateName .. ": " .. tostring(results[stateName]))
|
|
end
|
|
end
|
|
|
|
print("[accessibility] --- end self test ---")
|
|
end
|
|
|
|
--------------------------------------------------------------------------------
|
|
-- Core hooks into states.lua
|
|
--------------------------------------------------------------------------------
|
|
|
|
local function hookStates()
|
|
local states = require("states")
|
|
|
|
-- Screen changes: announce the new screen.
|
|
local realSwitch = states.switch
|
|
states.switch = function(newState, params)
|
|
local result = realSwitch(newState, params)
|
|
|
|
-- After the switch, so the state's open() has already set up its focus --
|
|
-- main_menu, for one, only picks selectedOption inside open().
|
|
pcall(function()
|
|
-- Guard against NESTED switches. open() can itself switch state:
|
|
-- ingame.open() sees world.pendingDialogue and goes straight to
|
|
-- ingame_dialogue. Because we announce after realSwitch returns, the
|
|
-- inner announcement ("Conversation") happens first and the outer one
|
|
-- ("Game") then interrupts and clobbers it. If we are no longer the
|
|
-- current state, the inner switch already announced -- stay quiet.
|
|
if states.getCurrent() ~= newState then
|
|
return
|
|
end
|
|
|
|
local descriptor = menus.loadDescriptor(newState)
|
|
|
|
if descriptor then
|
|
menus.announceScreen(newState)
|
|
if descriptor.onOpen then
|
|
speech.say(descriptor.onOpen, false)
|
|
end
|
|
else
|
|
speech.say(stateNames[newState] or newState, true)
|
|
end
|
|
end)
|
|
|
|
return result
|
|
end
|
|
|
|
-- Per-frame speech flush. main.lua calls states.update(dt) via a table lookup,
|
|
-- so wrapping the field here avoids editing love.update.
|
|
local realUpdate = states.update
|
|
states.update = function(dt)
|
|
local result = realUpdate(dt)
|
|
|
|
-- Poll focus rather than relying on keypress alone.
|
|
--
|
|
-- Focus can move with no key involved: main_menu.mousemoved assigns
|
|
-- selectedOption straight from the cursor position, so wherever the mouse
|
|
-- happens to rest silently overrides the selection -- including at startup,
|
|
-- before the player has touched anything. Polling also closes the race
|
|
-- where a state's open() sets focus after we have already announced.
|
|
--
|
|
-- Cheap: two debug.getupvalue lookups, and only for states we describe.
|
|
pcall(function()
|
|
menus.announceFocusIfChanged(states.getCurrent())
|
|
end)
|
|
|
|
pcall(walltones.update)
|
|
pcall(speech.flush)
|
|
if testbedActive then
|
|
pcall(testbed.update, dt)
|
|
end
|
|
return result
|
|
end
|
|
|
|
-- Global hotkey layer. Ours are consumed; everything else falls through to the
|
|
-- game untouched.
|
|
local realKeypressed = states.keypressed
|
|
states.keypressed = function(key, scancode)
|
|
local consumed = false
|
|
local ok, result = pcall(hotkeys.keypressed, key, scancode)
|
|
if ok then
|
|
consumed = result
|
|
end
|
|
|
|
if consumed then
|
|
return
|
|
end
|
|
|
|
pcall(hotkeys.onGameKey)
|
|
|
|
local keyResult = realKeypressed(key, scancode)
|
|
|
|
-- After the game handler, so we read the focus it just moved to.
|
|
pcall(function()
|
|
menus.announceFocusIfChanged(states.getCurrent())
|
|
end)
|
|
|
|
return keyResult
|
|
end
|
|
|
|
-- Text entry (character naming) arrives via textinput, not keypressed.
|
|
local realTextinput = states.textinput
|
|
states.textinput = function(text)
|
|
local result = realTextinput(text)
|
|
|
|
pcall(function()
|
|
menus.announceFocusIfChanged(states.getCurrent())
|
|
end)
|
|
|
|
return result
|
|
end
|
|
|
|
-- CRITICAL: re-point the love callbacks at our wrappers.
|
|
--
|
|
-- states.setup() runs before us and does:
|
|
-- love[callback] = love[callback] or states[callback]
|
|
-- which captures the function BY VALUE. main.lua defines love.load, update,
|
|
-- mousepressed, mousereleased and quit itself, but NOT keypressed/textinput --
|
|
-- so those were bound to the original states.keypressed at setup time.
|
|
-- Replacing the states table field alone leaves love.keypressed pointing at
|
|
-- the old function and our wrapper never runs.
|
|
--
|
|
-- states.switch and states.update are unaffected because they are looked up on
|
|
-- the table at call time (from state files and from main.lua's love.update).
|
|
love.keypressed = states.keypressed
|
|
love.textinput = states.textinput
|
|
end
|
|
|
|
--------------------------------------------------------------------------------
|
|
-- Entry point
|
|
--------------------------------------------------------------------------------
|
|
|
|
function accessibility.initialize()
|
|
if initialized then
|
|
return
|
|
end
|
|
initialized = true
|
|
|
|
accessibility.registerPatch("console", require("accessibility.patches.console"))
|
|
accessibility.registerPatch("popups", require("accessibility.patches.popups"))
|
|
|
|
pcall(function()
|
|
require("accessibility.surroundings").registerKeys(hotkeys)
|
|
require("accessibility.tracker").registerKeys(hotkeys)
|
|
end)
|
|
|
|
local ok, err = pcall(function()
|
|
speech.initialize()
|
|
walltones.load()
|
|
hookStates()
|
|
applyPatches()
|
|
end)
|
|
|
|
if not ok then
|
|
print("[accessibility] initialisation FAILED, game continues unmodified: " .. tostring(err))
|
|
return
|
|
end
|
|
|
|
accessibility.selfTest()
|
|
|
|
if testbed.isEnabled() then
|
|
testbedActive = true
|
|
testbed.start()
|
|
end
|
|
end
|
|
|
|
return accessibility
|