Files
Accessibility ModandClaude Opus 5 5778e86e8e orientation: the travel map, unexplored ways, the nearest exit
Prompted by "do sighted players have a minimap we don't have access to?"
No widget, but two map surfaces were only half exposed: the world map
screen, and the level view itself, which IS the explored-floor map --
rememberedFoV keeps everything ever seen drawn, and a sighted player pans
over it and reads the fog edge at a glance.

The world map dropped the three things the picture carries: what kind of
place each area is (the icon shape), which way it lies, and how long
travelling there takes. The last is the number the decision turns on and
was only ever in the side panel. states/worldmap.lua is now a read() plus
details() rather than probe/render -- necessarily, because render() gets
no state table and the travel time needs the screen's currentAreaId, an
upvalue like options and selectedOption. Travel time is the game's own
getDistanceMessage, the same call the screen makes. The blurb runs to
several sentences, so it goes on = and the settle timer instead of the
focus line.

Two gotchas: options[i].icon is already an image object, so the icon id
string has to come off the model area; and describe.direction only tests
the sign of each delta, which on the map's floating-point coordinates
called almost everything diagonal, hence a 25% deadzone carrying the same
4/3 x-correction the game's own distance maths uses.

New frontier.lua answers "where haven't I been", which nothing did:
surroundings is local, examine is one tile, and the tracker only finds
things it already knows about. A frontier tile is seen, not blocking
movement, with an in-bounds unseen CARDINAL neighbour -- cardinal because
the diagonal test fires on the unseen tile behind every wall corner, so a
lit corridor would report an unexplored way per corner. Clustered
8-connected and reduced to each cluster's nearest member, so a wide
doorway is one way but two openings that both lie north stay two. Reads
only FoV/rememberedFoV and reports only THAT an opening exists, never
what is beyond it -- exactly what the fog edge shows.

Shift+End names the nearest exit without disturbing the tracker
selection. Note for later: level.exits does not exist at runtime, it is
packed as nil and never assigned. The primitive is the game's own
getListByTag("exit"), which already gates on FoV or rememberedFoV, skips
hidden tiles and sorts by distance. Exits were already a tracker
category, so this is a shortcut rather than a new capability.

"remembered" moves from verbose to normal verbosity. tileFacts reads
actors only when a tile is currently VISIBLE, so a remembered tile is
silent about whatever may be standing on it now, and at normal verbosity
that silence was indistinguishable from "nothing there". A safety flag,
not a nicety.

Also adds descriptor.probeNames: a read() descriptor has no probe table,
so menus.selfTest could only report "custom read()" and the version-bump
canary was lost on exactly the screens reaching for the most upvalues.
worldmap and research now declare their game locals and get the same
PROBE BROKEN check.

120 testbed checks, 0 failures. It caught two things review had not. The
world map said "You are currently here." twice, because
getDistanceMessage and getLastVisitedMessage return the same sentence for
the area you are standing in -- fine as two panels on screen, a stutter
in one utterance. And the first frontier check walked a step east and
demanded the numbers change; it failed on a run where nothing was wrong,
the step landing but both openings keeping the same Chebyshev distance.
Replaced with the deterministic property: every reported distance must
equal Chebyshev from the player's current position, nearest first.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-03 16:05:15 +02:00

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