-- 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 = 1.0, farVolume = 0.25, -- 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