feature/imp-89-textbox-impl #33

Merged
mr.two merged 2 commits from feature/imp-89-textbox-impl into master 2026-03-12 16:11:44 +00:00
14 changed files with 421 additions and 31 deletions
Showing only changes of commit 5daf98fd06 - Show all commits

View File

@@ -7,6 +7,7 @@ globals = {
"Timer",
"Glitch",
"Trigger",
"Discussion",
"Util",
"Decision",
"Situation",
@@ -32,6 +33,7 @@ globals = {
"MinigameRhythmWindow",
"MinigameDDRWindow",
"MysteriousManWindow",
"DiscussionWindow",
"EndWindow",
"mset",
"mget",

View File

@@ -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

View File

@@ -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

View File

@@ -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,
})

View File

@@ -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 },
},
},
},
})

View File

@@ -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

View File

@@ -15,3 +15,4 @@ Focus = {}
Day = {}
Timer = {}
Trigger = {}
Discussion = {}

View File

@@ -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

View File

@@ -4,6 +4,7 @@ Screen.register({
decisions = {
"go_to_home",
"go_to_office",
"start_discussion",
},
background = "street"
})

View File

@@ -46,6 +46,54 @@ 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).<br/>
--- @param box_x number The x-coordinate of the box.<br/>
--- @param box_y number The y-coordinate of the box.<br/>
--- @param box_w number The width of the box.<br/>
--- @param box_h number The height of the box.<br/>
--- @param scroll_y number The vertical scroll offset for the text (0 = top, increases to scroll up).<br/>
--- @param[opt] color number The text color (default: Config.colors.white).<br/>
--- @param[opt] bg_color number The background fill color (default: Config.colors.dark_grey).<br/>
--- @param[opt] border_color number The border color (default: Config.colors.white).<br/>
--- @param[opt] center_text boolean Whether to center each line inside the box. Defaults to false.<br/>
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.<br/>
@@ -53,29 +101,53 @@ end
--- @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 = {}
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
for i = target - 1, 1, -1 do
if s:sub(i, i):match("%s") then
return i
end
end
return nil
end
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
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

View File

@@ -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

View File

@@ -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)

View File

@@ -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()

View File

@@ -33,3 +33,6 @@ Window.register("mysterious_man", MysteriousManWindow)
EndWindow = {}
Window.register("end", EndWindow)
DiscussionWindow = {}
Window.register("discussion", DiscussionWindow)