Files
alchemist-accessibility/accessibility/init.lua
T
Accessibility ModandClaude Opus 5 c17c5277d4 Phase 3: object tracker
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>
2026-08-01 21:34:13 +02:00

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