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..69dd7ca
--- /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 DISCUSSION_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 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
+
+--- 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 * 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
+
+ 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 - 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 + DISCUSSION_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)