Files
alchemist-accessibility/accessibility/docs/03-implementation-plan.md
T
Accessibility ModandClaude Opus 5 e57350ded3 Phase 0: accessibility mod bootstrap and TTS
Establishes the mod skeleton and gets speech working end to end.

Game-file footprint: one line in main.lua.

- accessibility/init.lua: sole entry point. Patch registry, hooks states.switch
  (announces screen changes) and states.update (per-frame speech flush, avoiding
  an edit to love.update). Startup self-test prints what resolved -- the canary
  for when a future game version moves something we hook.
- accessibility/speech.lua: TTS wrapper. pcall-guarded so a missing DLL degrades
  to silence instead of crashing. Queues utterances within a frame and joins them,
  since one game turn emits several console.print calls and speaking each with
  interrupt would leave only the last audible.
- accessibility/stateNames.lua: spoken names for the 28 states.
- accessibility/vendor/love2talk: vendored TTS library, see VENDORED.md.
  Loads Tolk.dll by absolute path via love.filesystem.getSource() and adds that
  directory via SetDllDirectory, so the DLLs live in the project instead of
  needing an elevated copy into Program Files.

Verified with lovec against NVDA: TTS available, speech channel yes,
'Main menu' spoken on startup.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-01 20:28:30 +02:00

12 KiB
Raw Blame History

Implementation plan

Phases are ordered so each one is independently playable-and-testable. Nothing later is required for something earlier to be useful.


Phase 0 — Baseline & bootstrap

  1. git init in the game root; commit the pristine tree, tag vanilla-0.4.2. (.gitignore: dist/, *.json save data.)
  2. Vendor d:\code\love2talk into accessibility/vendor/love2talk/ (drop objc/ and main.lua).
  3. Resolve the Tolk DLL placement question (see 04-open-questions.md Q1) and document the launch procedure.
  4. accessibility/speech.lua:
    • pcall(require, "accessibility.vendor.love2talk"); on failure, all functions become no-ops and we log once.
    • speech.say(text, interrupt) — normalises nil/empty, collapses whitespace, strips the game's inline markup if any.
    • speech.interrupt(text) — convenience for keypress-driven output.
    • A small queue so consecutive say calls in one frame don't clobber each other: buffer within a frame, flush one joined utterance at end of update. This matters a lot — a single game turn can emit several console.print lines.
  5. accessibility/init.lua with preInit/postInit, everything inside pcall so a mod bug never bricks the game.
  6. Add the bootstrap line(s) to main.lua. Add mod globals to .luacheckrc.

Test: game launches, speaks "Main menu" on startup.


Phase 1 — Console speech + line-by-line review

This is the highest-value, lowest-risk piece.

accessibility/history.lua

  • Wrap console.print(message, colorTemplate).
  • Append {text, colorTemplate, turn = world.turn, time = os.clock()} to an unbounded (or N=1000 ring) list.
  • Mirror the game's coalescing: if the new message equals the last, increment times rather than appending, so review output matches what a sighted player sees ("You feel cold (x3)").
  • Speak new lines automatically, non-interrupting, queued.

Review keys (working proposal, all rebindable):

Key Action
Ctrl+[ / Ctrl+] Previous / next history line (moves review cursor, speaks it)
Ctrl+Home / Ctrl+End Jump to oldest / newest line
Ctrl+\ Speak current line again; press twice quickly to spell it
Ctrl+Shift+C Copy current line to clipboard (love.system.setClipboardText)

Entering review mode does not pause the game (it's turn-based; nothing advances without input anyway). Review cursor resets to newest whenever a new line arrives unless the user is actively reviewing.

Open design point: should routine flavour messages (colorTemplate == "flavor", greyed out in-game) be spoken automatically or only on review? Proposal: a config toggle, default on but spoken last in the frame's queue. See Q4.

Test: walk around, take a hit, harvest something — all log text is spoken; review keys walk the full session history.


Phase 2 — Tile inspection & surroundings

The game already has a full examine cursor (states/ingame_observe.lua), driven by moveCursor(dx,dy) and producing text via levelWindow.getCursorPopupStats() → level.getCursorMessage().

2a. Narrate the existing examine mode (nearly free). Patch views/levelCursorPopup.set(lines, …) to speak lines. That instantly makes the game's own examine mode (default key: the examination command) fully usable — arrow keys move the cursor, each tile is spoken.

Augment the spoken string beyond what's drawn:

  • Prepend distance + direction from the player ("3 north-east").
  • Append passability ("blocked" / "passable" / "water"), whether the tile is currently visible vs. remembered ("remembered"), and whether it holds lootable items (tile.loot and not tile.unlootable).
  • Announce cursor-hit-edge-of-map rather than silently not moving.

2b. Surroundings explorer (new). A quick "what's around me" that doesn't require entering examine mode.

Key Action
Numpad 1–9 (with a modifier, e.g. Alt) Speak the single tile in that direction, without moving
Alt+5 Speak the tile the player is standing on
A "scan" key Speak all 8 neighbours in clockwise order, terse

2c. Wall tones. accessibility/assets/walltones/walltone_{n,s,e,w}.wav are present. Play the corresponding tone when the player bumps a wall (movement blocked) and optionally as a continuous ambient cue when a wall is directly adjacent. Use TEsound.play(..., {"sfx","interface"}) so the game's own volume sliders apply. Needs a decision on when they fire — see Q5.

Test: blind-navigate a corridor in the starting area using only examine + surroundings + wall tones.


Phase 3 — Object tracker

The headline feature. Key scheme as specified:

Key Action
Ctrl+Page Up / Ctrl+Page Down Previous / next category
Page Up / Page Down Previous / next item within the current category
Shift+Page Up / Shift+Page Down Previous / next instance of that specific object
Home Announce distance + direction of the current item

Semantics I'm assuming (please confirm — Q2): a category is e.g. "Creatures"; an item within a category is a distinct kind, e.g. "rat"; an instance is a specific rat at (12,7). So Page Down cycles rat → beetle → guard, and Shift+Page Down cycles rat #1 → rat #2 → rat #3.

accessibility/categories.lua — the tracker's own category table, since the game's templatesByTag only defines exit. Proposed categories:

  1. Creatures — from level.actors, excluding the player, filtered to visible-or-remembered. Sub-grouped by templateId, labelled with actor.nameObserved.
  2. Items — lootable tiles / items on the ground (getItemOnCoordinates, and tiles with loot). Sub-grouped by item category from modules/item/categories.lua.
  3. Exits — reuse level.getListByTag("exit") verbatim (doors, ladders, ways out, horses).
  4. Interactables — tiles with tryInteract that aren't exits (containers, levers, alchemy tables, beds, fires).
  5. Terrain features — water, fire, hazards (spore clouds, slime), stairs.
  6. Alchemy — harvestable ingredient nodes specifically; this is an alchemy game and these matter enough to deserve their own category.

Category membership is determined by a predicate over templateId + tile flags. Building this requires a pass over modules/tile/templates.lua (6745 lines) to classify template IDs — the single biggest chunk of manual work in the project. Mitigation: classify by flags first (tryInteract, loot, blocksMovement, blocksVision) and fall back to an explicit ID list only for the cases flags get wrong. That should keep the hand-maintained list to dozens rather than hundreds of entries, and it degrades gracefully on new content in future versions.

Scanning. One function tracker.rescan() walking x=1..xMax, y=1..yMax, honouring FoV / rememberedFoV exactly as getListByTag does (so the tracker never reveals unseen information — important, this must not become a cheat). Results cached and invalidated on turn advance / level change / FoV recalculation. Sort each bucket by distance to player (sortByDistanceToPlayer logic already exists in modules/level.lua).

Announcements.

  • Category change: "Creatures, 4 kinds".
  • Item change: "rat, 3" (3 instances).
  • Instance change: "rat 2 of 3, 5 tiles north-west".
  • Home: "5 tiles north-west, 7 steps by path" — using calculatePathFromPlayer for the true walking distance, which is far more useful than Euclidean in a walled map.
  • Empty category: "Exits, none".

Extras worth adding (cheap, given the above): a key to move the examine cursor to the tracked object, and a key to auto-path to it (bufferInput("move", calculatePathFromPlayer(x,y), ingame) — the game already does exactly this in ingame_observe.keypressed under confirmTarget).

Test: in a populated level, enumerate every creature, exit and item; verify counts match what examine mode finds; verify nothing outside FoV is ever reported.


Phase 4 — Menus and screens

Now the ~16 states that keep focus in file-locals.

accessibility/states/<statename>.lua — one descriptor per state:

return {
  probe = "keypressed",              -- which state function to read upvalues from
  focus = "selectedOption",          -- upvalue holding the index
  list  = "optionsList",             -- upvalue holding the entries
  title = "Main menu",
  render = function(entry, index, total, state)
    return ("%s, %d of %d%s"):format(entry.title, index, total,
      entry.isEnabled and "" or ", unavailable")
  end,
}

The generic driver (patches/states.lua) wraps states.keypressed: run the real handler, then re-probe the descriptor; if focus changed, speak. It also wraps states.switch to announce the screen title and the initially-focused entry.

States needing descriptors: main_menu, settings_menu, config_menu, keybindings_menu, load_game, new_game, journal, character, research, worldmap, actions_menu, alchemy_table, ingame_dialogue.

Special cases:

  • ingame_dialogue — must speak the NPC's line and the numbered response options; the game already supports number keys 1–0 to pick a response directly, which is ideal. Needs a "repeat the NPC line" key.
  • journal, character, research — tabbed and paged; need tab-change announcements (activeTabIndex, selectedPage, selectedPageId) plus a "read the page body" key that hands the long text to the history reviewer from Phase 1, so it can be read line by line.
  • menu_text, narration, ingame_message, menu_message — full-screen prose. Route the whole text into the review buffer and speak it; these have no meaningful focus state, so they need no upvalue probing.
  • craft, refine, trade, inventory — already covered by the inventoryCursorPopup patch from Phase 2a. But they also have a confirmation sub-window with a slider (modules/slider.lua, quantity selection). Left/Right adjusts it (adjustByFive modifier for ×5) — needs the current value spoken on change.
  • ingame_targeting_melee / ingame_targeting_throw — targeting cursor; reuse the Phase-2 cursor narration plus valid/invalid-target announcement (canThrowOnCell, level.recalculateInteractableArea).

Test: complete a full loop — new game → dialogue → inventory → craft a potion → journal — with the monitor off.


Phase 5 — Player status, polish, packaging

  • Status key(s): HP (player.actor.hp/maxhp), active statuses (views/statusGrid.lua, models/status), current action (player.currentSelectedAction), hotbar contents, level name + in-game time (level.getLevelDescriptionMessage()), player coordinates.
  • Popup announcements: wrap popupStack.add and the ingame.*Popup methods so loot/recipe/lore/bestiary/tutorial popups are spoken and reviewable.
  • Interruptions: ingame.checkInterruptions(hpLoss) fires on damage — speak an urgent, interrupting alert.
  • Mod settings screen: speech rate/verbosity, toggles for flavour text, wall tones, auto-speak. Either a new state or (cleaner, more decoupled) a plain accessibility/config.json plus hotkeys to toggle at runtime with spoken confirmation.
  • Self-test: startup check that walks every state descriptor and every patch target, logging what resolved. This is the version-bump canary.
  • Packaging: decide .love/exe distribution vs. source drop, given the DLL constraint (Q1). Update build.lua's ignore if needed.
  • Docs: a player-facing accessibility/README.md with the full keymap.

Effort estimate

Phase Relative size Risk
0 Bootstrap S Low (DLL placement is the only unknown)
1 Console S Low
2 Tiles M Low — builds on existing examine mode
3 Tracker L Medium — template classification is the grind
4 Menus L Medium — 13 descriptors, upvalue fragility
5 Polish M Low

The pleasant surprise is that Phases 1, 2a and much of 4 lean on text the game already generates (console.print, getCursorMessage, getCursorPopupStats). We are mostly re-routing existing strings to speech, not writing a description engine.