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