diff --git a/accessibility/docs/scratch.md b/accessibility/docs/scratch.md index 070f5a5..ce6941e 100644 --- a/accessibility/docs/scratch.md +++ b/accessibility/docs/scratch.md @@ -2,7 +2,20 @@ **Read this first on any handoff.** Living document: update it whenever state changes, before ending a turn. -Last updated: 2026-08-01 · Currently: **Phase 0 complete. Phase 1 next.** +Last updated: 2026-08-01 · Currently: **Phases 0–1 done, menus + dialogue done. +Phase 2 (tile examination) next.** + +**Game is now playable start-to-finish by ear** from main menu through the intro +conversation into gameplay — verified by the testbed, 9/9 checks. + +### Run the testbed (do this after any change) +``` +cd "D:/code/Sulphur Memories Alchemist 0.4.2 sanctified" +rm -rf ~/AppData/Roaming/LOVE/Alchemist # start from a clean save +"C:/Program Files/LOVE/lovec.exe" . --a11y-test +``` +It drives the game by code via `love.event.push`, navigating by *reading* focus +rather than counting keypresses. Every bug listed below was caught by it. --- @@ -197,13 +210,41 @@ fold and review keys. (bound, immobilized). Walking proves nothing about the console patch; drive `console.print` directly to test it. +### Done — dialogue narration + wall tone retrigger fix + +**`ingame_dialogue` descriptor.** Speaks speaker + message on a *node* change, but +only the response on a *selection* change (re-reading the whole speech every time +you move between responses would be unusable). Responses can also be picked +directly with number keys 1–0, which the game already supports. + +Two more bugs found and fixed: + +4. **Nested `states.switch` announced in reverse.** `ingame.open()` sees + `world.pendingDialogue` and switches straight to `ingame_dialogue`. Since we + announce *after* `realSwitch` returns, the inner announcement ("Conversation") + fired first and the outer ("Game") then interrupted and clobbered it. + **Fix: after switching, if `states.getCurrent() ~= newState`, stay quiet** — + a nested switch already announced. + +5. **Double numbering.** The game already prefixes response text with its number + (`"1. (Continue)"`, matching the number key that selects it), so adding ours + gave `"1. 1. (Continue)"`. Now only added when absent. + +**Wall tones — retrigger fix (per Talon: "stop previous tones and play new ones").** +Tones are **2.33 s, mono, 44.1 kHz**. Steps land far faster than that and up to +four directions sound at once, so they stacked into a continuous drone. Now each +direction stops its previous source before starting a new one, so it reads as a +per-step cue layered over footsteps, and the volume you hear always reflects +where you're standing now. `playing` is cleared on level change. + +*(Mono matters: OpenAL only spatialises mono sources. Stereo would not pan.)* + ### Known gaps (next up) -- **`ingame_dialogue` announces "Game", not "Conversation", and reads no dialogue - text.** It appears to be entered via a path that announces `ingame` first. - Needs a descriptor + investigation of how it's switched to. **This is the next - blocker for actually playing.** - `ingame` itself has no descriptor — that's Phase 2 (tile examination). -- Wall tones still unverified by ear. +- Wall tone retrigger fix not yet confirmed by ear. +- `worldmap`, `journal`, `character`, `research`, `inventory`, `craft`, `refine`, + `trade`, `actions_menu`, `alchemy_table`, `keybindings_menu`, `config_menu`, + `menu_text`, `ingame_message`, `menu_message` still have no descriptors. ### Next — Phase 2: tile examination Patch `views/levelCursorPopup.set` to narrate the game's existing examine mode, diff --git a/accessibility/init.lua b/accessibility/init.lua index 7cda114..844b246 100644 --- a/accessibility/init.lua +++ b/accessibility/init.lua @@ -29,6 +29,7 @@ local describedStates = { "load_game", "new_game", "narration", + "ingame_dialogue", } local initialized = false @@ -100,6 +101,16 @@ local function hookStates() -- After the switch, so the state's open() has already set up its focus -- -- main_menu, for one, only picks selectedOption inside open(). pcall(function() + -- Guard against NESTED switches. open() can itself switch state: + -- ingame.open() sees world.pendingDialogue and goes straight to + -- ingame_dialogue. Because we announce after realSwitch returns, the + -- inner announcement ("Conversation") happens first and the outer one + -- ("Game") then interrupts and clobbers it. If we are no longer the + -- current state, the inner switch already announced -- stay quiet. + if states.getCurrent() ~= newState then + return + end + local descriptor = menus.loadDescriptor(newState) if descriptor then diff --git a/accessibility/states/ingame_dialogue.lua b/accessibility/states/ingame_dialogue.lua new file mode 100644 index 0000000..1ad1540 --- /dev/null +++ b/accessibility/states/ingame_dialogue.lua @@ -0,0 +1,100 @@ +-- states/ingame_dialogue.lua +-- +-- Conversations. Two things must be spoken, and they change independently: +-- * the NPC's line (speaker name + message blocks) -- changes when the node does +-- * the currently selected response -- changes as you press up/down +-- +-- So this tracks the node itself and announces the full block on a node change, +-- but only the option on a selection change. Repeating the whole speech every +-- time you move between responses would be unusable. +-- +-- Responses can also be picked directly with the number keys 1-0, which the game +-- already supports and which is the fastest way to work through a conversation. + +local descriptor = {} + +local lastNodeKey + +local function nodeKeyFor(speakerName, blocks, options) + local parts = {tostring(speakerName), tostring(#options)} + + for _, block in ipairs(blocks or {}) do + parts[#parts + 1] = tostring(block.text) + end + + return table.concat(parts, "|") +end + +descriptor.read = function(stateTable, menus) + local keypressed = stateTable.keypressed + + local options = menus.getUpvalue(keypressed, "options") + local selected = menus.getUpvalue(keypressed, "selectedOption") + + if type(options) ~= "table" or type(selected) ~= "number" then + return nil, nil + end + + -- speakerName / messageTextBlocks are not touched by keypressed directly -- + -- it calls updateText(), so they are upvalues of that function. + local updateText = menus.getUpvalue(keypressed, "updateText") + local speakerName, blocks + + if type(updateText) == "function" then + speakerName = menus.getUpvalue(updateText, "speakerName") + blocks = menus.getUpvalue(updateText, "messageTextBlocks") + end + + local nodeKey = nodeKeyFor(speakerName, blocks, options) + + local option = options[selected] + local optionText = option and option.text or nil + + -- Selection moved within the same node: just the response. + if nodeKey == lastNodeKey then + if not optionText then + return nil, nil + end + return string.format("%s, %d of %d", optionText, selected, #options), + nodeKey .. ":" .. selected + end + + -- New node: speaker, what they said, then the options. + lastNodeKey = nodeKey + + local parts = {} + + if speakerName and speakerName ~= "" then + parts[#parts + 1] = speakerName + end + + for _, block in ipairs(blocks or {}) do + if block.text then + parts[#parts + 1] = block.text + end + end + + if #options > 0 then + parts[#parts + 1] = #options .. (#options == 1 and " response" or " responses") + for i, entry in ipairs(options) do + if entry.text then + -- The game already prefixes response text with its number + -- ("1. (Continue)"), matching the number keys that select it. + -- Adding our own gave "1. 1. (Continue)". + if entry.text:match("^%s*%d+%s*[%.%)]") then + parts[#parts + 1] = entry.text + else + parts[#parts + 1] = i .. ". " .. entry.text + end + end + end + end + + if #parts == 0 then + return nil, nil + end + + return table.concat(parts, ". "), nodeKey .. ":" .. selected +end + +return descriptor diff --git a/accessibility/testbed.lua b/accessibility/testbed.lua index 56e1f98..3c04a03 100644 --- a/accessibility/testbed.lua +++ b/accessibility/testbed.lua @@ -141,14 +141,27 @@ local function scenario() -- Intro sequence: narration / dialogue / message screens vary, so just advance -- until we land in gameplay. + local sawDialogue = false + local dialogueText + for i = 1, 20 do if currentState() == "ingame" then break end + if currentState() == "ingame_dialogue" and not sawDialogue then + sawDialogue = true + dialogueText = focusText() + -- Exercise response navigation before advancing. + press("down", "dialogue: next response") + press("up", "dialogue: previous response") + end press("return", "advancing intro (" .. currentState() .. ") " .. i) wait(10) end + check("dialogue was described", sawDialogue and dialogueText ~= nil, + "text=" .. tostring(dialogueText)) + check("reached gameplay", currentState() == "ingame", "got " .. currentState()) if currentState() == "ingame" then diff --git a/accessibility/walltones.lua b/accessibility/walltones.lua index c26caa4..da9274a 100644 --- a/accessibility/walltones.lua +++ b/accessibility/walltones.lua @@ -70,6 +70,10 @@ local directions = { local sources = {} +-- The currently-sounding source per direction, so a tone is never retriggered on +-- top of itself. See playTone. +local playing = {} + local lastX, lastY, lastLevel -------------------------------------------------------------------------------- @@ -143,6 +147,19 @@ local function playTone(name, dx, dy, distance) return end + -- One tone per direction at a time: stop the previous one, start the new one. + -- + -- The tones are 2.33s long and steps land far faster than that, with up to + -- four directions sounding at once -- so letting them overlap stacked into a + -- continuous drone instead of reading as a per-step cue. Cutting the old tone + -- keeps this a layer on top of footsteps, and means the distance/volume you + -- hear always reflects where you are standing now. + local existing = playing[name] + if existing then + pcall(existing.stop, existing) + playing[name] = nil + 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 @@ -157,6 +174,8 @@ local function playTone(name, dx, dy, distance) lz -- same height as the listener: maximise horizontal pan ) end) + + playing[name] = source end function walltones.playFor(level, x, y) @@ -208,6 +227,7 @@ function walltones.update() if level ~= lastLevel then lastLevel = level lastX, lastY = x, y + playing = {} return end