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

176 lines
12 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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:
```lua
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.