Files
Accessibility ModandClaude Opus 5 96ec79ed75 Phase 4 complete + Phase 5: status, popups, damage alerts
Game-file footprint unchanged: still one line in main.lua.
All 23 states now have descriptors; 5 patches applied.

Final two descriptors:

- keybindings_menu has two panels (commands and keys) with independent
  selection, so it reads currentlyActiveWindow to decide which one has focus.
  Announcing the wrong panel's row would be actively misleading, since the two
  lists are unrelated.
- research is a 2D node graph rather than a list, and its nodes are drawn as
  icons with no text label, so the id is the only identifier available. What
  actually matters is researched / locked / cannot afford / available: without
  the affordability part the screen is unusable by ear, because pressing enter
  on an unaffordable node silently does nothing.

status.lua adds two pull keys -- ' for health and active effects, , for the
current action -- plus unprompted damage alerts.

Damage is polled from actor.hp rather than hooked. ingame.checkInterruptions
exists but only fires for interruptions during queued movement, so damage taken
on your own turn never reaches it. Polling catches every source (combat, poison,
fire, falling) with no game edit. Damage speaks immediately and interrupts;
healing queues, since it is worth knowing but never urgent.

patches/popupstack.lua wraps popupStack.add, the single entry point for loot,
recipe, lore, map, quest, tutorial and bestiary popups. None of these go through
console.print, so they were entirely silent before.

speech.immediate now logs to the console like queued speech does. It bypasses
the frame queue and so also bypassed the flush-time logger, which meant damage
alerts fired correctly but were invisible in the testbed transcript. Prefixes
are now: ! interrupt, * immediate, space queued.

26/26 testbed checks, history unit tests passing.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-01 22:20:50 +02:00

157 lines
4.3 KiB
Lua

-- status.lua — player state on demand, plus unprompted alerts for things you
-- must not miss.
--
-- The HP bar, status icons and current-action indicator are all permanently
-- on-screen furniture. A sighted player absorbs them at a glance and never
-- "requests" them, so there is no game event to hook -- these have to be pull
-- keys, plus a push alert for damage.
local speech = require("accessibility.speech")
local status = {}
local function getPlayerActor()
local ok, world = pcall(require, "models.world")
if not ok or not world.currentLevel then
return nil
end
return world.currentLevel.playerActor
end
--------------------------------------------------------------------------------
-- Pull: health and statuses
--------------------------------------------------------------------------------
local function statusNames(actor)
local names = {}
for _, entry in ipairs(actor.statusList or {}) do
local name = entry.name or entry.templateId
if name then
names[#names + 1] = name
end
end
return names
end
function status.announceHealth()
local actor = getPlayerActor()
if not actor then
speech.say("No character", true)
return
end
local parts = {}
if actor.hp and actor.maxhp then
parts[#parts + 1] = string.format("Health %d of %d", actor.hp, actor.maxhp)
-- A bare number means little without a sense of how bad it is.
local fraction = actor.hp / actor.maxhp
if fraction <= 0.25 then
parts[#parts + 1] = "critical"
elseif fraction <= 0.5 then
parts[#parts + 1] = "wounded"
end
end
local names = statusNames(actor)
if #names > 0 then
parts[#parts + 1] = table.concat(names, ", ")
else
parts[#parts + 1] = "no active effects"
end
speech.say(table.concat(parts, ". "), true)
end
--------------------------------------------------------------------------------
-- Pull: current action
--------------------------------------------------------------------------------
function status.announceAction()
local ok, player = pcall(require, "models.player")
if not ok or not player.currentSelectedAction then
speech.say("No action selected", true)
return
end
local action = player.currentSelectedAction
-- `title` is the display string; `name` is the internal id ("interact"), so
-- capitalise when falling back to it.
local label = action.title
if not label then
label = action.name or "unknown"
label = label:sub(1, 1):upper() .. label:sub(2)
end
speech.say("Action: " .. label, true)
end
--------------------------------------------------------------------------------
-- Push: damage alerts
--------------------------------------------------------------------------------
-- Polled rather than hooked. ingame.checkInterruptions(hpLoss) exists, but it
-- only fires for interruptions during queued movement -- damage taken on your own
-- turn does not go through it. Watching hp directly catches every source
-- (combat, poison, fire, falling) with no game edit.
local lastHp
local lastActor
function status.update()
local actor = getPlayerActor()
if not actor or not actor.hp then
return
end
-- New character or new level: re-baseline instead of reporting a "change".
if actor ~= lastActor then
lastActor = actor
lastHp = actor.hp
return
end
if lastHp == nil then
lastHp = actor.hp
return
end
if actor.hp < lastHp then
local lost = lastHp - actor.hp
lastHp = actor.hp
-- Interrupting and immediate: taking damage outranks whatever flavour
-- text is queued, and waiting for end of frame is wrong for an alert.
speech.immediate(string.format("Hit, minus %d, health %d of %d",
lost, actor.hp, actor.maxhp or actor.hp), true)
if actor.maxhp and actor.hp / actor.maxhp <= 0.25 and actor.hp > 0 then
speech.say("Critical health", false)
end
elseif actor.hp > lastHp then
-- Healing is worth knowing but never urgent, so it queues.
local gained = actor.hp - lastHp
lastHp = actor.hp
speech.say(string.format("Healed %d, health %d of %d",
gained, actor.hp, actor.maxhp or actor.hp), false)
end
end
--------------------------------------------------------------------------------
-- Key registration
--------------------------------------------------------------------------------
function status.registerKeys(hotkeys)
hotkeys.bind("'", nil, status.announceHealth)
hotkeys.bind(",", nil, status.announceAction)
end
return status