Dialogue narration and wall tone retrigger fix
Game-file footprint unchanged: still one line in main.lua.
Wall tones: stop the previous tone per direction before playing the new one.
The tones are 2.33s mono; steps land far faster than that and up to four
directions sound at once, so overlapping them stacked into a continuous drone
instead of reading as a per-step cue. Cutting the old tone also means the
distance/volume you hear always reflects where you are standing now.
ingame_dialogue descriptor: speaks speaker and 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.
Two more bugs found by the testbed:
4. Nested states.switch announced in reverse. ingame.open() sees
world.pendingDialogue and switches straight to ingame_dialogue; because we
announce after realSwitch returns, the inner announcement fired first and the
outer one interrupted and clobbered it, so entering a conversation said
'Game' rather than 'Conversation'. Now we stay quiet if we are no longer the
current state.
5. The game already numbers response text ('1. (Continue)'), matching the number
key that picks it, so our own numbering produced '1. 1. (Continue)'.
9/9 testbed checks, history unit tests passing.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
9945703fb7
commit
da512fc67e
@@ -2,7 +2,20 @@
|
|||||||
|
|
||||||
**Read this first on any handoff.** Living document: update it whenever state changes, before ending a turn.
|
**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
|
(bound, immobilized). Walking proves nothing about the console patch; drive
|
||||||
`console.print` directly to test it.
|
`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)
|
### 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).
|
- `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
|
### Next — Phase 2: tile examination
|
||||||
Patch `views/levelCursorPopup.set` to narrate the game's existing examine mode,
|
Patch `views/levelCursorPopup.set` to narrate the game's existing examine mode,
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ local describedStates = {
|
|||||||
"load_game",
|
"load_game",
|
||||||
"new_game",
|
"new_game",
|
||||||
"narration",
|
"narration",
|
||||||
|
"ingame_dialogue",
|
||||||
}
|
}
|
||||||
|
|
||||||
local initialized = false
|
local initialized = false
|
||||||
@@ -100,6 +101,16 @@ local function hookStates()
|
|||||||
-- After the switch, so the state's open() has already set up its focus --
|
-- After the switch, so the state's open() has already set up its focus --
|
||||||
-- main_menu, for one, only picks selectedOption inside open().
|
-- main_menu, for one, only picks selectedOption inside open().
|
||||||
pcall(function()
|
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)
|
local descriptor = menus.loadDescriptor(newState)
|
||||||
|
|
||||||
if descriptor then
|
if descriptor then
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -141,14 +141,27 @@ local function scenario()
|
|||||||
|
|
||||||
-- Intro sequence: narration / dialogue / message screens vary, so just advance
|
-- Intro sequence: narration / dialogue / message screens vary, so just advance
|
||||||
-- until we land in gameplay.
|
-- until we land in gameplay.
|
||||||
|
local sawDialogue = false
|
||||||
|
local dialogueText
|
||||||
|
|
||||||
for i = 1, 20 do
|
for i = 1, 20 do
|
||||||
if currentState() == "ingame" then
|
if currentState() == "ingame" then
|
||||||
break
|
break
|
||||||
end
|
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)
|
press("return", "advancing intro (" .. currentState() .. ") " .. i)
|
||||||
wait(10)
|
wait(10)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
check("dialogue was described", sawDialogue and dialogueText ~= nil,
|
||||||
|
"text=" .. tostring(dialogueText))
|
||||||
|
|
||||||
check("reached gameplay", currentState() == "ingame", "got " .. currentState())
|
check("reached gameplay", currentState() == "ingame", "got " .. currentState())
|
||||||
|
|
||||||
if currentState() == "ingame" then
|
if currentState() == "ingame" then
|
||||||
|
|||||||
@@ -70,6 +70,10 @@ local directions = {
|
|||||||
|
|
||||||
local sources = {}
|
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
|
local lastX, lastY, lastLevel
|
||||||
|
|
||||||
--------------------------------------------------------------------------------
|
--------------------------------------------------------------------------------
|
||||||
@@ -143,6 +147,19 @@ local function playTone(name, dx, dy, distance)
|
|||||||
return
|
return
|
||||||
end
|
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.
|
-- Tagged so the game's own sfx/interface volume sliders apply.
|
||||||
local ok, source = pcall(TEsound.play, path, "static", {"sfx", "interface"}, volumeFor(distance))
|
local ok, source = pcall(TEsound.play, path, "static", {"sfx", "interface"}, volumeFor(distance))
|
||||||
if not ok or not source then
|
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
|
lz -- same height as the listener: maximise horizontal pan
|
||||||
)
|
)
|
||||||
end)
|
end)
|
||||||
|
|
||||||
|
playing[name] = source
|
||||||
end
|
end
|
||||||
|
|
||||||
function walltones.playFor(level, x, y)
|
function walltones.playFor(level, x, y)
|
||||||
@@ -208,6 +227,7 @@ function walltones.update()
|
|||||||
if level ~= lastLevel then
|
if level ~= lastLevel then
|
||||||
lastLevel = level
|
lastLevel = level
|
||||||
lastX, lastY = x, y
|
lastX, lastY = x, y
|
||||||
|
playing = {}
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user