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:
Accessibility Mod
2026-08-01 20:38:28 +02:00
co-authored by Claude Opus 5
parent ff402e64c9
commit fe872be683
7 changed files with 765 additions and 4 deletions
+70 -4
View File
@@ -84,10 +84,76 @@ None block Phase 0. Q4 lands in Phase 1, Q5 + verbosity in Phase 2.
[speech]! Main menu [speech]! Main menu
``` ```
### Next — Phase 1: console history + review keys ### Done — Phase 1: console history + review keys · + wall tones
Wrap `console.print`, keep our own unbounded history (the game's expires after 3s), - `accessibility/history.lua` — unbounded (1000-entry ring) history. Mirrors the
mirror its `(x3)` coalescing, add review keys. Needs Q4 answered (auto-speak policy) game's `(xN)` coalescing so speech matches the screen. Review cursor **holds
but a sensible default can ship first. 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.
--- ---
+193
View File
@@ -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
+130
View File
@@ -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
+24
View File
@@ -15,6 +15,8 @@ local accessibility = {}
local speech = require("accessibility.speech") local speech = require("accessibility.speech")
local stateNames = require("accessibility.stateNames") local stateNames = require("accessibility.stateNames")
local hotkeys = require("accessibility.hotkeys")
local walltones = require("accessibility.walltones")
local initialized = false local initialized = false
@@ -86,9 +88,28 @@ local function hookStates()
local realUpdate = states.update local realUpdate = states.update
states.update = function(dt) states.update = function(dt)
local result = realUpdate(dt) local result = realUpdate(dt)
pcall(walltones.update)
pcall(speech.flush) pcall(speech.flush)
return result return result
end 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 end
-------------------------------------------------------------------------------- --------------------------------------------------------------------------------
@@ -101,8 +122,11 @@ function accessibility.initialize()
end end
initialized = true initialized = true
accessibility.registerPatch("console", require("accessibility.patches.console"))
local ok, err = pcall(function() local ok, err = pcall(function()
speech.initialize() speech.initialize()
walltones.load()
hookStates() hookStates()
applyPatches() applyPatches()
end) end)
+46
View File
@@ -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
+80
View File
@@ -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)
+222
View File
@@ -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