-- frontier.lua — "where haven't I been yet?" -- -- The level view IS the map. level.rememberedFoV (modules/level.lua:576) keeps -- every tile the player has ever seen drawn on screen, and a sighted player pans -- over it with Ctrl+arrows and reads the shape of the fog edge at a glance: that -- corridor dead-ends, that one carries on, I never went back for the door in the -- north wall. -- -- Nothing in the mod answered that. The surroundings scan is local, the examine -- cursor is one tile at a time, and the tracker only finds things it already -- knows about -- none of them say where there is still to GO. This does, in one -- keypress and with no mode to enter. -- -------------------------------------------------------------------------------- -- What counts as a frontier -------------------------------------------------------------------------------- -- -- A frontier tile is one that is seen (FoV or rememberedFoV), NOT blocking -- movement, and has at least one neighbour that is in bounds and not seen. -- -- Both restrictions are load-bearing: -- -- * in bounds -- otherwise every walkable tile along the grid boundary is a -- frontier, and the report is nothing but map edge. -- * CARDINAL neighbours only, not all eight -- the diagonal test fires on the -- unseen tile tucked behind a wall corner, which is not anywhere you can -- walk. Standing in a lit corridor would otherwise report an "unexplored -- way" for every corner you can see. Cardinal removes almost all of those. -- -- Clustering is 8-connected, so a wide doorway is one way rather than three, but -- two separate openings that both happen to lie north stay two ways. -- -------------------------------------------------------------------------------- -- Not a cheat -------------------------------------------------------------------------------- -- -- This reads only FoV and rememberedFoV, and reports only THAT an opening exists -- -- never what lies beyond it. That is exactly the information the fog edge -- gives a sighted player. Nothing about an unseen tile is ever spoken. local speech = require("accessibility.speech") local describe = require("accessibility.describe") local frontier = {} frontier.config = { -- How many ways to name before falling back to "and N more". Four is about -- as much as fits in one utterance and still be held in the head. maxSpoken = 4, } -------------------------------------------------------------------------------- -- Level access -------------------------------------------------------------------------------- local function getLevel() local ok, world = pcall(require, "models.world") if not ok or not world.currentLevel then return nil end return world.currentLevel end local function seenAt(level, x, y) if level.FoV and level.FoV[x] and level.FoV[x][y] then return true end if level.rememberedFoV and level.rememberedFoV[x] and level.rememberedFoV[x][y] then return true end return false end local function inBounds(level, x, y) return x >= 1 and y >= 1 and x <= (level.xMax or 0) and y <= (level.yMax or 0) and level.grid[x] and level.grid[x][y] and true or false end local function blocksMovement(level, x, y) local stack = level.grid[x][y] if type(stack) ~= "table" then return true end for i = #stack, 1, -1 do local tile = stack[i] if tile and not tile.isHidden and tile.blocksMovement then return true end end return false end -------------------------------------------------------------------------------- -- Scanning -------------------------------------------------------------------------------- local CARDINALS = {{0, -1}, {1, 0}, {0, 1}, {-1, 0}} local function isFrontier(level, x, y) if not seenAt(level, x, y) or blocksMovement(level, x, y) then return false end for _, dir in ipairs(CARDINALS) do local nx, ny = x + dir[1], y + dir[2] if inBounds(level, nx, ny) and not seenAt(level, nx, ny) then return true end end return false end -- Every frontier tile on the level, as a set keyed "x,y" plus a flat list. local function collect(level) local set, list = {}, {} for x = 1, level.xMax or 0 do if level.grid[x] then for y = 1, level.yMax or 0 do if level.grid[x][y] and isFrontier(level, x, y) then set[x .. "," .. y] = true list[#list + 1] = {x = x, y = y} end end end end return set, list end -------------------------------------------------------------------------------- -- Clustering -------------------------------------------------------------------------------- -- 8-connected components, flood filled with an explicit stack rather than -- recursion: a long wall of frontier tiles would otherwise be deep enough to -- matter, and LuaJIT's C stack is not worth betting on here. -- -- Each cluster is reduced to its member nearest the player, which is the one the -- player would actually walk to. local function cluster(set, list, px, py) local clusters = {} for _, seed in ipairs(list) do local seedKey = seed.x .. "," .. seed.y if set[seedKey] then set[seedKey] = nil local best = seed local bestDistance = describe.distance(seed.x - px, seed.y - py) local size = 0 local stack = {seed} while #stack > 0 do local tile = table.remove(stack) size = size + 1 local distance = describe.distance(tile.x - px, tile.y - py) if distance < bestDistance then best, bestDistance = tile, distance end for dx = -1, 1 do for dy = -1, 1 do local key = (tile.x + dx) .. "," .. (tile.y + dy) if set[key] then set[key] = nil stack[#stack + 1] = {x = tile.x + dx, y = tile.y + dy} end end end end clusters[#clusters + 1] = { x = best.x, y = best.y, distance = bestDistance, size = size, } end end table.sort(clusters, function(a, b) if a.distance ~= b.distance then return a.distance < b.distance end -- Stable tie-break, so repeating the key repeats the sentence. if a.x ~= b.x then return a.x < b.x end return a.y < b.y end) return clusters end -------------------------------------------------------------------------------- -- Public -------------------------------------------------------------------------------- -- Returns the cluster list, nearest first, or nil if there is no level. function frontier.scan() local level = getLevel() local actor = level and level.playerActor if not level or not level.grid or not actor then return nil end local set, list = collect(level) return cluster(set, list, actor.x, actor.y) end -- The spoken sentence, or nil if there is no level to report on. function frontier.report() local clusters = frontier.scan() if not clusters then return nil end if #clusters == 0 then return "No unexplored ways from here" end local spoken = math.min(#clusters, frontier.config.maxSpoken) local actor = getLevel().playerActor local parts = {} for i = 1, spoken do local entry = clusters[i] parts[#parts + 1] = describe.offset(entry.x - actor.x, entry.y - actor.y) end local sentence = string.format("%d unexplored %s: %s", #clusters, #clusters == 1 and "way" or "ways", table.concat(parts, ", ")) if #clusters > spoken then sentence = sentence .. ", and " .. (#clusters - spoken) .. " more" end return sentence end -- End: say it. function frontier.announce() local sentence = frontier.report() if not sentence then speech.say("Not on a map", true) return end speech.say(sentence, true) end function frontier.registerKeys(hotkeys) hotkeys.bind("end", nil, frontier.announce) end return frontier