Files

267 lines
7.4 KiB
Lua
Raw Permalink Normal View History

-- 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