267 lines
7.4 KiB
Lua
267 lines
7.4 KiB
Lua
-- 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
|