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

184 lines
5.9 KiB
Lua

-- states/worldmap.lua — travel destinations.
--
-- The screen is a painted map of Rimesia with area icons at real coordinates,
-- plus a description window carrying the area's blurb and the travel time. The
-- list on the side is only half of it: the icon says what KIND of place each area
-- is, the position says where it lies, and the description window says how long
-- getting there costs. All three were silent.
--
-- Why read() rather than probe/render: travel time needs the screen's
-- `currentAreaId`, and render(entry, index, total) is handed no state table.
-- `options`, `selectedOption` and `currentAreaId` are all file-locals in the same
-- chunk (states/worldmap.lua) and all are upvalues of exported functions, so
-- menus.getAnyUpvalue reaches them. `currentAreaId` is nil until the screen has
-- been opened once -- treat that as "omit the travel time", never as an error.
--
-- The blurb goes through details() on `=` rather than the focus line: it runs to
-- several sentences, and arrowing through nine areas would be unbearable.
local describe = require("accessibility.describe")
local worldmapModel = require("models.worldmap")
-- The 20 icon ids (models/interface/mapIcons.lua) are already readable words once
-- the underscores go: town, ruined_house, pirate_grave, witchwood... so this is a
-- transform rather than a lookup table, which also means a new id in a future
-- game version reads sensibly instead of vanishing.
--
-- Deliberately NOT an addition to mapIcons: icons.preprocess iterates that table
-- with pairs() and would try to treat any new table in it as an image.
local function areaType(iconId)
if type(iconId) ~= "string" then
return nil
end
return (iconId:gsub("_", " "))
end
-- The model area behind a list entry, or nil. worldmap.areas holds `false` in the
-- slots of erased areas (models/worldmap.lua:173), hence the type check.
local function modelArea(areaId)
if type(areaId) ~= "number" then
return nil
end
local area = worldmapModel.areas and worldmapModel.areas[areaId]
if type(area) ~= "table" then
return nil
end
return area
end
-- Compass bearing from one area to another in 0..1 map space.
--
-- describe.direction only tests the sign of each delta, so on floating-point
-- coordinates it would call almost everything diagonal. The deadzone below
-- suppresses a component that is small next to the other one, so a place almost
-- due north reads as "north" rather than "north-east". The 4/3 on x matches the
-- aspect correction the game's own distance maths uses
-- (models/worldmap.lua:64) so the bearing agrees with the travel time.
local DEADZONE = 0.25
local function bearing(fromArea, toArea)
local dx = (toArea.x - fromArea.x) * 4 / 3
local dy = toArea.y - fromArea.y
local ax, ay = math.abs(dx), math.abs(dy)
if ax < ay * DEADZONE then
dx = 0
elseif ay < ax * DEADZONE then
dy = 0
end
if dx == 0 and dy == 0 then
return nil
end
return describe.direction(dx, dy)
end
-- The focused list entry, plus the id of the area the player is standing in.
local function focus(stateTable, menus)
local options = menus.getAnyUpvalue(stateTable, "options")
local selected = menus.getAnyUpvalue(stateTable, "selectedOption")
if type(options) ~= "table" or type(selected) ~= "number" then
return nil
end
local entry = options[selected]
if type(entry) ~= "table" then
return nil
end
return entry, selected, #options, menus.getAnyUpvalue(stateTable, "currentAreaId")
end
return {
-- Checked by menus.selfTest at startup: a game version that renames any of
-- these says so on the console instead of going quiet on the travel screen.
probeNames = {"options", "selectedOption", "currentAreaId"},
read = function(stateTable, menus)
local entry, index, total, currentAreaId = focus(stateTable, menus)
if not entry then
return nil, nil
end
local parts = {entry.name or "Unknown area"}
-- entry.icon is already an image object (states/worldmap.lua:240); the id
-- string lives on the model area.
local area = modelArea(entry.id)
local kind = area and areaType(area.icon)
if kind then
parts[#parts + 1] = kind
end
parts[#parts + 1] = index .. " of " .. total
local here = modelArea(currentAreaId)
if here and area and entry.id ~= currentAreaId then
local which = bearing(here, area)
if which then
parts[#parts + 1] = which
end
end
-- The game's own string, already finished English, including "You are
-- currently here." Same call the screen makes at states/worldmap.lua:360.
local travel
if currentAreaId and entry.id then
local ok, message = pcall(worldmapModel.getDistanceMessage, currentAreaId, entry.id)
if ok and message and message ~= "" then
travel = message
parts[#parts + 1] = travel
end
end
-- "Visited 3 days ago": on a map of similar-sounding place names this is
-- the main way to tell where you have been.
--
-- Skipped when it duplicates the travel line. For the area you are
-- standing in, getDistanceMessage and getLastVisitedMessage return the
-- SAME sentence -- "You are currently here." (models/worldmap.lua:75-77
-- and :106-107) -- which the screen gets away with because they are drawn
-- in different panels, and which by ear is just said twice.
if entry.visitedMessage and entry.visitedMessage ~= ""
and entry.visitedMessage ~= travel
then
parts[#parts + 1] = entry.visitedMessage
end
if entry.isCompleted then
parts[#parts + 1] = "completed"
end
if entry.isFavourite then
parts[#parts + 1] = "favourite"
end
return table.concat(parts, ", "), index .. "/" .. total .. ":" .. tostring(entry.id)
end,
details = function(stateTable, menus, verbose)
local entry = focus(stateTable, menus)
if not entry then
return nil
end
local blocks = {}
if verbose then
blocks[#blocks + 1] = entry.name or "Unknown area"
end
if entry.description and entry.description ~= "" then
blocks[#blocks + 1] = entry.description
end
if #blocks == 0 then
return nil
end
return blocks
end,
}