Files
alchemist-accessibility/accessibility/walltones.lua
T
Accessibility ModandClaude Opus 5 dddd8ed9de walltones: quieter, with a steeper falloff
Tuned by ear: nearVolume 1.0 -> 0.8 and farVolume 0.25 -> 0.05. At the old
values a wall out at the full radius was still loud enough to compete with
footsteps and speech, so the tones read as a wash rather than as a cue. The
wider gap between near and far also makes distance easier to judge, which is
the point of the volume ramp.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-03 01:36:12 +02:00

239 lines
7.0 KiB
Lua

-- 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's own
-- attenuation curve would decide the volume for us -- unpredictable, and tied to
-- whatever distance model the game happens to set. 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 for a wall you are standing next to, and for one out at `radius`.
nearVolume = 0.8,
farVolume = 0.05,
-- Set true to invert: a wall further away becomes the louder one.
louderWhenFurther = false,
}
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 = {}
-- The currently-sounding source per direction, so a tone is never retriggered on
-- top of itself. See playTone.
local playing = {}
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 config.louderWhenFurther then
t = 1 - t
end
-- t is now 0 at the loud end, 1 at the quiet 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
-- 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
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)
playing[name] = source
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
playing = {}
return
end
if x == lastX and y == lastY then
return
end
lastX, lastY = x, y
walltones.playFor(level, x, y)
end
return walltones