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>
184 lines
5.9 KiB
Lua
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,
|
|
}
|