Files
alchemist-accessibility/accessibility/describe.lua
T
Accessibility ModandClaude Opus 5 8b1eb44c38 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>
2026-08-01 21:26:35 +02:00

197 lines
5.0 KiB
Lua

-- 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