diff --git a/accessibility/README.md b/accessibility/README.md index 52042b8..d7fc83e 100644 --- a/accessibility/README.md +++ b/accessibility/README.md @@ -139,6 +139,22 @@ The game's own examine mode (**`x`**) is also fully spoken: the arrow keys move cursor around the map and each tile is described with its distance and direction from you. Press `f` in examine mode to jump between exits. +### Aiming + +Both targeting modes now speak. No new keys — this is the game's own aiming. + +**Ranged aiming** (throw, `t`) works like examine mode: the arrow keys move the +target cursor and each tile is read out as you land on it, followed by whether +you can actually throw there — *"can throw"*, *"blocked"*, *"out of range"* or +*"no line of sight"* — and how far away it is. `z` throws. + +**Melee aiming** (hit `b`, interact `g`, push `y`) has no cursor: in this game a +single direction key both picks the tile and swings. So instead of reading a +selection that does not exist, the mod reads **all eight tiles around you the +moment you enter melee aiming** — clockwise from north — which are exactly the +tiles you can reach. Listen, then press the direction you want. `z` targets +yourself; `escape` backs out without attacking. + ### Object tracker Finds everything you have seen, sorted by distance. diff --git a/accessibility/docs/scratch.md b/accessibility/docs/scratch.md index 84d19e0..32152f0 100644 --- a/accessibility/docs/scratch.md +++ b/accessibility/docs/scratch.md @@ -7,7 +7,7 @@ fixing what Talon hits. Remaining work is polish, mod settings and distribution. **Game is playable start-to-finish by ear** from main menu through the intro conversation into gameplay, plus journal, character, actions and inventory — -verified by the testbed, **120 checks, 0 failures**. +verified by the testbed, **129 checks, 0 failures**. **Not set up yet, or unsure how the two trees relate?** → [00-dev-setup.md](00-dev-setup.md). @@ -282,7 +282,13 @@ where you're standing now. `playing` is cleared on level change. (`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/states/ingame_targeting_throw.lua` — narrates the ranged + targeting cursor, plus whether the tile is a legal target. +- `accessibility/states/ingame_targeting_melee.lua` — speaks the eight adjacent + tiles on entry. **No `read()`, by design** (see below). - `accessibility/surroundings.lua` — probe/scan/where-am-I without moving. + `describeNeighbours()` returns the eight-neighbour line; `scan()` speaks it and + melee targeting reuses it. - `accessibility/patches/popups.lua` — wraps `inventoryCursorPopup.set`, covering **inventory, craft, refine, trade** in one patch. @@ -301,6 +307,38 @@ 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.)* +#### Design note: the two targeting modes are not symmetrical +They look like a pair and are not. + +**Throw** (`states/ingame_targeting_throw.lua`) has a real free-roaming cursor on +`level.cursorX/cursorY`, so its descriptor is `ingame_observe` plus a validity +clause. ⚠ **Do not read the state's `targetAllowed` upvalue** for that clause: +`throw.lua:228` sets it `false` for the whole 0.3s cursor glide and only the +animation's `onFinish` restores it, so mid-glide — which is exactly when we +speak — it always says "blocked". Recompute instead from the same three public +inputs the game uses (`throw.lua:86-89`): `level.FoV`, `level.interactableArea`, +`level.canThrowOnCell`. + +**Melee** (`states/ingame_targeting_melee.lua`) has **no cursor and no target +state at all**: `melee.lua:184-195` takes one direction key and fires the +callback in that same keypress. Nothing is ever "currently selected". Its +descriptor therefore has no `read()` and speaks only on entry, listing the eight +adjacent tiles (which are precisely the legal targets — the game's rule is +Chebyshev ≤ 1, `playerActions.lua:3-6`). A future reader should not "fix" the +missing `read()`; there is nothing for it to return. A two-step confirm cursor +was considered and rejected: it would change how the game plays. + +⚠ `menus.selfTest` had to grow a branch for descriptors with neither `read` nor +`probe` — melee is the first — or it would index a nil `probe` and take the +*whole* self test down with it. + +⚠ Neither mode is reachable by its key in the prologue: `b` (hit) is filtered out +by the room's `level.allowedActions`, and `t` (throw) falls through to +`gotoInventory()` when there is nothing throwable +(`states/ingame.lua:986-990`). The testbed drives both by `states.switch`, +borrowing `levelWindow`/`hpbar`/`levelDescriptionWindow` off the `ingame` state's +upvalues the way `gotoAction` would. + #### Testability seam `hotkeys.isCtrlDown` / `isShiftDown` are overridable fields, not direct calls to `love.keyboard.isScancodeDown`. Real hardware state can't be faked by @@ -378,7 +416,8 @@ yet. Verify against a later area before trusting those two categories.)* `init.lua`, all resolving in the startup self-test: `main_menu` · `settings_menu` · `load_game` · `new_game` · `narration` · -`ingame_dialogue` · `ingame_observe` · `actions_menu` · `worldmap` · +`ingame_dialogue` · `ingame_observe` · `ingame_targeting_melee` · +`ingame_targeting_throw` · `actions_menu` · `worldmap` · `alchemy_table` · `journal` · `character` · `inventory` · `menu_text` / `ingame_message` / `menu_message` (shared `_textscreen.lua`) @@ -1000,6 +1039,10 @@ require trap as the research tree. announcements (`popupStack.add`), damage alerts (`ingame.checkInterruptions`), mod settings. - `ingame` itself has no descriptor. +- **Targeting is verified only against the prologue room**, where neither mode is + reachable by its own key and the testbed drives them by `states.switch`. The + throw descriptor's "out of range" / "no line of sight" / "blocked" branches + have not been seen against real content — only "can throw". - **Unverified against real content:** tracker categories Items / Alchemy / Exits / Creatures / Terrain (tutorial house has none), and inventory item names (inventory is empty in the prologue). diff --git a/accessibility/init.lua b/accessibility/init.lua index 31354ef..26e21de 100644 --- a/accessibility/init.lua +++ b/accessibility/init.lua @@ -31,6 +31,8 @@ local describedStates = { "narration", "ingame_dialogue", "ingame_observe", + "ingame_targeting_melee", + "ingame_targeting_throw", "actions_menu", "worldmap", "alchemy_table", @@ -136,7 +138,17 @@ local function hookStates() if descriptor then menus.announceScreen(newState) if descriptor.onOpen then - speech.say(descriptor.onOpen, false) + -- onOpen may be a fixed string or a function returning one. + -- Melee targeting needs the latter: what it says (the eight + -- adjacent tiles) differs every time the state is entered. + local text = descriptor.onOpen + if type(text) == "function" then + local okOpen, result = pcall(text) + text = okOpen and result or nil + end + if type(text) == "string" and text ~= "" then + speech.say(text, false) + end end else speech.say(stateNames[newState] or newState, true) diff --git a/accessibility/menus.lua b/accessibility/menus.lua index b988da7..804b659 100644 --- a/accessibility/menus.lua +++ b/accessibility/menus.lua @@ -364,6 +364,13 @@ function menus.selfTest(stateList) end end end + elseif not descriptor.probe then + -- Neither read() nor probe: a descriptor that only speaks on entry. + -- ingame_targeting_melee is one -- it has no focus to track because the + -- game keeps no target state at all. Nothing to canary here, but it must + -- be handled explicitly rather than falling through and indexing a nil + -- probe, which would throw and cost us the whole self test. + results[stateName] = descriptor.onOpen and "onOpen only" or "nothing to read" else local stateTable = getStateTable(stateName) if not stateTable then diff --git a/accessibility/states/ingame_targeting_melee.lua b/accessibility/states/ingame_targeting_melee.lua new file mode 100644 index 0000000..d429436 --- /dev/null +++ b/accessibility/states/ingame_targeting_melee.lua @@ -0,0 +1,27 @@ +-- states/ingame_targeting_melee.lua — the melee targeting prompt. +-- +-- Deliberately has NO `read`. Unlike throw targeting, melee has no cursor and no +-- target state to poll: states/ingame_targeting_melee.lua:184-195 takes a single +-- direction key, works out the tile, and fires the callback in that same +-- keypress. Nothing is ever "currently selected", so there is no focus to track +-- and the only useful moment to speak is on entry. +-- +-- What we say instead is the eight adjacent tiles, which are exactly the legal +-- melee targets (the game's own rule is Chebyshev distance <= 1 -- +-- models/player/playerActions.lua:3-6). Hearing them before committing is the +-- accessible equivalent of glancing at the targeting arrows. +-- +-- Do not "fix" this by adding a read(): there is nothing for it to return. + +local surroundings = require("accessibility.surroundings") + +return { + -- A function rather than a static string: the neighbours differ every time. + onOpen = function() + local ok, text = pcall(surroundings.describeNeighbours) + if not ok then + return nil + end + return text + end, +} diff --git a/accessibility/states/ingame_targeting_throw.lua b/accessibility/states/ingame_targeting_throw.lua new file mode 100644 index 0000000..d95beb7 --- /dev/null +++ b/accessibility/states/ingame_targeting_throw.lua @@ -0,0 +1,91 @@ +-- states/ingame_targeting_throw.lua — the ranged targeting cursor. +-- +-- Same shape, and same reasoning, as accessibility/states/ingame_observe.lua: +-- read level.cursorX/cursorY straight off the level model each frame rather than +-- listening to the game's cursor popup. The popup is refreshed from the cursor +-- animation's completion callback (`onFinish`), so relaying it would put every +-- announcement a keypress behind. animations/moveCursor.lua assigns cursorX/Y in +-- its constructor, so polling is correct on the frame the key is pressed. +-- +-- The extra job here over examine mode is saying whether the tile is a LEGAL +-- target, which is the whole point of being in this state. + +local describe = require("accessibility.describe") + +-- Recompute validity from the level rather than reading the state's own +-- `targetAllowed` upvalue. +-- +-- Why: states/ingame_targeting_throw.lua:228 sets targetAllowed = false for the +-- duration of the 0.3s cursor glide and only the animation's onFinish restores +-- it. We speak on the frame the key is pressed, i.e. mid-glide, so that upvalue +-- would report "blocked" for every single move. The three inputs the game +-- itself uses (:86-89) are all public fields, so recomputing is both cheap and +-- honest. +local function validity(level, x, y) + if not level.interactableArea then + return nil + end + + if not (level.FoV and level.FoV[x] and level.FoV[x][y]) then + return "no line of sight" + end + + if not (level.interactableArea[x] and level.interactableArea[x][y]) then + return "out of range" + end + + local ok, throwable = pcall(level.canThrowOnCell, x, y) + if ok and not throwable then + return "blocked" + end + + return "can throw" +end + +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 + + -- Terse: in combat the target's name, its legality and how far away it is + -- are what matter; the scenery adjectives are noise. + -- + -- Described without an origin so that describe.tile does not append the + -- offset itself -- the validity belongs between the two, giving + -- "rat, can throw, 3 tiles north-east". + local previousVerbosity = describe.verbosity + describe.verbosity = "terse" + local okTile, text = pcall(describe.tile, level, x, y) + describe.verbosity = previousVerbosity + + if not okTile or not text or text == "" then + return nil, nil + end + + local parts = {text} + + local state = validity(level, x, y) + if state then + parts[#parts + 1] = state + end + + if px and py then + parts[#parts + 1] = describe.offset(x - px, y - py) + end + + return table.concat(parts, ", "), x .. "," .. y + end, +} diff --git a/accessibility/surroundings.lua b/accessibility/surroundings.lua index 50090f1..a90ad1f 100644 --- a/accessibility/surroundings.lua +++ b/accessibility/surroundings.lua @@ -72,14 +72,17 @@ function surroundings.probe(dx, dy) 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() +-- All eight neighbours, clockwise from north, as one line. Terse by design: +-- eight descriptions at normal verbosity is a wall of speech. +-- +-- Returns the text rather than speaking it, because melee targeting needs the +-- same sentence on entry (accessibility/states/ingame_targeting_melee.lua). +function surroundings.describeNeighbours() local level = getLevel() local px, py = playerPosition(level) if not px then - return + return nil end local previousVerbosity = describe.verbosity @@ -94,7 +97,15 @@ function surroundings.scan() describe.verbosity = previousVerbosity - speech.say(table.concat(parts, ". "), true) + return table.concat(parts, ". ") +end + +-- Speak all eight neighbours. +function surroundings.scan() + local text = surroundings.describeNeighbours() + if text then + speech.say(text, true) + end end -- Where am I: level name, in-game time, coordinates. diff --git a/accessibility/testbed.lua b/accessibility/testbed.lua index 6747b9f..fc0305a 100644 --- a/accessibility/testbed.lua +++ b/accessibility/testbed.lua @@ -1377,6 +1377,132 @@ local function scenario() check("left examine mode", currentState() == "ingame", "got " .. currentState()) end + + -- Targeting. Both modes used to announce their name and then go silent, + -- which left no way to know what you were about to hit or throw at. + -- + -- Neither mode is reachable by its key here. The prologue room restricts + -- level.allowedActions, so `b` (hit) never resolves an action; and with + -- nothing throwable in the pack, `t` falls through to gotoInventory() + -- (states/ingame.lua:986-990). So drive both states directly, borrowing the + -- views from the ingame state exactly as gotoAction would pass them + -- (states/ingame.lua:133-151). + local function targetingParams() + local ingameTable = menus.getStateTable("ingame") + if not ingameTable then + return nil + end + + local params = { + range = 5, + callback = function() end, + levelWindow = menus.getAnyUpvalue(ingameTable, "levelWindow"), + hpbar = menus.getAnyUpvalue(ingameTable, "hpbar"), + levelDescriptionWindow = + menus.getAnyUpvalue(ingameTable, "levelDescriptionWindow"), + } + + if not (params.levelWindow and params.hpbar + and params.levelDescriptionWindow) then + return nil + end + + return params + end + + if currentState() == "ingame" then + local params = targetingParams() + if params then + params.actionName = "hit" + pcall(require("states").switch, "ingame_targeting_melee", params) + wait(10) + else + print("[testbed] could not harvest ingame views for melee targeting") + end + + if currentState() == "ingame_targeting_melee" then + check("entered melee targeting", true) + + -- Melee has no cursor: one direction key picks AND commits. So the + -- descriptor speaks on entry only, and we must not press an arrow + -- here -- that would swing at something and perturb the run. + local descriptor = menus.loadDescriptor("ingame_targeting_melee") + local spoken = descriptor and descriptor.onOpen and descriptor.onOpen() + + check("melee targeting describes the adjacent tiles on entry", + type(spoken) == "string" and spoken ~= "", + "onOpen=" .. tostring(spoken)) + + if type(spoken) == "string" then + local missing + for _, dir in ipairs({"north-east", "east", "south-east", + "south", "south-west", "west", "north-west"}) do + if not spoken:find(dir, 1, true) then + missing = dir + break + end + end + check("melee entry scan covers all eight directions", + missing == nil, "missing " .. tostring(missing) + .. " in " .. tostring(spoken)) + end + + press("escape", "leave melee targeting") + wait(10) + check("left melee targeting", currentState() == "ingame", + "got " .. currentState()) + else + print("[testbed] melee targeting not reachable here (skipped), state=" + .. currentState()) + end + end + + if currentState() == "ingame" then + local params = targetingParams() + if params then + params.actionName = "throw" + pcall(require("states").switch, "ingame_targeting_throw", params) + wait(10) + else + print("[testbed] could not harvest ingame views for throw targeting") + end + + if currentState() == "ingame_targeting_throw" then + check("entered throw targeting", true) + + local first = focusText() + check("throw targeting speaks the cursor tile", + first ~= nil and first ~= "", + "focus=" .. tostring(first)) + + -- The validity clause is the whole point of targeting as opposed to + -- examining, so assert it specifically rather than just "some text". + check("throw targeting says whether the tile is a legal target", + first ~= nil and (first:find("can throw", 1, true) + or first:find("blocked", 1, true) + or first:find("out of range", 1, true) + or first:find("no line of sight", 1, true)) ~= nil, + "focus=" .. tostring(first)) + + press("right", "throw: cursor east") + local afterMove = focusText() + + -- Not merely "it said something": the cursor glides for 0.3s, so a + -- descriptor that read the popup (or the state's targetAllowed + -- upvalue) would report the PREVIOUS tile here. + check("throw targeting tracks the cursor on the frame it moves", + afterMove ~= nil and afterMove ~= first, + "before=" .. tostring(first) .. " after=" .. tostring(afterMove)) + + press("escape", "leave throw targeting") + wait(10) + check("left throw targeting", currentState() == "ingame", + "got " .. currentState()) + else + print("[testbed] throw targeting not reachable here (skipped), state=" + .. currentState()) + end + end end print(string.format("[testbed] === %d checks, %d failures ===", checks, failures))