Phase 1: console history, review keys, and wall tones
Game-file footprint unchanged: still one line in main.lua. Console (Q4: speak everything queued, cancelled by explicit action): - history.lua keeps our own unbounded history, since the game's console buffer expires entries after 3s. Mirrors the game's (xN) coalescing so what is heard matches what is on screen. The review cursor holds position while new lines arrive rather than being yanked to the tail mid-read. - patches/console.lua wraps console.print, calling the real one first so a bug on our side cannot swallow a message the player would otherwise see. - Any keypress reaching the game silences the pending speech backlog. Hotkeys: [ and ] review, Shift+[/] jump to ends, \ repeat, Shift+\ copy. Chosen from scancodes the game does not bind. Note keybindings.getCommand() ignores modifiers, so Ctrl+Up would read to the game as a movement -- our keys are consumed and never overlay a game-claimed scancode. Wall tones (Q5: on step, 3D-positioned, radius 5): - Direction and distance are deliberately decoupled. The listener sits at the camera 4 units above the plane under an inverse distance model, so placing a tone at the wall's real position would make distant walls quieter (the opposite of the request) and collapse the stereo pan. Instead the source sits at a fixed radius in the wall's direction at listener height, and volume is computed explicitly from distance. - Step detection polls player coordinates rather than hooking movement, so it covers walking, pathing, portals and pushes for free. - Respects FoV/rememberedFoV; never leaks unseen geometry. tests/test_history.lua: 16 assertions, all passing, pure Lua. Not yet verified in actual play -- needs a human at the keyboard. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
ff402e64c9
commit
fe872be683
@@ -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.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user