Phase 2: tile examination and surroundings

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

- describe.lua: shared spatial formatting. 8-point compass and Chebyshev
  distance, since the game moves 8-directionally and Euclidean would overstate
  diagonal travel. Verbosity setting, because sweeping eight neighbours and
  inspecting a single tile want different amounts of detail.
- states/ingame_observe.lua: narrates the game's existing examine cursor.
- surroundings.lua: probe one direction, scan all eight, or report location,
  without moving anything or touching game state.
- patches/popups.lua: wraps inventoryCursorPopup.set, covering inventory, craft,
  refine and trade in a single patch.

Q3 resolved as augment rather than relay: the game's descriptions carry no
position, which is fine on screen and useless by ear, so distance and direction
are appended.

Examine mode is polled rather than driven by levelCursorPopup. That popup is set
from an animation-completion callback, so unless the player has enabled the
game's quickExaminationPopup option the text arrives a full keypress late -- in
testing you heard the previous tile while standing on the next. Polling
level.cursorX/Y is same-frame and setting-independent.

hotkeys.isCtrlDown/isShiftDown are now overridable fields rather than direct
love.keyboard calls: real hardware state cannot be faked by love.event.push, so
without that seam every modifier-gated hotkey would be untestable.

11/11 testbed checks, history unit tests passing.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Accessibility Mod
2026-08-01 21:26:35 +02:00
co-authored by Claude Opus 5
parent da512fc67e
commit 8b1eb44c38
12 changed files with 559 additions and 6 deletions
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+196
View File
@@ -0,0 +1,196 @@
-- describe.lua — shared spatial text formatting.
--
-- The game's own descriptions ("A wooden chair.") carry no spatial information,
-- which is fine on screen where position is obvious and useless by ear. Per Q3
-- we augment rather than merely relay: distance, direction, and passability.
--
-- Verbosity is a setting because the right amount differs between sweeping the
-- surroundings (terse) and inspecting one tile (verbose).
local describe = {}
-- "terse" | "normal" | "verbose"
describe.verbosity = "normal"
--------------------------------------------------------------------------------
-- Direction
--------------------------------------------------------------------------------
-- Eight-point compass. Screen coordinates: +y is south, +x is east.
function describe.direction(dx, dy)
if dx == 0 and dy == 0 then
return "here"
end
local parts = {}
if dy < 0 then
parts[#parts + 1] = "north"
elseif dy > 0 then
parts[#parts + 1] = "south"
end
if dx < 0 then
parts[#parts + 1] = "west"
elseif dx > 0 then
parts[#parts + 1] = "east"
end
return table.concat(parts, "-")
end
--------------------------------------------------------------------------------
-- Distance
--------------------------------------------------------------------------------
-- Chebyshev distance: the number of steps on an 8-directional grid, which is what
-- this game uses. Euclidean would overstate diagonal travel.
function describe.distance(dx, dy)
return math.max(math.abs(dx), math.abs(dy))
end
function describe.tiles(n)
return n == 1 and "1 tile" or (n .. " tiles")
end
-- "3 tiles north-east" / "here"
function describe.offset(dx, dy)
if dx == 0 and dy == 0 then
return "here"
end
return describe.tiles(describe.distance(dx, dy)) .. " " .. describe.direction(dx, dy)
end
--------------------------------------------------------------------------------
-- Tile inspection
--------------------------------------------------------------------------------
-- What the player would see standing on / looking at (x,y).
-- Returns a table of facts rather than a sentence, so callers choose the wording.
function describe.tileFacts(level, x, y)
local facts = {
inBounds = false,
seen = false,
visible = false,
blocked = false,
blocksVision = false,
water = false,
hasLoot = false,
interactable = false,
actor = nil,
description = nil,
}
if not level or not level.grid or not level.grid[x] or not level.grid[x][y] then
return facts
end
facts.inBounds = true
local visible = level.FoV and level.FoV[x] and level.FoV[x][y]
local remembered = level.rememberedFoV and level.rememberedFoV[x] and level.rememberedFoV[x][y]
facts.visible = visible and true or false
facts.seen = (visible or remembered) and true or false
if not facts.seen then
return facts
end
if visible then
local ok, actor = pcall(level.getActorOnCoordinates, x, y, function(a)
return a.visibilityAlpha ~= 0
end)
if ok and actor then
facts.actor = actor
end
end
for i = #level.grid[x][y], 1, -1 do
local tile = level.grid[x][y][i]
if tile and not tile.isHidden then
if tile.blocksMovement then
facts.blocked = true
end
if tile.blocksVision then
facts.blocksVision = true
end
if tile.tryInteract then
facts.interactable = true
end
if tile.loot and tile.loot[1] and not tile.unlootable then
facts.hasLoot = true
end
if not facts.description and tile.description then
facts.description = tile.description
end
end
end
local ok, isWater = pcall(level.isWaterTile, x, y)
facts.water = (ok and isWater) and true or false
return facts
end
-- One spoken line for a tile, relative to an origin (usually the player).
function describe.tile(level, x, y, originX, originY)
local facts = describe.tileFacts(level, x, y)
local dx = originX and (x - originX) or 0
local dy = originY and (y - originY) or 0
local where = originX and describe.offset(dx, dy) or nil
if not facts.inBounds then
return (where and (where .. ", ") or "") .. "edge of the map"
end
if not facts.seen then
return (where and (where .. ", ") or "") .. "unknown"
end
local parts = {}
-- Creatures first: they matter more than the floor they stand on, and the
-- game's own getCursorMessage prioritises them the same way.
if facts.actor then
parts[#parts + 1] = facts.actor.nameObserved or facts.actor.name or "someone"
end
if facts.description then
parts[#parts + 1] = facts.description
elseif not facts.actor then
parts[#parts + 1] = facts.blocked and "wall" or "floor"
end
if describe.verbosity ~= "terse" then
if facts.blocked then
parts[#parts + 1] = "blocked"
end
if facts.hasLoot then
parts[#parts + 1] = "has items"
end
if facts.water then
parts[#parts + 1] = "water"
end
end
if describe.verbosity == "verbose" then
if facts.interactable then
parts[#parts + 1] = "can interact"
end
if not facts.visible then
parts[#parts + 1] = "remembered"
end
end
if where then
parts[#parts + 1] = where
end
return table.concat(parts, ", ")
end
return describe
+41 -1
View File
@@ -124,6 +124,12 @@ needs a human at the keyboard — Talon to test.
| `Shift+[` / `Shift+]` | Oldest / newest line | | `Shift+[` / `Shift+]` | Oldest / newest line |
| `\` | Repeat current line | | `\` | Repeat current line |
| `Shift+\` | Copy current line to clipboard | | `Shift+\` | Copy current line to clipboard |
| `/` | Scan all 8 neighbours, clockwise from north (terse) |
| `;` | Where am I — level, in-game time, coordinates |
| `Ctrl`+numpad 1–9 | Probe that direction without moving (`Ctrl+kp5` = standing on) |
Game keys worth knowing: **`x`** enters examine mode (not `e`), `f` finds exits
while examining, `j` journal, `i` inventory, `c` character, `a` actions menu.
Reserved for the tracker (Phase 3): `PageUp`/`PageDown`, `Home`, with Ctrl/Shift. Reserved for the tracker (Phase 3): `PageUp`/`PageDown`, `Home`, with Ctrl/Shift.
@@ -239,9 +245,43 @@ where you're standing now. `playing` is cleared on level change.
*(Mono matters: OpenAL only spatialises mono sources. Stereo would not pan.)* *(Mono matters: OpenAL only spatialises mono sources. Stereo would not pan.)*
### Done — Phase 2: tile examination + surroundings
- `accessibility/describe.lua` — shared spatial formatting. 8-point compass,
**Chebyshev** distance (the game moves 8-directionally, so Euclidean would
overstate diagonals), and `tileFacts()` / `tile()`. Verbosity setting
(`terse`/`normal`/`verbose`) since sweeping 8 neighbours and inspecting one
tile want different amounts of detail.
- `accessibility/states/ingame_observe.lua` — narrates the game's examine cursor.
- `accessibility/surroundings.lua` — probe/scan/where-am-I without moving.
- `accessibility/patches/popups.lua` — wraps `inventoryCursorPopup.set`, covering
**inventory, craft, refine, trade** in one patch.
**Q3 answered by default: augment, not just relay.** The game's descriptions
("A wooden chair.") carry no position — fine on screen, useless by ear. We append
distance + direction, and blocked/has-items/water at normal verbosity.
#### Design note: why examine mode is polled, not popup-driven
`levelCursorPopup.set` is called from an **animation-completion callback**, so
unless the player enables the game's `quickExaminationPopup` option the text
lands a full keypress late — in testing you heard the *previous* tile while
standing on the next. The `ingame_observe` descriptor polls `level.cursorX/Y`
instead: same-frame, setting-independent, no game edit. `levelCursorPopup` is
therefore deliberately **not** patched; only `inventoryCursorPopup` is.
*(The level model is a closure instance, not a module, so it can't be patched at
require time — reading its public fields is the decoupled route.)*
#### Testability seam
`hotkeys.isCtrlDown` / `isShiftDown` are overridable fields, not direct calls to
`love.keyboard.isScancodeDown`. Real hardware state can't be faked by
`love.event.push`, so without this seam every modifier-gated hotkey would be
untestable. The testbed swaps them per keypress.
### Known gaps (next up) ### Known gaps (next up)
- `ingame` itself has no descriptor — that's Phase 2 (tile examination). - **Phase 3: object tracker** — the headline feature, not started.
- Wall tone retrigger fix not yet confirmed by ear. - Wall tone retrigger fix not yet confirmed by ear.
- `ingame` itself has no descriptor (player status: HP, statuses, current action).
- `worldmap`, `journal`, `character`, `research`, `inventory`, `craft`, `refine`, - `worldmap`, `journal`, `character`, `research`, `inventory`, `craft`, `refine`,
`trade`, `actions_menu`, `alchemy_table`, `keybindings_menu`, `config_menu`, `trade`, `actions_menu`, `alchemy_table`, `keybindings_menu`, `config_menu`,
`menu_text`, `ingame_message`, `menu_message` still have no descriptors. `menu_text`, `ingame_message`, `menu_message` still have no descriptors.
+14 -2
View File
@@ -48,14 +48,26 @@ end
-- Modifier helpers -- Modifier helpers
-------------------------------------------------------------------------------- --------------------------------------------------------------------------------
local function ctrlDown() -- Indirected through overridable fields so the testbed can drive modified
-- hotkeys. love.keyboard.isScancodeDown reads real hardware state, which a
-- synthetic love.event.push cannot influence -- without this seam, every
-- modifier-gated key would be untestable.
hotkeys.isCtrlDown = function()
return love.keyboard.isScancodeDown("lctrl", "rctrl") return love.keyboard.isScancodeDown("lctrl", "rctrl")
end end
local function shiftDown() hotkeys.isShiftDown = function()
return love.keyboard.isScancodeDown("lshift", "rshift") return love.keyboard.isScancodeDown("lshift", "rshift")
end end
local function ctrlDown()
return hotkeys.isCtrlDown()
end
local function shiftDown()
return hotkeys.isShiftDown()
end
-------------------------------------------------------------------------------- --------------------------------------------------------------------------------
-- Bindings -- Bindings
-- --
+6
View File
@@ -30,6 +30,7 @@ local describedStates = {
"new_game", "new_game",
"narration", "narration",
"ingame_dialogue", "ingame_dialogue",
"ingame_observe",
} }
local initialized = false local initialized = false
@@ -218,6 +219,11 @@ function accessibility.initialize()
initialized = true initialized = true
accessibility.registerPatch("console", require("accessibility.patches.console")) accessibility.registerPatch("console", require("accessibility.patches.console"))
accessibility.registerPatch("popups", require("accessibility.patches.popups"))
pcall(function()
require("accessibility.surroundings").registerKeys(hotkeys)
end)
local ok, err = pcall(function() local ok, err = pcall(function()
speech.initialize() speech.initialize()
+76
View File
@@ -0,0 +1,76 @@
-- Patch: views/levelCursorPopup.lua and views/inventoryCursorPopup.lua
--
-- `inventoryCursorPopup.set` is the convergence point for focus text in four
-- screens: inventory, craft, refine and trade. Patching it narrates all four
-- without touching any state file.
--
-- inventoryCursorPopup.set(lines, x, y, alignX, alignY) -- lines is a TABLE
-- of {text=, icon=}
--
-- NOTE: levelCursorPopup (the examine cursor) is deliberately NOT handled here.
-- It is set from an animation-completion callback, so its text lands a keypress
-- late unless the player enables the game's "quickExaminationPopup" option.
-- accessibility/states/ingame_observe.lua polls the cursor position instead,
-- which is immediate and setting-independent.
local speech = require("accessibility.speech")
-- Suppress repeats: the setter is called on every mouse move, often with
-- unchanged content.
local lastInventoryText
local function linesToText(lines)
if type(lines) ~= "table" then
return nil
end
local parts = {}
for _, line in ipairs(lines) do
if type(line) == "table" and line.text then
parts[#parts + 1] = line.text
elseif type(line) == "string" then
parts[#parts + 1] = line
end
end
if #parts == 0 then
return nil
end
return table.concat(parts, ". ")
end
return function()
----------------------------------------------------------------------------
-- inventoryCursorPopup: inventory, craft, refine, trade
----------------------------------------------------------------------------
local inventoryPopup = require("views.inventoryCursorPopup")
local realInventorySet = inventoryPopup.set
if not realInventorySet then
error("views.inventoryCursorPopup has no set function -- game structure changed")
end
inventoryPopup.set = function(lines, ...)
local result = realInventorySet(lines, ...)
pcall(function()
local text = linesToText(lines)
if not text then
lastInventoryText = nil
return
end
text = speech.normalise(text)
if not text or text == lastInventoryText then
return
end
lastInventoryText = text
speech.say(text, true)
end)
return result
end
end
+43
View File
@@ -0,0 +1,43 @@
-- states/ingame_observe.lua — the examine cursor.
--
-- Reads the cursor position straight off the level model rather than listening to
-- levelCursorPopup.set.
--
-- Why: the popup is set from an animation-completion callback (`onFinish`), so
-- unless the player has turned on the game's "quickExaminationPopup" option, the
-- text arrives only after the cursor finishes gliding. In testing that put each
-- announcement a full keypress behind -- you would hear the previous tile while
-- standing on the next one. Polling cursorX/cursorY announces on the same frame
-- the cursor moves, regardless of that setting, and needs no game edit.
--
-- The level model is a closure instance rather than a module, so it cannot be
-- patched at require time; reading its public fields is the decoupled route.
local describe = require("accessibility.describe")
return {
read = function()
local ok, world = pcall(require, "models.world")
if not ok or not world.currentLevel then
return nil, nil
end
local level = world.currentLevel
local x, y = level.cursorX, level.cursorY
if type(x) ~= "number" or type(y) ~= "number" then
return nil, nil
end
local player = level.playerActor
local px = player and player.x
local py = player and player.y
local text = describe.tile(level, x, y, px, py)
if not text or text == "" then
return nil, nil
end
return text, x .. "," .. y
end,
}
+144
View File
@@ -0,0 +1,144 @@
-- surroundings.lua — probe the tiles around the player without moving anything.
--
-- The game's examine mode (ingame_observe) already walks a cursor around the map
-- and is now narrated, but entering it, stepping, and leaving is heavy when you
-- just want to know what is immediately next to you. This answers that in one
-- keypress, with no state change and no game side effects.
local speech = require("accessibility.speech")
local describe = require("accessibility.describe")
local surroundings = {}
-- Screen coordinates: +y is south.
local numpadDirections = {
kp1 = {-1, 1},
kp2 = {0, 1},
kp3 = {1, 1},
kp4 = {-1, 0},
kp5 = {0, 0},
kp6 = {1, 0},
kp7 = {-1, -1},
kp8 = {0, -1},
kp9 = {1, -1},
}
-- Clockwise from north, which is how people scan a compass.
local scanOrder = {
{0, -1, "north"},
{1, -1, "north-east"},
{1, 0, "east"},
{1, 1, "south-east"},
{0, 1, "south"},
{-1, 1, "south-west"},
{-1, 0, "west"},
{-1, -1, "north-west"},
}
local function getLevel()
local ok, world = pcall(require, "models.world")
if not ok or not world.currentLevel then
return nil
end
return world.currentLevel
end
local function playerPosition(level)
local actor = level and level.playerActor
if not actor or not actor.x or not actor.y then
return nil
end
return actor.x, actor.y
end
--------------------------------------------------------------------------------
-- Probing
--------------------------------------------------------------------------------
-- Speak a single tile at an offset from the player.
function surroundings.probe(dx, dy)
local level = getLevel()
local px, py = playerPosition(level)
if not px then
return
end
if dx == 0 and dy == 0 then
speech.say("Standing on: " .. describe.tile(level, px, py), true)
return
end
speech.say(describe.tile(level, px + dx, py + dy, px, py), true)
end
-- Speak all eight neighbours, clockwise from north. Terse by design: eight
-- descriptions at normal verbosity is a wall of speech.
function surroundings.scan()
local level = getLevel()
local px, py = playerPosition(level)
if not px then
return
end
local previousVerbosity = describe.verbosity
describe.verbosity = "terse"
local parts = {}
for _, entry in ipairs(scanOrder) do
local dx, dy, name = entry[1], entry[2], entry[3]
local text = describe.tile(level, px + dx, py + dy)
parts[#parts + 1] = name .. ", " .. text
end
describe.verbosity = previousVerbosity
speech.say(table.concat(parts, ". "), true)
end
-- Where am I: level name, in-game time, coordinates.
function surroundings.whereAmI()
local level = getLevel()
local px, py = playerPosition(level)
if not px then
return
end
local parts = {}
if level.name then
parts[#parts + 1] = level.name
end
local ok, world = pcall(require, "models.world")
if ok and world.printableDatetime then
parts[#parts + 1] = world.printableDatetime
end
parts[#parts + 1] = "position " .. px .. ", " .. py
speech.say(table.concat(parts, ". "), true)
end
--------------------------------------------------------------------------------
-- Key registration
--------------------------------------------------------------------------------
function surroundings.registerKeys(hotkeys)
-- Numpad probing is modifier-gated: kp1-kp9 are movement in game, so an
-- unmodified numpad key must keep moving the player. Ctrl is the game's
-- moveCamera modifier but that only acts on direction commands we consume
-- here, so Ctrl+numpad is free.
for scancode, offset in pairs(numpadDirections) do
hotkeys.bind(scancode, "ctrl", function()
surroundings.probe(offset[1], offset[2])
end)
end
hotkeys.bind("/", nil, surroundings.scan)
hotkeys.bind(";", nil, surroundings.whereAmI)
end
return surroundings
+39 -3
View File
@@ -50,16 +50,30 @@ end
-- Presses a key and waits long enough for the event to be pumped, handled, and -- Presses a key and waits long enough for the event to be pumped, handled, and
-- for our end-of-frame speech flush to run. -- for our end-of-frame speech flush to run.
local function press(scancode, note) local function press(scancode, note, mods)
local before = focusText() local before = focusText()
print(string.format("[testbed] (%s) press %-8s focus_before=%s %s", print(string.format("[testbed] (%s) press %-8s%s focus_before=%s %s",
currentState(), scancode, before and ('"' .. before .. '"') or "nil", note or "")) currentState(), scancode,
mods and (mods.ctrl and " +ctrl" or mods.shift and " +shift" or "") or "",
before and ('"' .. before .. '"') or "nil", note or ""))
-- Modifier state is read from real hardware, which synthetic events cannot
-- affect -- so override the hotkey layer's modifier probes for this press.
local hotkeys = require("accessibility.hotkeys")
local realCtrl, realShift = hotkeys.isCtrlDown, hotkeys.isShiftDown
if mods then
hotkeys.isCtrlDown = function() return mods.ctrl == true end
hotkeys.isShiftDown = function() return mods.shift == true end
end
love.event.push("keypressed", scancode, scancode, false) love.event.push("keypressed", scancode, scancode, false)
love.event.push("keyreleased", scancode, scancode) love.event.push("keyreleased", scancode, scancode)
wait(6) wait(6)
hotkeys.isCtrlDown, hotkeys.isShiftDown = realCtrl, realShift
local after = focusText() local after = focusText()
print(string.format("[testbed] -> state=%s focus_after=%s", print(string.format("[testbed] -> state=%s focus_after=%s",
currentState(), after and ('"' .. after .. '"') or "nil")) currentState(), after and ('"' .. after .. '"') or "nil"))
@@ -202,6 +216,28 @@ local function scenario()
press("[", "review previous") press("[", "review previous")
press("]", "review next") press("]", "review next")
press("\\", "repeat current") press("\\", "repeat current")
-- Phase 2: surroundings probing (no state change, no game side effects).
press("/", "scan all eight neighbours")
press(";", "where am I")
press("kp8", "probe north", {ctrl = true})
press("kp6", "probe east", {ctrl = true})
press("kp5", "probe standing-on", {ctrl = true})
-- The game's own examine mode, now narrated via levelCursorPopup.
press("x", "enter examine mode")
wait(10)
check("entered examine mode", currentState() == "ingame_observe",
"got " .. currentState())
if currentState() == "ingame_observe" then
press("right", "examine: cursor east")
press("down", "examine: cursor south")
press("escape", "leave examine mode")
wait(10)
check("left examine mode", currentState() == "ingame",
"got " .. currentState())
end
end end
print(string.format("[testbed] === %d checks, %d failures ===", checks, failures)) print(string.format("[testbed] === %d checks, %d failures ===", checks, failures))