2026-08-01 20:38:28 +02:00
|
|
|
-- 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 = {}
|
|
|
|
|
|
2026-08-01 20:59:04 +02:00
|
|
|
-- The currently-sounding source per direction, so a tone is never retriggered on
|
|
|
|
|
-- top of itself. See playTone.
|
|
|
|
|
local playing = {}
|
|
|
|
|
|
2026-08-01 20:38:28 +02:00
|
|
|
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
|
|
|
|
|
|
2026-08-01 20:59:04 +02:00
|
|
|
-- 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
|
|
|
|
|
|
2026-08-01 20:38:28 +02:00
|
|
|
-- 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)
|
2026-08-01 20:59:04 +02:00
|
|
|
|
|
|
|
|
playing[name] = source
|
2026-08-01 20:38:28 +02:00
|
|
|
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
|
2026-08-01 20:59:04 +02:00
|
|
|
playing = {}
|
2026-08-01 20:38:28 +02:00
|
|
|
return
|
|
|
|
|
end
|
|
|
|
|
|
|
|
|
|
if x == lastX and y == lastY then
|
|
|
|
|
return
|
|
|
|
|
end
|
|
|
|
|
|
|
|
|
|
lastX, lastY = x, y
|
|
|
|
|
walltones.playFor(level, x, y)
|
|
|
|
|
end
|
|
|
|
|
|
|
|
|
|
return walltones
|