diff --git a/accessibility/docs/scratch.md b/accessibility/docs/scratch.md index d1b70ad..b7f529c 100644 --- a/accessibility/docs/scratch.md +++ b/accessibility/docs/scratch.md @@ -84,10 +84,76 @@ None block Phase 0. Q4 lands in Phase 1, Q5 + verbosity in Phase 2. [speech]! Main menu ``` -### Next — Phase 1: console history + review keys -Wrap `console.print`, keep our own unbounded history (the game's expires after 3s), -mirror its `(x3)` coalescing, add review keys. Needs Q4 answered (auto-speak policy) -but a sensible default can ship first. +### Done — Phase 1: console history + review keys · + wall tones +- `accessibility/history.lua` — unbounded (1000-entry ring) history. Mirrors the + game's `(xN)` coalescing so speech matches the screen. Review cursor **holds + still while new lines arrive** and only follows the tail when not being used. +- `accessibility/hotkeys.lua` — global key layer in front of `states.keypressed`. +- `accessibility/patches/console.lua` — wraps `console.print` (calls the real one + *first*, so a mod bug can't swallow an on-screen message) and `console.clear`. +- `accessibility/walltones.lua` — see below. +- `accessibility/tests/test_history.lua` — 16 assertions, all passing. + Run with: `lua accessibility/tests/test_history.lua` (pure Lua, no LÖVE needed). + +**Auto-speak policy (Q4, answered):** speak everything, queued and joined in order +at end of frame. Any keypress that reaches the game counts as an explicit action +and silences the pending backlog (`hotkeys.onGameKey` → `speech.silence`). + +**⚠ Not yet verified in-game.** Loading, the self-test, and history logic are all +confirmed. Whether console speech and wall tones behave correctly *during play* +needs a human at the keyboard — Talon to test. + +### Keymap so far + +| Key | Action | +|---|---| +| `[` / `]` | Previous / next console history line | +| `Shift+[` / `Shift+]` | Oldest / newest line | +| `\` | Repeat current line | +| `Shift+\` | Copy current line to clipboard | + +Reserved for the tracker (Phase 3): `PageUp`/`PageDown`, `Home`, with Ctrl/Shift. + +**Free scancodes** (game binds none of these): `[ ] \ ; ' , / - =`, `h k l m n o +p u v w`, `F1`–`F12`, `pageup pagedown home end insert`, `kp0 kp. kp+ kp- kp* kp/`. + +**Game-claimed scancodes — never bind these unmodified:** +`. 0-9 \` a b backspace c d delete down e escape f g i j kp1-kp9 kpenter lalt +lctrl left lshift mouse3 q r ralt rctrl return right rshift s space t tab up x y z` + +⚠ **`keybindings.getCommand()` dispatches on scancode alone and ignores +modifiers.** Ctrl and Shift are *held* modifiers in game (`moveCamera`, +`repeatAction`/`adjustByFive`). So `Ctrl+Up` is seen by the game as plain "up" — +a movement. Never bind a modifier combo over a game-claimed scancode. Combos over +*unclaimed* scancodes (e.g. `Ctrl+PageUp`) are safe. Always consume our keys. + +### Wall tones (Q5, answered: on step, 3D-positioned, radius 5) + +Key design point, in `walltones.lua`: **direction and distance are decoupled.** +The game's listener sits at the camera, 4 units above the plane, with an *inverse* +distance model. Placing a tone at its wall's real position would (a) make far +walls quieter — the opposite of what was asked — and (b) collapse the pan toward +centre, since the 4-unit vertical offset dominates a 2-unit horizontal one. + +So: the source is placed at a **fixed radius** from the listener in the wall's +direction, at the listener's own height (pure horizontal pan, constant +attenuation), and **volume is computed explicitly** from distance. Positioned +relative to the listener via `love.audio.getPosition()`, so panning stays correct +even while the camera is panned away. + +Step detection **polls** `playerActor.x/y` each frame instead of hooking movement +— free, and covers walking, pathing, portals and pushes by construction. + +⚠ `config.louderWhenFurther = true` as requested, but this inverts the usual +convention (proximity cues normally get louder as you approach, and a near wall is +the more urgent fact). Flip the flag in `walltones.lua` if it reads backwards. + +Respects `FoV`/`rememberedFoV` — never leaks unseen map geometry. + +### Next — Phase 2: tile examination +Patch `views/levelCursorPopup.set` to narrate the game's existing examine mode, +augmented with distance/direction/passability. Then the surroundings explorer +(`Alt`+numpad to probe a direction without moving). Needs Q3 (verbosity) answered. --- diff --git a/accessibility/history.lua b/accessibility/history.lua new file mode 100644 index 0000000..5c5dcd7 --- /dev/null +++ b/accessibility/history.lua @@ -0,0 +1,193 @@ +-- history.lua — unbounded console history with a review cursor. +-- +-- The game's own console buffer (modules/console.lua) is unusable as history: its +-- entries expire after 3 seconds and scroll away. We keep our own copy, appended +-- from a wrapper around console.print. +-- +-- Coalescing mirrors the game exactly. console.print folds a repeated identical +-- message into the previous entry and bumps a `times` counter, drawing it as +-- "You feel cold (x3)". We do the same and speak the same string, so what is +-- heard matches what is on screen. + +local speech = require("accessibility.speech") + +local history = {} + +local MAX_ENTRIES = 1000 + +local entries = {} +local reviewIndex = nil -- nil = following the tail (live) + +-------------------------------------------------------------------------------- +-- Rendering an entry +-------------------------------------------------------------------------------- + +local function entryText(entry) + if not entry then + return nil + end + + if entry.times and entry.times > 1 then + return entry.text .. " (x" .. entry.times .. ")" + end + + return entry.text +end + +history.entryText = entryText + +-------------------------------------------------------------------------------- +-- Appending +-------------------------------------------------------------------------------- + +-- Called from the console.print wrapper. Returns the text that should be spoken, +-- or nil if nothing should be. +function history.append(text, colorTemplate) + text = speech.normalise(text) + if not text then + return nil + end + + local last = entries[#entries] + + if last and last.text == text then + -- Same fold the game performs. + last.times = last.times + 1 + return entryText(last) + end + + entries[#entries + 1] = { + text = text, + times = 1, + colorTemplate = colorTemplate, + } + + -- Trim from the front. Keep the review cursor pointing at the same entry. + if #entries > MAX_ENTRIES then + table.remove(entries, 1) + if reviewIndex then + reviewIndex = math.max(1, reviewIndex - 1) + end + end + + return entryText(entries[#entries]) +end + +function history.count() + return #entries +end + +function history.clear() + entries = {} + reviewIndex = nil +end + +-------------------------------------------------------------------------------- +-- Review cursor +-------------------------------------------------------------------------------- + +-- While reviewing, the cursor holds still as new lines arrive; it only follows the +-- tail when the user has not moved it. Otherwise reading back through a fight +-- would be yanked forward by every new message. + +local function speakAt(index) + if #entries == 0 then + speech.interrupt("No messages") + return + end + + local entry = entries[index] + if not entry then + return + end + + local position + if index == 1 then + position = "First message" + elseif index == #entries then + position = "Last message" + end + + local text = entryText(entry) + + if position then + -- Most game messages already end in punctuation; joining with ". " blindly + -- gives "You hit the rat.. First message". + local separator = text:match("[%.%!%?]$") and " " or ". " + text = text .. separator .. position + end + + speech.interrupt(text) +end + +function history.reviewPrevious() + if #entries == 0 then + speech.interrupt("No messages") + return + end + + if reviewIndex == nil then + -- Entering review from live: start at the newest, then step back one. + reviewIndex = #entries + end + + reviewIndex = math.max(1, reviewIndex - 1) + speakAt(reviewIndex) +end + +function history.reviewNext() + if #entries == 0 then + speech.interrupt("No messages") + return + end + + if reviewIndex == nil then + speakAt(#entries) + reviewIndex = #entries + return + end + + reviewIndex = math.min(#entries, reviewIndex + 1) + speakAt(reviewIndex) +end + +function history.reviewFirst() + if #entries == 0 then + speech.interrupt("No messages") + return + end + + reviewIndex = 1 + speakAt(reviewIndex) +end + +function history.reviewLast() + if #entries == 0 then + speech.interrupt("No messages") + return + end + + reviewIndex = nil -- back to following live output + speakAt(#entries) +end + +function history.repeatCurrent() + if #entries == 0 then + speech.interrupt("No messages") + return + end + + speakAt(reviewIndex or #entries) +end + +function history.copyCurrent() + if #entries == 0 then + return + end + + local text = entryText(entries[reviewIndex or #entries]) + local ok = pcall(love.system.setClipboardText, text) + speech.interrupt(ok and "Copied" or "Copy failed") +end + +return history diff --git a/accessibility/hotkeys.lua b/accessibility/hotkeys.lua new file mode 100644 index 0000000..45f175b --- /dev/null +++ b/accessibility/hotkeys.lua @@ -0,0 +1,130 @@ +-- hotkeys.lua — the mod's global key layer. +-- +-- Sits in front of states.keypressed. Keys we own are handled and CONSUMED (the +-- game never sees them); everything else falls straight through. +-- +-- Consuming matters more than it looks. keybindings.getCommand() dispatches on +-- scancode alone and ignores modifiers, checking held modifiers separately via +-- isCommandDown. So Ctrl+Up is seen by the game as plain "up" — a movement — with +-- moveCamera held. If we bound anything to Ctrl+Up and passed it through, the +-- player would move. Hence: our keys are chosen from scancodes the game does not +-- bind at all, and we still consume them. +-- +-- Keys the game claims by default (do not use): +-- . 0-9 ` a b backspace c d delete down e escape f g i j kp1-kp9 kpenter +-- lalt lctrl left lshift mouse3 q r ralt rctrl return right rshift s space +-- t tab up x y z +-- +-- Note Ctrl = moveCamera and Shift = repeatAction/adjustByFive are HELD modifiers +-- in game. Combining them with an unbound scancode (e.g. Ctrl+PageUp) is safe, +-- because the game maps the scancode to no command and does nothing. + +local speech = require("accessibility.speech") +local history = require("accessibility.history") + +local hotkeys = {} + +-------------------------------------------------------------------------------- +-- Where our keys must stay out of the way +-------------------------------------------------------------------------------- + +-- States that accept free text. Swallowing "[" while the player is naming a +-- character or typing in the developer console would be a real bug. +local textEntryStates = { + ingame_say = true, + new_game = true, + keybindings_menu = true, -- captures raw keys for rebinding +} + +local function isTextEntryState() + local ok, states = pcall(require, "states") + if not ok then + return false + end + return textEntryStates[states.getCurrent()] == true +end + +-------------------------------------------------------------------------------- +-- Modifier helpers +-------------------------------------------------------------------------------- + +local function ctrlDown() + return love.keyboard.isScancodeDown("lctrl", "rctrl") +end + +local function shiftDown() + return love.keyboard.isScancodeDown("lshift", "rshift") +end + +-------------------------------------------------------------------------------- +-- Bindings +-- +-- Each entry: scancode -> { {mods, action}, ... }, checked in order. +-- mods: "ctrl", "shift", or nil for "no modifier required". +-------------------------------------------------------------------------------- + +local bindings = {} + +local function bind(scancode, mods, action) + bindings[scancode] = bindings[scancode] or {} + table.insert(bindings[scancode], {mods = mods, action = action}) +end + +-- Console review +bind("[", nil, history.reviewPrevious) +bind("]", nil, history.reviewNext) +bind("[", "shift", history.reviewFirst) +bind("]", "shift", history.reviewLast) +bind("\\", nil, history.repeatCurrent) +bind("\\", "shift", history.copyCurrent) + +function hotkeys.bind(scancode, mods, action) + bind(scancode, mods, action) +end + +local function matches(entry) + if entry.mods == "ctrl" then + return ctrlDown() + elseif entry.mods == "shift" then + return shiftDown() + end + -- No modifier required: reject if one is held, so Shift+[ doesn't also fire [. + return not ctrlDown() and not shiftDown() +end + +-------------------------------------------------------------------------------- +-- Dispatch +-------------------------------------------------------------------------------- + +-- Returns true if the key was ours and should NOT reach the game. +function hotkeys.keypressed(_key, scancode) + if isTextEntryState() then + return false + end + + local candidates = bindings[scancode] + if not candidates then + return false + end + + for _, entry in ipairs(candidates) do + if matches(entry) then + local ok, err = pcall(entry.action) + if not ok then + print("[accessibility] hotkey '" .. tostring(scancode) .. "' error: " .. tostring(err)) + end + return true + end + end + + return false +end + +-- Any key that reaches the game is an explicit player action, so it cancels the +-- pending speech backlog. Whatever that action produces is then spoken fresh. +-- This is the "speak everything queued, unless explicitly stopped" policy. +function hotkeys.onGameKey() + speech.silence() +end + +return hotkeys diff --git a/accessibility/init.lua b/accessibility/init.lua index 46ade6b..df89878 100644 --- a/accessibility/init.lua +++ b/accessibility/init.lua @@ -15,6 +15,8 @@ local accessibility = {} local speech = require("accessibility.speech") local stateNames = require("accessibility.stateNames") +local hotkeys = require("accessibility.hotkeys") +local walltones = require("accessibility.walltones") local initialized = false @@ -86,9 +88,28 @@ local function hookStates() local realUpdate = states.update states.update = function(dt) local result = realUpdate(dt) + pcall(walltones.update) pcall(speech.flush) return result end + + -- Global hotkey layer. Ours are consumed; everything else falls through to the + -- game untouched. + local realKeypressed = states.keypressed + states.keypressed = function(key, scancode) + local consumed = false + local ok, result = pcall(hotkeys.keypressed, key, scancode) + if ok then + consumed = result + end + + if consumed then + return + end + + pcall(hotkeys.onGameKey) + return realKeypressed(key, scancode) + end end -------------------------------------------------------------------------------- @@ -101,8 +122,11 @@ function accessibility.initialize() end initialized = true + accessibility.registerPatch("console", require("accessibility.patches.console")) + local ok, err = pcall(function() speech.initialize() + walltones.load() hookStates() applyPatches() end) diff --git a/accessibility/patches/console.lua b/accessibility/patches/console.lua new file mode 100644 index 0000000..d7bdcc8 --- /dev/null +++ b/accessibility/patches/console.lua @@ -0,0 +1,46 @@ +-- Patch: modules/console.lua +-- +-- console.print is the single chokepoint for every log message the game produces +-- (combat, interaction, flavour, status). Wrapping it captures all of them. +-- +-- The wrapper must return the original's results verbatim and must not alter what +-- the game draws. + +local history = require("accessibility.history") +local speech = require("accessibility.speech") + +return function() + local console = require("modules.console") + + local realPrint = console.print + if not realPrint then + error("modules.console has no print function -- game structure changed") + end + + console.print = function(message, colorTemplate) + -- Game first, so a bug on our side cannot swallow a message the player + -- would otherwise see on screen. + local result = realPrint(message, colorTemplate) + + pcall(function() + local text = history.append(message, colorTemplate) + if text then + -- Queued, not interrupting: several messages can land in one turn + -- and speech.flush joins them in order at end of frame. + speech.say(text, false) + end + end) + + return result + end + + -- console.clear is called on new game / level reset. Keep our history aligned + -- so review doesn't read back messages from a previous playthrough. + local realClear = console.clear + if realClear then + console.clear = function(...) + pcall(history.clear) + return realClear(...) + end + end +end diff --git a/accessibility/tests/test_history.lua b/accessibility/tests/test_history.lua new file mode 100644 index 0000000..efdebf9 --- /dev/null +++ b/accessibility/tests/test_history.lua @@ -0,0 +1,80 @@ +-- Standalone test of accessibility/history.lua coalescing + review cursor. +package.path = "./?.lua;" .. package.path + +-- Stub the speech module before history requires it. +local spoken = {} +package.loaded["accessibility.speech"] = { + normalise = function(t) + if t == nil then return nil end + t = tostring(t):gsub("[\r\n]+", ". "):gsub("%s+", " "):gsub("^%s+",""):gsub("%s+$","") + if t == "" then return nil end + return t + end, + interrupt = function(t) spoken[#spoken+1] = t end, + say = function(t) spoken[#spoken+1] = t end, +} +love = { system = { setClipboardText = function() end } } + +local history = require("accessibility.history") + +local failures = 0 +local function check(label, got, want) + if got ~= want then + failures = failures + 1 + print(string.format("FAIL %-42s got=%q want=%q", label, tostring(got), tostring(want))) + else + print(string.format("ok %-42s %s", label, tostring(got))) + end +end + +-- 1. plain append +check("first append", history.append("You hit the rat."), "You hit the rat.") + +-- 2. coalescing matches the game's (xN) rendering +history.append("You feel cold.") +check("repeat -> x2", history.append("You feel cold."), "You feel cold. (x2)") +check("repeat -> x3", history.append("You feel cold."), "You feel cold. (x3)") +check("entry count after folds", history.count(), 2) + +-- 3. a different message breaks the fold +check("new message", history.append("The rat dies."), "The rat dies.") +check("entry count", history.count(), 3) + +-- 4. normalisation: newlines collapse, empties rejected +check("nil rejected", history.append(nil), nil) +check("blank rejected", history.append(" "), nil) +check("newlines collapsed", history.append("a\nb"), "a. b") + +-- 5. review cursor +spoken = {} +history.reviewLast() +check("reviewLast speaks newest", spoken[#spoken], "a. b. Last message") + +spoken = {} +history.reviewPrevious() +check("reviewPrevious steps back", spoken[#spoken], "The rat dies.") + +spoken = {} +history.reviewFirst() +check("reviewFirst", spoken[#spoken], "You hit the rat. First message") + +spoken = {} +history.reviewPrevious() +check("clamps at first", spoken[#spoken], "You hit the rat. First message") + +-- 6. cursor holds still while new lines arrive (the key requirement) +history.reviewFirst() +history.append("Something new happened.") +spoken = {} +history.reviewNext() +check("cursor not yanked to tail", spoken[#spoken], "You feel cold. (x3)") + +-- 7. empty history +history.clear() +spoken = {} +history.reviewNext() +check("empty history", spoken[#spoken], "No messages") +check("count after clear", history.count(), 0) + +print(failures == 0 and "\nALL PASS" or ("\n" .. failures .. " FAILURE(S)")) +os.exit(failures == 0 and 0 or 1) diff --git a/accessibility/walltones.lua b/accessibility/walltones.lua new file mode 100644 index 0000000..c26caa4 --- /dev/null +++ b/accessibility/walltones.lua @@ -0,0 +1,222 @@ +-- walltones.lua — directional wall cues, played on each step. +-- +-- Scans the four cardinal directions from the player out to `radius` tiles, finds +-- the first movement-blocking tile in each, and plays that direction's tone +-- positioned in 3D so the ear can place it. +-- +-------------------------------------------------------------------------------- +-- Why direction and distance are handled separately +-------------------------------------------------------------------------------- +-- +-- The game sets an *inverse* distance model (main.lua) and puts the listener at +-- the camera: views/levelWindow.lua does +-- +-- love.audio.setPosition(x*0.4, y*0.4, 4) +-- +-- i.e. 0.4 world units per tile, listener floating 4 units above the map plane. +-- +-- If we simply placed each tone at its wall's real position, OpenAL would +-- attenuate it by distance -- so a far wall would be QUIETER. That directly +-- fights the requested behaviour (far wall = louder). It would also be feeble +-- directionally: with the listener 4 units up and a wall 5 tiles out (2.0 units), +-- the vertical offset dominates and the pan collapses toward centre. +-- +-- So we decouple the two: +-- +-- * DIRECTION comes from 3D position. The source is placed at a FIXED radius +-- from the listener, in the wall's direction, at the listener's own height +-- (z = listener z) so the pan is purely horizontal and as wide as possible. +-- * DISTANCE comes from explicit volume, which we compute. Because the source +-- sits at a constant distance, OpenAL's attenuation is constant and does not +-- interfere. +-- +-- Positioning relative to the *listener* (queried via love.audio.getPosition) +-- rather than to the player keeps the pan correct even while the player is +-- panning the camera away with the moveCamera key. + +local walltones = {} + +local config = { + enabled = true, + + -- How far to look for a wall, in tiles. + radius = 5, + + -- Fixed distance (world units) at which tones are placed. Only affects the + -- constant attenuation, not the perceived direction. + sourceRadius = 1.0, + + -- Volume at the near and far ends of the range. + nearVolume = 0.25, + farVolume = 1.0, + + -- As requested: a wall further away is LOUDER. + -- + -- Flagging this because it inverts the usual convention -- most proximity cues + -- get louder as you approach, and a nearby wall is the more urgent fact. If it + -- reads backwards in play, set this to false and near walls become the loud + -- ones; nothing else needs to change. + louderWhenFurther = true, +} + +walltones.config = config + +local directions = { + north = {dx = 0, dy = -1}, + south = {dx = 0, dy = 1}, + east = {dx = 1, dy = 0}, + west = {dx = -1, dy = 0}, +} + +local sources = {} + +local lastX, lastY, lastLevel + +-------------------------------------------------------------------------------- +-- Loading +-------------------------------------------------------------------------------- + +function walltones.load() + for name in pairs(directions) do + local path = "accessibility/assets/walltones/walltone_" .. name .. ".wav" + if love.filesystem.getInfo(path) then + sources[name] = path + else + print("[accessibility] missing wall tone: " .. path) + end + end +end + +-------------------------------------------------------------------------------- +-- Scanning +-------------------------------------------------------------------------------- + +-- Distance in tiles to the first movement-blocking tile in a direction, or nil if +-- none within radius. +-- +-- Only counts tiles the player has actually seen (FoV or rememberedFoV), so this +-- never leaks map knowledge -- the tracker/explorer must not become a cheat. +local function distanceToWall(level, x, y, dx, dy) + for step = 1, config.radius do + local tx, ty = x + dx * step, y + dy * step + + if not level.grid[tx] or not level.grid[tx][ty] then + return step -- edge of map reads as a wall + end + + local seen = (level.FoV and level.FoV[tx] and level.FoV[tx][ty]) + or (level.rememberedFoV and level.rememberedFoV[tx] and level.rememberedFoV[tx][ty]) + + if not seen then + return nil + end + + for _, tile in ipairs(level.grid[tx][ty]) do + if tile.blocksMovement and not tile.isHidden then + return step + end + end + end + + return nil +end + +-------------------------------------------------------------------------------- +-- Playing +-------------------------------------------------------------------------------- + +local function volumeFor(distance) + -- Normalise to 0 (adjacent) .. 1 (at the radius limit). + local t = (distance - 1) / math.max(1, config.radius - 1) + t = math.max(0, math.min(1, t)) + + if not config.louderWhenFurther then + t = 1 - t + end + + return config.nearVolume + (config.farVolume - config.nearVolume) * t +end + +local function playTone(name, dx, dy, distance) + local path = sources[name] + if not path then + return + end + + -- Tagged so the game's own sfx/interface volume sliders apply. + local ok, source = pcall(TEsound.play, path, "static", {"sfx", "interface"}, volumeFor(distance)) + if not ok or not source then + return + end + + pcall(function() + local lx, ly, lz = love.audio.getPosition() + source:setPosition( + lx + dx * config.sourceRadius, + ly + dy * config.sourceRadius, + lz -- same height as the listener: maximise horizontal pan + ) + end) +end + +function walltones.playFor(level, x, y) + if not config.enabled then + return + end + + for name, dir in pairs(directions) do + local distance = distanceToWall(level, x, y, dir.dx, dir.dy) + if distance then + playTone(name, dir.dx, dir.dy, distance) + end + end +end + +-------------------------------------------------------------------------------- +-- Step detection +-- +-- Polling the player's tile coordinates each frame rather than hooking movement. +-- Comparing two integers is free, and it means no game function is wrapped -- +-- every way the player can move (walk, path, teleport, portal, push) is covered +-- by construction. +-------------------------------------------------------------------------------- + +function walltones.update() + if not config.enabled then + return + end + + local ok, world = pcall(require, "models.world") + if not ok then + return + end + + local level = world.currentLevel + if not level or not level.playerActor then + return + end + + local actor = level.playerActor + local x, y = actor.x, actor.y + + if not x or not y then + return + end + + -- Entering a new level should not fire a volley of tones for a position the + -- player never stepped from. + if level ~= lastLevel then + lastLevel = level + lastX, lastY = x, y + return + end + + if x == lastX and y == lastY then + return + end + + lastX, lastY = x, y + walltones.playFor(level, x, y) +end + +return walltones