2026-08-01 22:00:52 +02:00
|
|
|
-- states/worldmap.lua — travel destinations.
|
|
|
|
|
--
|
2026-08-03 16:05:15 +02:00
|
|
|
-- 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
|
2026-08-01 22:00:52 +02:00
|
|
|
|
|
|
|
|
return {
|
2026-08-03 16:05:15 +02:00
|
|
|
-- 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
|
2026-08-01 22:00:52 +02:00
|
|
|
|
|
|
|
|
local parts = {entry.name or "Unknown area"}
|
|
|
|
|
|
2026-08-03 16:05:15 +02:00
|
|
|
-- 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
|
|
|
|
|
|
2026-08-01 22:00:52 +02:00
|
|
|
if entry.isCompleted then
|
|
|
|
|
parts[#parts + 1] = "completed"
|
|
|
|
|
end
|
|
|
|
|
if entry.isFavourite then
|
|
|
|
|
parts[#parts + 1] = "favourite"
|
|
|
|
|
end
|
2026-08-03 16:05:15 +02:00
|
|
|
|
|
|
|
|
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
|
2026-08-01 22:00:52 +02:00
|
|
|
end
|
|
|
|
|
|
2026-08-03 16:05:15 +02:00
|
|
|
local blocks = {}
|
2026-08-01 22:00:52 +02:00
|
|
|
|
2026-08-03 16:05:15 +02:00
|
|
|
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
|
2026-08-01 22:00:52 +02:00
|
|
|
end,
|
|
|
|
|
}
|