From 5daf98fd06615181aeb1743c040900d033c31298 Mon Sep 17 00:00:00 2001 From: Zoltan Timar Date: Thu, 12 Mar 2026 17:08:08 +0100 Subject: [PATCH 1/2] feat: added new discussion functionality, implemented textbox draw functionality, added first discussion with sumphore to the street --- .luacheckrc | 2 + impostor.inc | 4 + inc/decision/decision.manager.lua | 20 +++- inc/decision/decision.start_discussion.lua | 18 ++++ inc/discussion/discussion.sumphore.lua | 59 ++++++++++ inc/init/init.context.lua | 10 ++ inc/init/init.module.lua | 1 + inc/logic/logic.discussion.lua | 103 ++++++++++++++++++ inc/screen/screen.walking_to_office.lua | 1 + inc/system/system.ui.lua | 120 ++++++++++++++++----- inc/window/window.discussion.lua | 107 ++++++++++++++++++ inc/window/window.minigame.mash.lua | 2 +- inc/window/window.minigame.rhythm.lua | 2 +- inc/window/window.register.lua | 3 + 14 files changed, 421 insertions(+), 31 deletions(-) create mode 100644 inc/decision/decision.start_discussion.lua create mode 100644 inc/discussion/discussion.sumphore.lua create mode 100644 inc/logic/logic.discussion.lua create mode 100644 inc/window/window.discussion.lua diff --git a/.luacheckrc b/.luacheckrc index ac053ec..1d94ea5 100644 --- a/.luacheckrc +++ b/.luacheckrc @@ -7,6 +7,7 @@ globals = { "Timer", "Glitch", "Trigger", + "Discussion", "Util", "Decision", "Situation", @@ -32,6 +33,7 @@ globals = { "MinigameRhythmWindow", "MinigameDDRWindow", "MysteriousManWindow", + "DiscussionWindow", "EndWindow", "mset", "mget", diff --git a/impostor.inc b/impostor.inc index 75b404a..24e6e84 100644 --- a/impostor.inc +++ b/impostor.inc @@ -12,6 +12,7 @@ logic/logic.timer.lua logic/logic.trigger.lua logic/logic.minigame.lua logic/logic.glitch.lua +logic/logic.discussion.lua system/system.ui.lua audio/audio.manager.lua audio/audio.songs.lua @@ -29,6 +30,8 @@ decision/decision.go_to_end.lua decision/decision.go_to_walking_to_home.lua decision/decision.go_to_sleep.lua decision/decision.do_work.lua +decision/decision.start_discussion.lua +discussion/discussion.sumphore.lua map/map.manager.lua map/map.bedroom.lua map/map.street.lua @@ -53,6 +56,7 @@ window/window.minigame.mash.lua window/window.minigame.rhythm.lua window/window.minigame.ddr.lua window/window.mysterious_man.lua +window/window.discussion.lua window/window.game.lua system/system.main.lua meta/meta.assets.lua diff --git a/inc/decision/decision.manager.lua b/inc/decision/decision.manager.lua index 330a1d9..8950ece 100644 --- a/inc/decision/decision.manager.lua +++ b/inc/decision/decision.manager.lua @@ -5,7 +5,7 @@ local _decisions = {} --- @within Decision --- @param decision table The decision data table. --- @param decision.id string Unique decision identifier. ---- @param decision.label string Display text for the decision. +--- @param decision.label string|function Display text for the decision, or a function returning it. --- @param[opt] decision.condition function Returns true if decision is available. Defaults to always true. --- @param[opt] decision.handle function Called when the decision is selected. Defaults to noop. function Decision.register(decision) @@ -30,6 +30,18 @@ function Decision.register(decision) _decisions[decision.id] = decision end +--- Gets the display label for a decision. +--- @within Decision +--- @param decision table The decision data table. +--- @return string result The resolved decision label. +function Decision.get_label(decision) + if not decision then return "" end + if type(decision.label) == "function" then + return decision.label() or "" + end + return decision.label or "" +end + --- Gets a decision by ID. --- @within Decision --- @param id string The ID of the decision. @@ -109,12 +121,10 @@ function Decision.draw(decisions, selected_decision_index) rect(0, bar_y, Config.screen.width, bar_height, Config.colors.dark_grey) if #decisions > 0 then local selected_decision = decisions[selected_decision_index] - local decision_label = selected_decision.label - local text_width = #decision_label * 4 + local decision_label = Decision.get_label(selected_decision) local text_y = bar_y + 4 - local text_x = (Config.screen.width - text_width) / 2 Print.text("<", 2, text_y, Config.colors.light_blue) - Print.text(decision_label, text_x, text_y, Config.colors.item) + Print.text_center(decision_label, Config.screen.width / 2, text_y, Config.colors.item) Print.text(">", Config.screen.width - 6, text_y, Config.colors.light_blue) end end diff --git a/inc/decision/decision.start_discussion.lua b/inc/decision/decision.start_discussion.lua new file mode 100644 index 0000000..e7e4159 --- /dev/null +++ b/inc/decision/decision.start_discussion.lua @@ -0,0 +1,18 @@ +Decision.register({ + id = "start_discussion", + label = function() + if Context.day_count >= 3 then + return "Talk to Sumphore" + end + return "Talk to the homeless guy" + end, + handle = function() + if Context.day_count < 3 then + Discussion.start("homeless_guy", "game") + end + if Context.day_count >= 3 then + Discussion.start("sumphore_day_3", "game") + return + end + end, +}) diff --git a/inc/discussion/discussion.sumphore.lua b/inc/discussion/discussion.sumphore.lua new file mode 100644 index 0000000..fc04a8c --- /dev/null +++ b/inc/discussion/discussion.sumphore.lua @@ -0,0 +1,59 @@ +Discussion.register({ + id = "sumphore_day_3", + steps = { + { + question = "Are you still seeking the ox?", + answers = { + { label = "Huh? What ox?", next_step = 2 }, + { label = "Are you drunk, old man?", next_step = nil }, + }, + }, + { + question = "Did you never think there would be more to this?", + answers = { + { label = "I'm not sure what you mean.", next_step = nil }, + }, + }, + }, +}) + + +Discussion.register({ + id = "homeless_guy", + steps = { + { + question = "Sup bro, how are you?", + answers = { + { label = "I'm doing great, thanks!", next_step = 2 }, + { label = "Not as good as you", next_step = nil }, + }, + }, + { + question = "What's your name?", + answers = { + { label = "Norman Reds, nice to meet you.", next_step = 3 }, + { label = "Mom told me not to talk to strangers.", next_step = nil }, + }, + }, + { + question = "That name ... could it be? I know a guy with that name...", + answers = { + { label = "Never met you before.", next_step = 4 }, + { label = "I'm not sure what you mean.", next_step = nil }, + + }, + }, + { + question = "My name is Sumphore, nice to meet you.", + answers = { + { label = "Nice to meet you, Sumphore.", next_step = 5 }, + }, + }, + { + question = "You're a good guy, I can tell. You abide by the rules. Life would be so much easier if more people were like you ...", + answers = { + { label = "Thanks, I try my best.", next_step = nil }, + }, + }, + }, +}) \ No newline at end of file diff --git a/inc/init/init.context.lua b/inc/init/init.context.lua index dac01ff..93a6609 100644 --- a/inc/init/init.context.lua +++ b/inc/init/init.context.lua @@ -51,6 +51,16 @@ function Context.initial_data() state = "choice", selection = 1, }, + discussion = { + active = false, + id = nil, + step = 1, + selected_answer = 1, + scroll_y = 0, + scroll_timer = 0, + auto_scroll = true, + return_window = nil, + }, } end diff --git a/inc/init/init.module.lua b/inc/init/init.module.lua index 3a9654b..cb8557f 100644 --- a/inc/init/init.module.lua +++ b/inc/init/init.module.lua @@ -15,3 +15,4 @@ Focus = {} Day = {} Timer = {} Trigger = {} +Discussion = {} diff --git a/inc/logic/logic.discussion.lua b/inc/logic/logic.discussion.lua new file mode 100644 index 0000000..6729d71 --- /dev/null +++ b/inc/logic/logic.discussion.lua @@ -0,0 +1,103 @@ +--- @section Discussion +local _discussions = {} + +--- Registers a discussion definition. +--- @within Discussion +--- @param discussion table The discussion data table. +--- @param discussion.id string Unique discussion identifier. +--- @param discussion.steps table Array of step tables, each with `question` (string) and `answers` (array of {label, next_step} tables). +--- @param[opt] discussion.on_end function Called when the discussion ends. Defaults to noop. +function Discussion.register(discussion) + if not discussion or not discussion.id then + trace("Error: Invalid discussion registered (missing id)!") + return + end + if not discussion.steps or #discussion.steps == 0 then + trace("Error: Discussion '" .. discussion.id .. "' has no steps!") + return + end + if not discussion.on_end then + discussion.on_end = function() end + end + if _discussions[discussion.id] then + trace("Warning: Overwriting discussion with id: " .. discussion.id) + end + _discussions[discussion.id] = discussion +end + +--- Gets a discussion by ID. +--- @within Discussion +--- @param id string The discussion ID. +--- @return table|nil result The discussion table or nil. +function Discussion.get_by_id(id) + return _discussions[id] +end + +--- Starts a discussion, switching to the discussion window. +--- @within Discussion +--- @param id string The discussion ID to start. +--- @param return_window string The window ID to return to after the discussion. +function Discussion.start(id, return_window) + local discussion = _discussions[id] + if not discussion then + trace("Error: Discussion not found: " .. tostring(id)) + return + end + Context.discussion.active = true + Context.discussion.id = id + Context.discussion.step = 1 + Context.discussion.selected_answer = 1 + Context.discussion.scroll_y = 0 + Context.discussion.scroll_timer = 0 + Context.discussion.auto_scroll = true + Context.discussion.return_window = return_window or "game" + Meter.hide() + Window.set_current("discussion") +end + +--- Gets the current step data for the active discussion. +--- @within Discussion +--- @return table|nil result The current step table or nil. +function Discussion.get_current_step() + if not Context.discussion.active or not Context.discussion.id then return nil end + local discussion = _discussions[Context.discussion.id] + if not discussion then return nil end + return discussion.steps[Context.discussion.step] +end + +--- Advances to a specific step or ends the discussion. +--- @within Discussion +--- @param next_step number|nil The step index to go to, or nil to end. +function Discussion.go_to_step(next_step) + if not next_step then + Discussion.finish() + return + end + local discussion = _discussions[Context.discussion.id] + if not discussion or not discussion.steps[next_step] then + Discussion.finish() + return + end + Context.discussion.step = next_step + Context.discussion.selected_answer = 1 + Context.discussion.scroll_y = 0 + Context.discussion.scroll_timer = 0 + Context.discussion.auto_scroll = true +end + +--- Ends the active discussion and returns to the previous window. +--- @within Discussion +function Discussion.finish() + local discussion = _discussions[Context.discussion.id] + local return_window = Context.discussion.return_window or "game" + Context.discussion.active = false + Context.discussion.id = nil + Context.discussion.scroll_y = 0 + Context.discussion.scroll_timer = 0 + Context.discussion.auto_scroll = true + Meter.show() + if discussion and discussion.on_end then + discussion.on_end() + end + Window.set_current(return_window) +end diff --git a/inc/screen/screen.walking_to_office.lua b/inc/screen/screen.walking_to_office.lua index 5f28f79..c77379a 100644 --- a/inc/screen/screen.walking_to_office.lua +++ b/inc/screen/screen.walking_to_office.lua @@ -4,6 +4,7 @@ Screen.register({ decisions = { "go_to_home", "go_to_office", + "start_discussion", }, background = "street" }) diff --git a/inc/system/system.ui.lua b/inc/system/system.ui.lua index d48fe3d..5a371b7 100644 --- a/inc/system/system.ui.lua +++ b/inc/system/system.ui.lua @@ -46,36 +46,108 @@ function UI.update_menu(items, selected_item) return selected_item end +--- Draws a bordered textbox with scrolling text. +--- @within UI +--- @param text string The text to display (multi-line supported).
+--- @param box_x number The x-coordinate of the box.
+--- @param box_y number The y-coordinate of the box.
+--- @param box_w number The width of the box.
+--- @param box_h number The height of the box.
+--- @param scroll_y number The vertical scroll offset for the text (0 = top, increases to scroll up).
+--- @param[opt] color number The text color (default: Config.colors.white).
+--- @param[opt] bg_color number The background fill color (default: Config.colors.dark_grey).
+--- @param[opt] border_color number The border color (default: Config.colors.white).
+--- @param[opt] center_text boolean Whether to center each line inside the box. Defaults to false.
+function UI.draw_textbox(text, box_x, box_y, box_w, box_h, scroll_y, color, bg_color, border_color, center_text) + color = color or Config.colors.white + bg_color = bg_color or Config.colors.dark_grey + border_color = border_color or Config.colors.white + center_text = center_text or false + + local padding = 4 + local line_height = 8 + local inner_x = box_x + padding + local inner_y = box_y + padding + local inner_center_x = box_x + (box_w / 2) + local visible_height = box_h - padding * 2 + local lines = UI.word_wrap(text, 30) + local text_height = #lines * line_height + local base_y = inner_y + + if center_text and text_height < visible_height then + base_y = inner_y + math.floor((visible_height - text_height) / 2) + end + + rect(box_x, box_y, box_w, box_h, bg_color) + + for i, line in ipairs(lines) do + local ly = base_y + (i - 1) * line_height - scroll_y + if ly >= inner_y and ly + line_height <= inner_y + visible_height then + if center_text then + Print.text_center(line, inner_center_x, ly, color) + else + Print.text(line, inner_x, ly, color) + end + end + end + + rectb(box_x, box_y, box_w, box_h, border_color) +end + --- Wraps text. --- @within UI --- @param text string The text to wrap.
--- @param max_chars_per_line number The maximum characters per line.
--- @return result table A table of wrapped lines. function UI.word_wrap(text, max_chars_per_line) - if text == nil then return {""} end - local lines = {} - for input_line in (text .. "\n"):gmatch("(.-)\n") do - local current_line = "" - local words_in_line = 0 - for word in input_line:gmatch("%S+") do - words_in_line = words_in_line + 1 - if #current_line == 0 then - current_line = word - elseif #current_line + #word + 1 <= max_chars_per_line then - current_line = current_line .. " " .. word - else - table.insert(lines, current_line) - current_line = word - end - end - if words_in_line > 0 then - table.insert(lines, current_line) - else - table.insert(lines, "") - end + if text == nil then return {""} end + + local lines = {} + + local function trim(s) + return (s:gsub("^%s+", ""):gsub("%s+$", "")) + end + + local function previous_whitespace_index(s, target) + if s:sub(target, target):match("%s") then + return target end - if #lines == 0 then - return {""} + + for i = target - 1, 1, -1 do + if s:sub(i, i):match("%s") then + return i + end end - return lines + + return nil + end + + for input_line in (text .. "\n"):gmatch("(.-)\n") do + local remaining = trim(input_line) + + if remaining == "" then + table.insert(lines, "") + else + while #remaining > max_chars_per_line do + local split_at = previous_whitespace_index(remaining, max_chars_per_line) + local line = trim(remaining:sub(1, split_at)) + + if not split_at or line == "" then + line = remaining:sub(1, max_chars_per_line) + split_at = max_chars_per_line + end + + table.insert(lines, line) + remaining = trim(remaining:sub(split_at + 1)) + end + + table.insert(lines, remaining) + end + end + + if #lines == 0 then + return {""} + end + + return lines end diff --git a/inc/window/window.discussion.lua b/inc/window/window.discussion.lua new file mode 100644 index 0000000..b70c170 --- /dev/null +++ b/inc/window/window.discussion.lua @@ -0,0 +1,107 @@ +--- @section DiscussionWindow + +local TEXTBOX_W = math.floor(Config.screen.width * 0.7) +local TEXTBOX_H = math.floor(Config.screen.height * 0.3) +local TEXTBOX_X = math.floor((Config.screen.width - TEXTBOX_W) / 2) +local TEXTBOX_Y = math.floor((Config.screen.height - TEXTBOX_H) / 2 - 8) +local TEXTBOX_MAX_CHARS = 30 +local LINE_HEIGHT = 8 +local PADDING = 4 +local AUTO_SCROLL_DELAY = 12 +local AUTO_SCROLL_STEP = 1 + +--- Draws the discussion window. +--- @within DiscussionWindow +function DiscussionWindow.draw() + GameWindow.draw() + + local step = Discussion.get_current_step() + if not step then return end + + UI.draw_textbox( + step.question, + TEXTBOX_X, TEXTBOX_Y, + TEXTBOX_W, TEXTBOX_H, + Context.discussion.scroll_y, + Config.colors.white, + Config.colors.dark_grey, + Config.colors.light_blue, + true + ) + + local answers = step.answers + if #answers > 0 then + local bar_height = 16 + local bar_y = Config.screen.height - bar_height + rect(0, bar_y, Config.screen.width, bar_height, Config.colors.dark_grey) + local selected = answers[Context.discussion.selected_answer] + local label = selected.label + local text_y = bar_y + 4 + Print.text("<", 2, text_y, Config.colors.light_blue) + Print.text_center(label, Config.screen.width / 2, text_y, Config.colors.item) + Print.text(">", Config.screen.width - 6, text_y, Config.colors.light_blue) + end +end + +--- Updates the discussion window logic. +--- @within DiscussionWindow +function DiscussionWindow.update() + local step = Discussion.get_current_step() + if not step then return end + + local lines = UI.word_wrap(step.question, TEXTBOX_MAX_CHARS) + local text_height = #lines * LINE_HEIGHT + local visible_height = TEXTBOX_H - PADDING * 2 + local max_scroll = text_height - visible_height + if max_scroll < 0 then max_scroll = 0 end + + if max_scroll > 0 then + if Context.discussion.auto_scroll then + Context.discussion.scroll_timer = Context.discussion.scroll_timer + 1 + if Context.discussion.scroll_timer >= AUTO_SCROLL_DELAY then + Context.discussion.scroll_timer = 0 + Context.discussion.scroll_y = Context.discussion.scroll_y + AUTO_SCROLL_STEP + if Context.discussion.scroll_y > max_scroll then + Context.discussion.scroll_y = max_scroll + end + end + end + else + Context.discussion.scroll_y = 0 + Context.discussion.scroll_timer = 0 + end + + if Input.up() then + Context.discussion.auto_scroll = false + Context.discussion.scroll_y = Context.discussion.scroll_y - LINE_HEIGHT + if Context.discussion.scroll_y < 0 then + Context.discussion.scroll_y = 0 + end + elseif Input.down() then + Context.discussion.auto_scroll = false + Context.discussion.scroll_y = Context.discussion.scroll_y + LINE_HEIGHT + if Context.discussion.scroll_y > max_scroll then + Context.discussion.scroll_y = max_scroll + end + end + + local answers = step.answers + if #answers > 0 then + if Input.left() then + Audio.sfx_beep() + Context.discussion.selected_answer = Util.safeindex(answers, Context.discussion.selected_answer - 1) + elseif Input.right() then + Audio.sfx_beep() + Context.discussion.selected_answer = Util.safeindex(answers, Context.discussion.selected_answer + 1) + end + + if Input.select() then + Audio.sfx_select() + local selected = answers[Context.discussion.selected_answer] + if selected.on_select then + selected.on_select() + end + Discussion.go_to_step(selected.next_step) + end + end +end diff --git a/inc/window/window.minigame.mash.lua b/inc/window/window.minigame.mash.lua index d38ef0e..5d1fa11 100644 --- a/inc/window/window.minigame.mash.lua +++ b/inc/window/window.minigame.mash.lua @@ -129,7 +129,7 @@ function MinigameButtonMashWindow.draw() if mg.button_pressed_timer > 0 then circ(mg.button_x, mg.button_y, mg.button_size - 2, button_color) end - Print.text_center(" Z", mg.button_x - 2, mg.button_y - 3, Config.colors.light_grey) + Print.text_center("Z", mg.button_x, mg.button_y - 3, button_color) Print.text_center("MASH Z!", Config.screen.width / 2, mg.bar_y + mg.bar_height + 10, Config.colors.light_grey) local percentage = math.floor((mg.bar_fill / mg.max_fill) * 100) Print.text_center(percentage .. "%", mg.bar_x + mg.bar_width / 2, mg.bar_y + 2, Config.colors.black) diff --git a/inc/window/window.minigame.rhythm.lua b/inc/window/window.minigame.rhythm.lua index 064a9d5..5dfc3d6 100644 --- a/inc/window/window.minigame.rhythm.lua +++ b/inc/window/window.minigame.rhythm.lua @@ -160,7 +160,7 @@ function MinigameRhythmWindow.draw() if mg.button_pressed_timer > 0 then circ(mg.button_x, mg.button_y, mg.button_size - 2, button_color) end - Print.text_center("Z", mg.button_x - 2, mg.button_y - 3, button_color) + Print.text_center("Z", mg.button_x, mg.button_y - 3, button_color) if mg.win_timer > 0 then Minigame.draw_win_overlay() diff --git a/inc/window/window.register.lua b/inc/window/window.register.lua index d9f82b2..46ad6ff 100644 --- a/inc/window/window.register.lua +++ b/inc/window/window.register.lua @@ -33,3 +33,6 @@ Window.register("mysterious_man", MysteriousManWindow) EndWindow = {} Window.register("end", EndWindow) + +DiscussionWindow = {} +Window.register("discussion", DiscussionWindow) From 5346281c5c965082aaaa9b108524e1dba3ebbad1 Mon Sep 17 00:00:00 2001 From: Zoltan Timar Date: Thu, 12 Mar 2026 17:10:24 +0100 Subject: [PATCH 2/2] fix: lint fix --- inc/window/window.discussion.lua | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/inc/window/window.discussion.lua b/inc/window/window.discussion.lua index b70c170..69dd7ca 100644 --- a/inc/window/window.discussion.lua +++ b/inc/window/window.discussion.lua @@ -5,7 +5,7 @@ local TEXTBOX_H = math.floor(Config.screen.height * 0.3) local TEXTBOX_X = math.floor((Config.screen.width - TEXTBOX_W) / 2) local TEXTBOX_Y = math.floor((Config.screen.height - TEXTBOX_H) / 2 - 8) local TEXTBOX_MAX_CHARS = 30 -local LINE_HEIGHT = 8 +local DISCUSSION_LINE_HEIGHT = 8 local PADDING = 4 local AUTO_SCROLL_DELAY = 12 local AUTO_SCROLL_STEP = 1 @@ -36,10 +36,10 @@ function DiscussionWindow.draw() rect(0, bar_y, Config.screen.width, bar_height, Config.colors.dark_grey) local selected = answers[Context.discussion.selected_answer] local label = selected.label - local text_y = bar_y + 4 - Print.text("<", 2, text_y, Config.colors.light_blue) - Print.text_center(label, Config.screen.width / 2, text_y, Config.colors.item) - Print.text(">", Config.screen.width - 6, text_y, Config.colors.light_blue) + local answer_text_y = bar_y + 4 + Print.text("<", 2, answer_text_y, Config.colors.light_blue) + Print.text_center(label, Config.screen.width / 2, answer_text_y, Config.colors.item) + Print.text(">", Config.screen.width - 6, answer_text_y, Config.colors.light_blue) end end @@ -50,7 +50,7 @@ function DiscussionWindow.update() if not step then return end local lines = UI.word_wrap(step.question, TEXTBOX_MAX_CHARS) - local text_height = #lines * LINE_HEIGHT + local text_height = #lines * DISCUSSION_LINE_HEIGHT local visible_height = TEXTBOX_H - PADDING * 2 local max_scroll = text_height - visible_height if max_scroll < 0 then max_scroll = 0 end @@ -73,13 +73,13 @@ function DiscussionWindow.update() if Input.up() then Context.discussion.auto_scroll = false - Context.discussion.scroll_y = Context.discussion.scroll_y - LINE_HEIGHT + Context.discussion.scroll_y = Context.discussion.scroll_y - DISCUSSION_LINE_HEIGHT if Context.discussion.scroll_y < 0 then Context.discussion.scroll_y = 0 end elseif Input.down() then Context.discussion.auto_scroll = false - Context.discussion.scroll_y = Context.discussion.scroll_y + LINE_HEIGHT + Context.discussion.scroll_y = Context.discussion.scroll_y + DISCUSSION_LINE_HEIGHT if Context.discussion.scroll_y > max_scroll then Context.discussion.scroll_y = max_scroll end