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>
203 lines
5.3 KiB
Lua
203 lines
5.3 KiB
Lua
-- describe.lua — shared spatial text formatting.
|
|
--
|
|
-- The game's own descriptions ("A wooden chair.") carry no spatial information,
|
|
-- which is fine on screen where position is obvious and useless by ear. Per Q3
|
|
-- we augment rather than merely relay: distance, direction, and passability.
|
|
--
|
|
-- Verbosity is a setting because the right amount differs between sweeping the
|
|
-- surroundings (terse) and inspecting one tile (verbose).
|
|
|
|
local describe = {}
|
|
|
|
-- "terse" | "normal" | "verbose"
|
|
describe.verbosity = "normal"
|
|
|
|
--------------------------------------------------------------------------------
|
|
-- Direction
|
|
--------------------------------------------------------------------------------
|
|
|
|
-- Eight-point compass. Screen coordinates: +y is south, +x is east.
|
|
function describe.direction(dx, dy)
|
|
if dx == 0 and dy == 0 then
|
|
return "here"
|
|
end
|
|
|
|
local parts = {}
|
|
|
|
if dy < 0 then
|
|
parts[#parts + 1] = "north"
|
|
elseif dy > 0 then
|
|
parts[#parts + 1] = "south"
|
|
end
|
|
|
|
if dx < 0 then
|
|
parts[#parts + 1] = "west"
|
|
elseif dx > 0 then
|
|
parts[#parts + 1] = "east"
|
|
end
|
|
|
|
return table.concat(parts, "-")
|
|
end
|
|
|
|
--------------------------------------------------------------------------------
|
|
-- Distance
|
|
--------------------------------------------------------------------------------
|
|
|
|
-- Chebyshev distance: the number of steps on an 8-directional grid, which is what
|
|
-- this game uses. Euclidean would overstate diagonal travel.
|
|
function describe.distance(dx, dy)
|
|
return math.max(math.abs(dx), math.abs(dy))
|
|
end
|
|
|
|
function describe.tiles(n)
|
|
return n == 1 and "1 tile" or (n .. " tiles")
|
|
end
|
|
|
|
-- "3 tiles north-east" / "here"
|
|
function describe.offset(dx, dy)
|
|
if dx == 0 and dy == 0 then
|
|
return "here"
|
|
end
|
|
|
|
return describe.tiles(describe.distance(dx, dy)) .. " " .. describe.direction(dx, dy)
|
|
end
|
|
|
|
--------------------------------------------------------------------------------
|
|
-- Tile inspection
|
|
--------------------------------------------------------------------------------
|
|
|
|
-- What the player would see standing on / looking at (x,y).
|
|
-- Returns a table of facts rather than a sentence, so callers choose the wording.
|
|
function describe.tileFacts(level, x, y)
|
|
local facts = {
|
|
inBounds = false,
|
|
seen = false,
|
|
visible = false,
|
|
blocked = false,
|
|
blocksVision = false,
|
|
water = false,
|
|
hasLoot = false,
|
|
interactable = false,
|
|
actor = nil,
|
|
description = nil,
|
|
}
|
|
|
|
if not level or not level.grid or not level.grid[x] or not level.grid[x][y] then
|
|
return facts
|
|
end
|
|
|
|
facts.inBounds = true
|
|
|
|
local visible = level.FoV and level.FoV[x] and level.FoV[x][y]
|
|
local remembered = level.rememberedFoV and level.rememberedFoV[x] and level.rememberedFoV[x][y]
|
|
|
|
facts.visible = visible and true or false
|
|
facts.seen = (visible or remembered) and true or false
|
|
|
|
if not facts.seen then
|
|
return facts
|
|
end
|
|
|
|
if visible then
|
|
local ok, actor = pcall(level.getActorOnCoordinates, x, y, function(a)
|
|
return a.visibilityAlpha ~= 0
|
|
end)
|
|
if ok and actor then
|
|
facts.actor = actor
|
|
end
|
|
end
|
|
|
|
for i = #level.grid[x][y], 1, -1 do
|
|
local tile = level.grid[x][y][i]
|
|
|
|
if tile and not tile.isHidden then
|
|
if tile.blocksMovement then
|
|
facts.blocked = true
|
|
end
|
|
if tile.blocksVision then
|
|
facts.blocksVision = true
|
|
end
|
|
if tile.tryInteract then
|
|
facts.interactable = true
|
|
end
|
|
if tile.loot and tile.loot[1] and not tile.unlootable then
|
|
facts.hasLoot = true
|
|
end
|
|
if not facts.description and tile.description then
|
|
facts.description = tile.description
|
|
end
|
|
end
|
|
end
|
|
|
|
local ok, isWater = pcall(level.isWaterTile, x, y)
|
|
facts.water = (ok and isWater) and true or false
|
|
|
|
return facts
|
|
end
|
|
|
|
-- One spoken line for a tile, relative to an origin (usually the player).
|
|
function describe.tile(level, x, y, originX, originY)
|
|
local facts = describe.tileFacts(level, x, y)
|
|
|
|
local dx = originX and (x - originX) or 0
|
|
local dy = originY and (y - originY) or 0
|
|
local where = originX and describe.offset(dx, dy) or nil
|
|
|
|
if not facts.inBounds then
|
|
return (where and (where .. ", ") or "") .. "edge of the map"
|
|
end
|
|
|
|
if not facts.seen then
|
|
return (where and (where .. ", ") or "") .. "unknown"
|
|
end
|
|
|
|
local parts = {}
|
|
|
|
-- Creatures first: they matter more than the floor they stand on, and the
|
|
-- game's own getCursorMessage prioritises them the same way.
|
|
if facts.actor then
|
|
parts[#parts + 1] = facts.actor.nameObserved or facts.actor.name or "someone"
|
|
end
|
|
|
|
if facts.description then
|
|
parts[#parts + 1] = facts.description
|
|
elseif not facts.actor then
|
|
parts[#parts + 1] = facts.blocked and "wall" or "floor"
|
|
end
|
|
|
|
if describe.verbosity ~= "terse" then
|
|
if facts.blocked then
|
|
parts[#parts + 1] = "blocked"
|
|
end
|
|
if facts.hasLoot then
|
|
parts[#parts + 1] = "has items"
|
|
end
|
|
if facts.water then
|
|
parts[#parts + 1] = "water"
|
|
end
|
|
|
|
-- "remembered" is a safety flag, not a nicety, so it earns its place at
|
|
-- normal verbosity. tileFacts reads actors only when the tile is
|
|
-- currently VISIBLE (see the `if visible` guard above), so a remembered
|
|
-- tile is silent about whatever may be standing on it now. Without this
|
|
-- word that silence is indistinguishable from "nothing there".
|
|
if not facts.visible then
|
|
parts[#parts + 1] = "remembered"
|
|
end
|
|
end
|
|
|
|
if describe.verbosity == "verbose" then
|
|
if facts.interactable then
|
|
parts[#parts + 1] = "can interact"
|
|
end
|
|
end
|
|
|
|
if where then
|
|
parts[#parts + 1] = where
|
|
end
|
|
|
|
return table.concat(parts, ", ")
|
|
end
|
|
|
|
return describe
|