diff --git a/changelog.txt b/changelog.txt index 1ed30ae501..c369705ecf 100644 --- a/changelog.txt +++ b/changelog.txt @@ -31,6 +31,7 @@ Template for new versions: - `gui/export-world-map`: New GUI tool to configure world map exports from the embark selection screen. ## New Features +- `gui/unit-info-viewer`: the unit character sheet now shows the viewed unit's current and long-term mood, color-coded by stress level ## Fixes - `bodyswap`: fix "invalid argument count" when the target unit has no nemesis record diff --git a/docs/gui/unit-info-viewer.rst b/docs/gui/unit-info-viewer.rst index ed9d29a2b2..05ea9e9516 100644 --- a/docs/gui/unit-info-viewer.rst +++ b/docs/gui/unit-info-viewer.rst @@ -27,3 +27,6 @@ panels, color-coded to highlight rust and the highest skill levels: - If a skill is rusty, then the level marker is colored light red - If a skill is at Legendary level or higher, it is colored light cyan - Other skills are colored plain white + +It also shows the viewed unit's current and long-term mood at the top of +their character sheet, color-coded by stress level. diff --git a/gui/unit-info-viewer.lua b/gui/unit-info-viewer.lua index db1635a0a6..411e5b6cb3 100644 --- a/gui/unit-info-viewer.lua +++ b/gui/unit-info-viewer.lua @@ -4,6 +4,7 @@ local gui = require('gui') local widgets = require('gui.widgets') local skills_progress = reqscript('internal/unit-info-viewer/skills-progress') +local overall_mood = reqscript('internal/unit-info-viewer/overall-mood') -------------------------------------------------- ---------------------- Time ---------------------- @@ -549,6 +550,7 @@ end OVERLAY_WIDGETS = { skillprogress=skills_progress.SkillProgressOverlay, + mood=overall_mood.OverallMoodOverlay, } if dfhack_flags.module then diff --git a/internal/unit-info-viewer/overall-mood.lua b/internal/unit-info-viewer/overall-mood.lua new file mode 100644 index 0000000000..b71298bf18 --- /dev/null +++ b/internal/unit-info-viewer/overall-mood.lua @@ -0,0 +1,132 @@ +--@ module=true + +local gui = require('gui') +local widgets = require('gui.widgets') +local overlay = require('plugins.overlay') + +local view_sheets = df.global.game.main_interface.view_sheets + +-- the unit sheet is a right-side panel whose layout shifts with the window +-- size, so anchor to the "Overview" tab label, which sits on the bottom tab +-- row and is rendered on every sub-tab of the sheet. the tabs are near the +-- top of the sheet, so only scan that band +local function find_overview_tab() + local dscreen = dfhack.screen + local sw, sh = dscreen.getWindowSize() + for y = 4, math.min(sh - 2, 25) do + local line = {} + for x = sw - 100, sw - 1 do + local tile = dscreen.readTile(x, y) + line[#line+1] = tile and tile.ch > 0 and tile.ch < 128 + and string.char(tile.ch) or ' ' + end + local s = table.concat(line):find('Overview') + if s then + return y, sw - 101 + s + end + end +end + +-- display names and colors follow the stress categories in spectate and +-- dwarfmonitor: index 1 is most stressed, 7 is least +local STRESS_LEVELS = { + {name='Miserable', pen=COLOR_LIGHTRED}, + {name='Unhappy', pen=COLOR_RED}, + {name='Displeased', pen=COLOR_YELLOW}, + {name='Content', pen=COLOR_WHITE}, + {name='Pleased', pen=COLOR_CYAN}, + {name='Happy', pen=COLOR_LIGHTBLUE}, + {name='Ecstatic', pen=COLOR_LIGHTGREEN}, +} + +local function get_level(unit, stress) + if not stress then + return STRESS_LEVELS[dfhack.units.getStressCategory(unit) + 1] + end + return STRESS_LEVELS[dfhack.units.getStressCategoryRaw(stress) + 1] +end + +OverallMoodOverlay = defclass(OverallMoodOverlay, overlay.OverlayWidget) +OverallMoodOverlay.ATTRS { + desc="Show the viewed unit's current and long-term mood on their sheet.", + default_pos={x=-96, y=10}, + default_enabled=true, + version=1, + viewscreens={ + 'dwarfmode/ViewSheets/UNIT/Overview', + 'dungeonmode/ViewSheets/UNIT/Overview', + }, + frame={w=27, h=2}, + -- anchor on the first tick the sheet is open so the widget never flashes + -- at the default position; the scan itself is gated by a cheap key check + overlay_onupdate_max_freq_seconds=0, +} + +function OverallMoodOverlay:init() + self:addviews{ + widgets.Label{ + view_id='mood', + frame={t=0, l=0}, + auto_height=false, + text='', + }, + } +end + +function OverallMoodOverlay:onRenderBody(dc) + local text, key = {}, '' + local unit = df.unit.find(view_sheets.active_id) + local soul = unit and unit.status.current_soul + if soul then + local cur = get_level(unit) + local longterm = get_level(unit, soul.personality.longterm_stress) + key = cur.name .. '|' .. longterm.name + text = { + {text='Mood: '}, + {text=cur.name, pen=cur.pen}, + NEWLINE, + {text='Long-term mood: '}, + {text=longterm.name, pen=longterm.pen}, + } + end + -- only rewrite the label when it changes; setText forces a relayout, + -- which makes the text flicker every frame + if key ~= self.last_text_key then + self.last_text_key = key + self.subviews.mood:setText(text) + end + OverallMoodOverlay.super.onRenderBody(self, dc) +end + +function OverallMoodOverlay:overlay_onupdate() + -- the overlay framework persists user-set positions in config.pos; if the + -- user has repositioned us away from the default, don't fight them + local config = overlay.get_state().config[self.name] + if config and config.pos and + (config.pos.x ~= self.default_pos.x or + config.pos.y ~= self.default_pos.y) then + return + end + -- only rescan when the window size or sheet unit changes, and only after a + -- successful anchor so a transient miss doesn't stick + local _, sh = dfhack.screen.getWindowSize() + local scan_key = sh * 100000 + view_sheets.active_id + if scan_key == self.scan_key and self.anchored then return end + self.scan_key = scan_key + local y, x = find_overview_tab() + if not y then + self.anchored = false + return + end + -- the tab label sits on the lower tab row; put the mood text at the bottom + -- of the vital-stats section (just above its divider), aligned with the + -- section's left content margin + local t = y + 13 + x = x - 2 + if self.anchored and self.frame.t == t and self.frame.l == x then return end + self.frame.t = t + self.frame.r = nil + self.frame.l = x + self.anchored = true + self:updateLayout(gui.ViewRect{rect=gui.get_interface_rect()}) +end diff --git a/test/overlay/unit-info-viewer-mood.lua b/test/overlay/unit-info-viewer-mood.lua new file mode 100644 index 0000000000..44603f911f --- /dev/null +++ b/test/overlay/unit-info-viewer-mood.lua @@ -0,0 +1,144 @@ +local gui = require('gui') +local overlay = require('plugins.overlay') + +local view_sheets = df.global.game.main_interface.view_sheets + +config = { + target = 'unit-info-viewer', + mode = 'fortress', +} + +local was_overlay_enabled = overlay.isEnabled() + +local function get_widget() + overlay.setEnabled(true) + overlay.rescan() + overlay.overlay_command({'enable', 'gui/unit-info-viewer.mood'}) + if not overlay.isOverlayEnabled('gui/unit-info-viewer.mood') then + qerror('could not enable gui/unit-info-viewer.mood overlay') + end + return overlay.get_state().db['gui/unit-info-viewer.mood'].widget +end + +local function sheet_is_open() + return dfhack.gui.matchFocusString('dwarfmode/ViewSheets/UNIT/Overview', + dfhack.gui.getDFViewscreen(true)) +end + +local function get_citizen() + for _,u in ipairs(df.global.world.units.active) do + if dfhack.units.isCitizen(u) and u.status.current_soul then + return u + end + end +end + +local function open_sheet(unit) + view_sheets.open = true + view_sheets.context = df.view_sheets_context_type.REGULAR_PLAY + view_sheets.active_sheet = df.view_sheet_type.UNIT + view_sheets.active_id = unit.id + view_sheets.active_sub_tab = 0 + view_sheets.viewing_unid:insert('#', unit.id) +end + +local function close_sheet() + view_sheets.open = false + view_sheets.active_id = -1 + view_sheets.active_sheet = df.view_sheet_type.NONE + view_sheets.viewing_unid:resize(0) +end + +local function with_unit_sheet(test_fn) + local unit = get_citizen() + expect.true_(unit ~= nil, 'test fort must have at least one citizen') + local was_open = sheet_is_open() + local saved = { + open = view_sheets.open, + context = view_sheets.context, + sheet = view_sheets.active_sheet, + id = view_sheets.active_id, + sub_tab = view_sheets.active_sub_tab, + } + local saved_unids = {} + for i = 0, #view_sheets.viewing_unid - 1 do + saved_unids[i] = view_sheets.viewing_unid[i] + end + dfhack.with_finalize( + function() + view_sheets.viewing_unid:resize(0) + if was_open then + view_sheets.open = saved.open + view_sheets.context = saved.context + view_sheets.active_sheet = saved.sheet + view_sheets.active_id = saved.id + view_sheets.active_sub_tab = saved.sub_tab + for i = 0, #saved_unids - 1 do + view_sheets.viewing_unid:insert('#', saved_unids[i]) + end + else + close_sheet() + end + overlay.setEnabled(was_overlay_enabled) + end, + function() + open_sheet(unit) + test_fn(get_widget(), unit) + end) +end + +local function label_text(widget) + widget:onRenderBody() + local parts = {} + for _,tok in ipairs(widget.subviews.mood.text) do + parts[#parts+1] = type(tok) == 'table' and tok.text or tostring(tok) + end + return table.concat(parts) +end + +function test.mood_shows_stress_categories() + with_unit_sheet(function(widget, unit) + local soul = unit.status.current_soul + local cur = soul.personality.stress + local lt = soul.personality.longterm_stress + local text = label_text(widget) + expect.true_(text:find('Mood:') ~= nil, 'mood line missing: ' .. text) + expect.true_(text:find('Long%-term mood:') ~= nil, + 'long-term line missing: ' .. text) + expect.true_(#text > 0) + end) +end + +function test.mood_reflects_stress_change() + with_unit_sheet(function(widget, unit) + local soul = unit.status.current_soul + local saved = soul.personality.stress + local function restore() + soul.personality.stress = saved + end + dfhack.with_finalize(restore, function() + soul.personality.stress = 100000 + local text = label_text(widget) + expect.true_(text:find('Miserable') ~= nil, + 'expected Miserable at stress=100000: ' .. text) + soul.personality.stress = -150000 + text = label_text(widget) + expect.true_(text:find('Ecstatic') ~= nil, + 'expected Ecstatic at stress=-150000: ' .. text) + end) + end) +end + +function test.mood_hides_for_soulless_unit() + with_unit_sheet(function(widget, unit) + local soul = unit.status.current_soul + local saved = unit.status.current_soul + dfhack.with_finalize(function() + unit.status.current_soul = saved + end, function() + unit.status.current_soul = nil + local text = label_text(widget) + expect.eq('', text, 'expected empty text for soul-less unit') + end) + end) +end