Files
alchemist-accessibility/accessibility/init.lua
T

299 lines
9.8 KiB
Lua
Raw Normal View History

-- 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")
2026-08-01 20:53:29 +02:00
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",
2026-08-01 21:26:35 +02:00
"ingame_observe",
"ingame_targeting_melee",
"ingame_targeting_throw",
2026-08-01 22:00:52 +02:00
"actions_menu",
"worldmap",
"alchemy_table",
"journal",
"character",
"menu_text",
"ingame_message",
"menu_message",
"inventory",
"craft",
"refine",
"trade",
"config_menu",
"keybindings_menu",
"research",
"ingame_popup",
2026-08-01 20:53:29 +02:00
}
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
2026-08-01 20:53:29 +02:00
-- 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
local lootOk, lootStatus = pcall(require("accessibility.lootoverlay").probeStatus)
print("[accessibility] loot overlay: " .. (lootOk and tostring(lootStatus) or ("FAILED: " .. tostring(lootStatus))))
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)
2026-08-01 20:53:29 +02:00
-- 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
2026-08-01 20:53:29 +02:00
local descriptor = menus.loadDescriptor(newState)
if descriptor then
menus.announceScreen(newState)
if descriptor.onOpen then
-- onOpen may be a fixed string or a function returning one.
-- Melee targeting needs the latter: what it says (the eight
-- adjacent tiles) differs every time the state is entered.
local text = descriptor.onOpen
if type(text) == "function" then
local okOpen, result = pcall(text)
text = okOpen and result or nil
end
if type(text) == "string" and text ~= "" then
speech.say(text, false)
end
2026-08-01 20:53:29 +02:00
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)
2026-08-01 20:53:29 +02:00
-- 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(require("accessibility.viewwatch").update, dt)
pcall(require("accessibility.status").update)
2026-08-03 01:31:58 +02:00
pcall(require("accessibility.details").update, dt)
pcall(require("accessibility.lootoverlay").update)
pcall(speech.flush)
2026-08-01 20:53:29 +02:00
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)
2026-08-01 20:53:29 +02:00
local keyResult = realKeypressed(key, scancode)
-- The loot overlay is edge-triggered off a flag the game sets, and in
-- hold-to-highlight mode a quick tap can set it and clear it again inside
-- one frame -- the end-of-frame poll would then never see it up. Checking
-- here, between the game's keypressed and its keyreleased, catches the tap.
pcall(require("accessibility.lootoverlay").update)
2026-08-01 20:53:29 +02:00
-- After the game handler, so we read the focus it just moved to.
pcall(function()
menus.announceFocusIfChanged(states.getCurrent())
end)
return keyResult
end
2026-08-01 20:53:29 +02:00
-- 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"))
2026-08-01 21:26:35 +02:00
accessibility.registerPatch("popups", require("accessibility.patches.popups"))
accessibility.registerPatch("slider", require("accessibility.patches.slider"))
accessibility.registerPatch("popupstack", require("accessibility.patches.popupstack"))
2026-08-02 02:59:21 +02:00
accessibility.registerPatch("itemmenu", require("accessibility.patches.itemmenu"))
accessibility.registerPatch("hotbar", require("accessibility.patches.hotbar").apply)
2026-08-01 21:26:35 +02:00
pcall(function()
require("accessibility.surroundings").registerKeys(hotkeys)
2026-08-01 21:34:13 +02:00
require("accessibility.tracker").registerKeys(hotkeys)
require("accessibility.status").registerKeys(hotkeys)
require("accessibility.viewwatch").registerKeys(hotkeys)
2026-08-03 01:31:58 +02:00
require("accessibility.details").registerKeys(hotkeys)
require("accessibility.frontier").registerKeys(hotkeys)
2026-08-01 21:26:35 +02:00
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()
2026-08-01 20:53:29 +02:00
if testbed.isEnabled() then
testbedActive = true
testbed.start()
end
end
return accessibility