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>
This commit is contained in:
co-authored by
Claude Opus 5
parent
50b226620e
commit
96ec79ed75
@@ -0,0 +1,156 @@
|
||||
-- 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
|
||||
Reference in New Issue
Block a user