feat: added intro sequence, fixed norman's sprite, placed him in various places
Some checks failed
ci/woodpecker/push/woodpecker Pipeline failed
ci/woodpecker/pr/woodpecker Pipeline failed

This commit is contained in:
Zoltan Timar
2026-03-12 18:10:50 +01:00
parent c897cdcbd5
commit b349ded281
14 changed files with 211 additions and 80 deletions

View File

@@ -40,3 +40,6 @@ function Audio.sfx_success() sfx(16, 'C-7', 60) end
--- Plays bloop sound effect. --- Plays bloop sound effect.
--- @within Audio --- @within Audio
function Audio.sfx_bloop() sfx(21, 'C-3', 60) end function Audio.sfx_bloop() sfx(21, 'C-3', 60) end
--- Plays alarm sound effect.
--- @within Audio
function Audio.sfx_alarm() sfx(61) end

View File

@@ -5,8 +5,8 @@ Decision.register({
Meter.hide() Meter.hide()
Day.increase() Day.increase()
MinigameRhythmWindow.start("game", { MinigameRhythmWindow.start("game", {
focus_center_x = Config.screen.width / 2, focus_center_x = (Config.screen.width / 2) - 22,
focus_center_y = Config.screen.height / 2, focus_center_y = (Config.screen.height / 2) - 18,
focus_initial_radius = 0, focus_initial_radius = 0,
on_win = function() on_win = function()
MysteriousManWindow.start() MysteriousManWindow.start()

View File

@@ -4,8 +4,8 @@ Decision.register({
handle = function() handle = function()
Meter.hide() Meter.hide()
MinigameButtonMashWindow.start("game", { MinigameButtonMashWindow.start("game", {
focus_center_x = Config.screen.width / 2, focus_center_x = (Config.screen.width / 2) - 22,
focus_center_y = Config.screen.height / 2, focus_center_y = (Config.screen.height / 2) - 18,
focus_initial_radius = 0, focus_initial_radius = 0,
}) })
end, end,

View File

@@ -4,8 +4,8 @@ Decision.register({
handle = function() handle = function()
Meter.hide() Meter.hide()
MinigameRhythmWindow.start("game", { MinigameRhythmWindow.start("game", {
focus_center_x = Config.screen.width / 2, focus_center_x = (Config.screen.width / 2) - 22,
focus_center_y = Config.screen.height / 2, focus_center_y = (Config.screen.height / 2) - 18,
focus_initial_radius = 0, focus_initial_radius = 0,
}) })
end, end,

View File

@@ -37,6 +37,7 @@ function Context.initial_data()
meters = Meter.get_initial(), meters = Meter.get_initial(),
timer = Timer.get_initial(), timer = Timer.get_initial(),
triggers = {}, triggers = {},
home_norman_visible = false,
game = { game = {
current_screen = "home", current_screen = "home",
current_situation = nil, current_situation = nil,
@@ -86,6 +87,34 @@ function Context.new_game()
Context.game_in_progress = true Context.game_in_progress = true
MenuWindow.refresh_menu_items() MenuWindow.refresh_menu_items()
Screen.get_by_id(Context.game.current_screen).init() Screen.get_by_id(Context.game.current_screen).init()
MysteriousManWindow.start({
text = [[
Norman was never a bad
simulation engineer, but
we need to be careful in
letting him improve. We
need to distract him.
]],
on_text_complete = function()
Audio.sfx_alarm()
Context.home_norman_visible = false
Util.go_to_screen_by_id("home")
MinigameButtonMashWindow.start("game", {
focus_center_x = (Config.screen.width / 2) - 22,
focus_center_y = (Config.screen.height / 2) - 18,
focus_initial_radius = 0,
target_points = 100,
instruction_text = "Wake up Norman!",
show_progress_text = false,
on_win = function()
Audio.music_play_wakingup()
Context.home_norman_visible = true
Meter.show()
Window.set_current("game")
end,
})
end,
})
end end
--- Saves the current game state. --- Saves the current game state.

View File

@@ -7,5 +7,10 @@ Screen.register({
"go_to_sleep", "go_to_sleep",
"go_to_end", "go_to_end",
}, },
background = "bedroom" background = "bedroom",
draw = function()
if Context.home_norman_visible and Window.get_current_id() == "game" then
Sprite.draw_at("norman", 100, 80)
end
end
}) })

View File

@@ -26,10 +26,14 @@ Screen.register({
local sw = Config.screen.width local sw = Config.screen.width
local cx = sw / 2 local cx = sw / 2
local norman_x = math.floor(sw * 0.75)
local norman_y = math.floor(Config.screen.height * 0.75)
local bar_w = math.floor(sw * 0.75) local bar_w = math.floor(sw * 0.75)
local bar_x = math.floor((sw - bar_w) / 2) local bar_x = math.floor((sw - bar_w) / 2)
local bar_h = 4 local bar_h = 4
Sprite.draw_at("norman", norman_x, norman_y)
Print.text_center("day " .. Context.day_count, cx, 10, Config.colors.white) Print.text_center("day " .. Context.day_count, cx, 10, Config.colors.white)
local narrative = "reflecting on my past and present\n...\nboth eventually flushed." local narrative = "reflecting on my past and present\n...\nboth eventually flushed."

View File

@@ -2,6 +2,32 @@
local _sprites = {} local _sprites = {}
local _active_sprites = {} local _active_sprites = {}
local function draw_sprite_instance(sprite_data, params)
local colorkey = params.colorkey or sprite_data.colorkey or 0
local scale = params.scale or sprite_data.scale or 1
local flip_x = params.flip_x or sprite_data.flip_x or 0
local flip_y = params.flip_y or sprite_data.flip_y or 0
local rot = params.rot or sprite_data.rot or 0
if sprite_data.sprites then
for i = 1, #sprite_data.sprites do
local sub_sprite = sprite_data.sprites[i]
spr(
sub_sprite.s,
params.x + (sub_sprite.x_offset or 0),
params.y + (sub_sprite.y_offset or 0),
sub_sprite.colorkey or colorkey,
sub_sprite.scale or scale,
sub_sprite.flip_x or flip_x,
sub_sprite.flip_y or flip_y,
sub_sprite.rot or rot
)
end
else
spr(sprite_data.s, params.x, params.y, colorkey, scale, flip_x, flip_y, rot)
end
end
--- Registers a sprite definition. --- Registers a sprite definition.
--- @within Sprite --- @within Sprite
--- @param sprite_data table A table containing the sprite definition. --- @param sprite_data table A table containing the sprite definition.
@@ -59,6 +85,34 @@ function Sprite.hide(id)
_active_sprites[id] = nil _active_sprites[id] = nil
end end
--- Draws a sprite immediately without scheduling it.
--- @within Sprite
--- @param id string The unique identifier of the sprite.<br/>
--- @param x number The x-coordinate.<br/>
--- @param y number The y-coordinate.<br/>
--- @param[opt] colorkey number The color index for transparency.<br/>
--- @param[opt] scale number The scaling factor.<br/>
--- @param[opt] flip_x number Set to 1 to flip horizontally.<br/>
--- @param[opt] flip_y number Set to 1 to flip vertically.<br/>
--- @param[opt] rot number The rotation in degrees.<br/>
function Sprite.draw_at(id, x, y, colorkey, scale, flip_x, flip_y, rot)
local sprite_data = _sprites[id]
if not sprite_data then
trace("Error: Attempted to draw non-registered sprite with id: " .. id)
return
end
draw_sprite_instance(sprite_data, {
x = x,
y = y,
colorkey = colorkey,
scale = scale,
flip_x = flip_x,
flip_y = flip_y,
rot = rot,
})
end
--- Draws all scheduled sprites. --- Draws all scheduled sprites.
--- @within Sprite --- @within Sprite
function Sprite.draw() function Sprite.draw()
@@ -68,29 +122,8 @@ function Sprite.draw()
trace("Error: Sprite id " .. id .. " in _active_sprites is not registered.") trace("Error: Sprite id " .. id .. " in _active_sprites is not registered.")
_active_sprites[id] = nil _active_sprites[id] = nil
end end
if sprite_data then
local colorkey = params.colorkey or sprite_data.colorkey or 0 draw_sprite_instance(sprite_data, params)
local scale = params.scale or sprite_data.scale or 1
local flip_x = params.flip_x or sprite_data.flip_x or 0
local flip_y = params.flip_y or sprite_data.flip_y or 0
local rot = params.rot or sprite_data.rot or 0
if sprite_data.sprites then
for i = 1, #sprite_data.sprites do
local sub_sprite = sprite_data.sprites[i]
spr(
sub_sprite.s,
params.x + (sub_sprite.x_offset or 0),
params.y + (sub_sprite.y_offset or 0),
sub_sprite.colorkey or colorkey,
sub_sprite.scale or scale,
sub_sprite.flip_x or flip_x,
sub_sprite.flip_y or flip_y,
sub_sprite.rot or rot
)
end
else
spr(sprite_data.s, params.x, params.y, colorkey, scale, flip_x, flip_y, rot)
end end
end end
end end

View File

@@ -1,17 +1,23 @@
Sprite.register({ Sprite.register({
id = "norman", id = "norman",
sprites = { sprites = {
-- Body (sprite index 0) { s = 272, x_offset = -4, y_offset = -4 },
{ s = 0, x_offset = 0, y_offset = 0 }, { s = 273, x_offset = 4, y_offset = -4 },
-- Head (sprite index 1) { s = 288, x_offset = -4, y_offset = 4 },
{ s = 1, x_offset = 0, y_offset = -8 }, { s = 289, x_offset = 4, y_offset = 4 },
-- Left Arm (sprite index 2) { s = 304, x_offset = -4, y_offset = 12 },
{ s = 2, x_offset = -4, y_offset = 4 }, { s = 305, x_offset = 4, y_offset = 12 }
-- Right Arm (sprite index 3, flipped) }
{ s = 3, x_offset = 4, y_offset = 4, flip_x = 1 }, -- Flipped arm })
-- Left Leg (sprite index 4)
{ s = 4, x_offset = -2, y_offset = 8 }, Sprite.register({
-- Right Leg (sprite index 5, flipped) id = "sleeping_norman",
{ s = 5, x_offset = 2, y_offset = 8, flip_x = 1 } -- Flipped leg sprites = {
{ s = 272, x_offset = 12, y_offset = -4, flip_y = 1 },
{ s = 273, x_offset = 12, y_offset = 4, flip_y = 1 },
{ s = 288, x_offset = 4, y_offset = -4, flip_y = 1 },
{ s = 289, x_offset = 4, y_offset = 4, flip_y = 1 },
{ s = 304, x_offset = -4, y_offset = -4, flip_y = 1 },
{ s = 305, x_offset = -4, y_offset = 4, flip_y = 1 }
} }
}) })

View File

@@ -2,9 +2,7 @@
local _available_decisions = {} local _available_decisions = {}
local _selected_decision_index = 1 local _selected_decision_index = 1
--- Draws the game window. local function draw_game_scene(underlay_draw)
--- @within GameWindow
function GameWindow.draw()
local screen = Screen.get_by_id(Context.game.current_screen) local screen = Screen.get_by_id(Context.game.current_screen)
if not screen then return end if not screen then return end
if screen.background then if screen.background then
@@ -12,6 +10,9 @@ function GameWindow.draw()
elseif screen.background_color then elseif screen.background_color then
rect(0, 0, Config.screen.width, Config.screen.height, screen.background_color) rect(0, 0, Config.screen.width, Config.screen.height, screen.background_color)
end end
if underlay_draw then
underlay_draw()
end
if not Context.stat_screen_active and #_available_decisions > 0 then if not Context.stat_screen_active and #_available_decisions > 0 then
Decision.draw(_available_decisions, _selected_decision_index) Decision.draw(_available_decisions, _selected_decision_index)
end end
@@ -20,6 +21,19 @@ function GameWindow.draw()
screen.draw() screen.draw()
end end
--- Draws the game window.
--- @within GameWindow
function GameWindow.draw()
draw_game_scene()
end
--- Draws the game window with a custom underlay.
--- @within GameWindow
--- @param underlay_draw function A draw callback rendered after the background but before overlays.<br/>
function GameWindow.draw_with_underlay(underlay_draw)
draw_game_scene(underlay_draw)
end
--- Updates the game window logic. --- Updates the game window logic.
--- @within GameWindow --- @within GameWindow
function GameWindow.update() function GameWindow.update()
@@ -31,12 +45,13 @@ function GameWindow.update()
end end
local screen = Screen.get_by_id(Context.game.current_screen) local screen = Screen.get_by_id(Context.game.current_screen)
if not screen or not screen.update then return end
screen.update() screen.update()
-- Handle current situation updates -- Handle current situation updates
if Context.game.current_situation then if Context.game.current_situation then
local current_situation_obj = Situation.get_by_id(Context.game.current_situation) local current_situation_obj = Situation.get_by_id(Context.game.current_situation)
if current_situation_obj and current_situation_obj.update then if current_situation_obj and type(current_situation_obj.update) == "function" then
current_situation_obj.update() current_situation_obj.update()
end end
end end

View File

@@ -26,7 +26,6 @@ end
--- @within MenuWindow --- @within MenuWindow
function MenuWindow.new_game() function MenuWindow.new_game()
Context.new_game() Context.new_game()
GameWindow.set_state("game")
end end
--- Loads a game from the menu. --- Loads a game from the menu.

View File

@@ -4,12 +4,14 @@
function MinigameButtonMashWindow.init_context() function MinigameButtonMashWindow.init_context()
return { return {
bar_fill = 0, bar_fill = 0,
max_fill = 100, target_points = 100,
fill_per_press = 8, fill_per_press = 8,
base_degradation = 0.15, base_degradation = 0.15,
degradation_multiplier = 0.006, degradation_multiplier = 0.006,
button_pressed_timer = 0, button_pressed_timer = 0,
button_press_duration = 8, button_press_duration = 8,
instruction_text = "MASH Z!",
show_progress_text = true,
return_window = nil, return_window = nil,
bar_x = 20, bar_x = 20,
bar_y = 10, bar_y = 10,
@@ -35,6 +37,9 @@ function MinigameButtonMashWindow.init(params)
for k, v in pairs(params) do for k, v in pairs(params) do
defaults[k] = v defaults[k] = v
end end
if params.max_fill and not params.target_points then
defaults.target_points = params.max_fill
end
end end
Context.minigame_button_mash = defaults Context.minigame_button_mash = defaults
end end
@@ -78,11 +83,11 @@ function MinigameButtonMashWindow.update()
if Input.select() then if Input.select() then
mg.bar_fill = mg.bar_fill + mg.fill_per_press mg.bar_fill = mg.bar_fill + mg.fill_per_press
mg.button_pressed_timer = mg.button_press_duration mg.button_pressed_timer = mg.button_press_duration
if mg.bar_fill > mg.max_fill then if mg.bar_fill > mg.target_points then
mg.bar_fill = mg.max_fill mg.bar_fill = mg.target_points
end end
end end
if mg.bar_fill >= mg.max_fill then if mg.bar_fill >= mg.target_points then
mg.win_timer = Config.timing.minigame_win_duration mg.win_timer = Config.timing.minigame_win_duration
return return
end end
@@ -95,7 +100,7 @@ function MinigameButtonMashWindow.update()
mg.button_pressed_timer = mg.button_pressed_timer - 1 mg.button_pressed_timer = mg.button_pressed_timer - 1
end end
if mg.focus_center_x then if mg.focus_center_x then
Focus.set_percentage(mg.bar_fill / mg.max_fill) Focus.set_percentage(mg.bar_fill / mg.target_points)
end end
end end
@@ -104,14 +109,16 @@ end
function MinigameButtonMashWindow.draw() function MinigameButtonMashWindow.draw()
local mg = Context.minigame_button_mash local mg = Context.minigame_button_mash
if mg.return_window == "game" then if mg.return_window == "game" then
GameWindow.draw() GameWindow.draw_with_underlay(function()
Sprite.draw_at("sleeping_norman", (Config.screen.width / 2) - 30, (Config.screen.height / 2) - 22)
end)
end end
if not mg.focus_center_x then if not mg.focus_center_x then
rect(0, 0, Config.screen.width, Config.screen.height, Config.colors.black) rect(0, 0, Config.screen.width, Config.screen.height, Config.colors.black)
end end
rect(mg.bar_x - 2, mg.bar_y - 2, mg.bar_width + 4, mg.bar_height + 4, Config.colors.light_grey) rect(mg.bar_x - 2, mg.bar_y - 2, mg.bar_width + 4, mg.bar_height + 4, Config.colors.light_grey)
rectb(mg.bar_x - 2, mg.bar_y - 2, mg.bar_width + 4, mg.bar_height + 4, Config.colors.dark_grey) rectb(mg.bar_x - 2, mg.bar_y - 2, mg.bar_width + 4, mg.bar_height + 4, Config.colors.dark_grey)
local fill_width = (mg.bar_fill / mg.max_fill) * mg.bar_width local fill_width = (mg.bar_fill / mg.target_points) * mg.bar_width
if fill_width > 0 then if fill_width > 0 then
local bar_color = Config.colors.light_blue local bar_color = Config.colors.light_blue
if mg.bar_fill > 66 then if mg.bar_fill > 66 then
@@ -130,9 +137,11 @@ function MinigameButtonMashWindow.draw()
circ(mg.button_x, mg.button_y, mg.button_size - 2, button_color) circ(mg.button_x, mg.button_y, mg.button_size - 2, button_color)
end end
Print.text_center("Z", mg.button_x, mg.button_y - 3, button_color) 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) Print.text_center(mg.instruction_text, 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) if mg.show_progress_text then
Print.text_center(percentage .. "%", mg.bar_x + mg.bar_width / 2, mg.bar_y + 2, Config.colors.black) local points_text = math.floor(mg.bar_fill) .. "/" .. mg.target_points
Print.text_center(points_text, mg.bar_x + mg.bar_width / 2, mg.bar_y + 2, Config.colors.black)
end
if mg.win_timer > 0 then if mg.win_timer > 0 then
Minigame.draw_win_overlay() Minigame.draw_win_overlay()

View File

@@ -130,7 +130,9 @@ end
function MinigameRhythmWindow.draw() function MinigameRhythmWindow.draw()
local mg = Context.minigame_rhythm local mg = Context.minigame_rhythm
if mg.return_window == "game" then if mg.return_window == "game" then
GameWindow.draw() GameWindow.draw_with_underlay(function()
Sprite.draw_at("sleeping_norman", (Config.screen.width / 2) - 30, (Config.screen.height / 2) - 22)
end)
end end
if not mg.focus_center_x then if not mg.focus_center_x then
rect(0, 0, Config.screen.width, Config.screen.height, Config.colors.black) rect(0, 0, Config.screen.width, Config.screen.height, Config.colors.black)
@@ -144,12 +146,10 @@ function MinigameRhythmWindow.draw()
rect(target_x, mg.bar_y, target_width_pixels, mg.bar_height, Config.colors.light_blue) rect(target_x, mg.bar_y, target_width_pixels, mg.bar_height, Config.colors.light_blue)
local line_x = mg.bar_x + (mg.line_position * mg.bar_width) local line_x = mg.bar_x + (mg.line_position * mg.bar_width)
rect(line_x - 1, mg.bar_y, 2, mg.bar_height, Config.colors.item) rect(line_x - 1, mg.bar_y, 2, mg.bar_height, Config.colors.item)
local score_text = "SCORE: " .. mg.score .. " / " .. mg.max_score
Print.text_center(score_text, Config.screen.width / 2, mg.bar_y + mg.bar_height + 8, Config.colors.light_grey)
Print.text_center( Print.text_center(
"Press Z when line is in green!", "Sleep Norman ... Sleep!",
Config.screen.width / 2, Config.screen.width / 2,
mg.bar_y + mg.bar_height + 20, mg.bar_y + mg.bar_height + 14,
Config.colors.light_grey Config.colors.light_grey
) )
local button_color = Config.colors.light_grey local button_color = Config.colors.light_grey

View File

@@ -4,12 +4,25 @@ local STATE_TEXT = "text"
local STATE_DAY = "day" local STATE_DAY = "day"
local STATE_CHOICE = "choice" local STATE_CHOICE = "choice"
local DEFAULT_TEXT = [[
Misterious man appears
during your sleep.
He says nothing.
He doesn't need to.
He says nothing.
]]
local state = STATE_TEXT local state = STATE_TEXT
local text_y = Config.screen.height local text_y = Config.screen.height
local text_speed = 0.4 local text_speed = 0.4
local day_timer = 0 local day_timer = 0
local day_display_frames = 120 local day_display_frames = 120
local selected_choice = 1 local selected_choice = 1
local text = DEFAULT_TEXT
local day_text_override = nil
local on_text_complete = nil
local choices = { local choices = {
{ {
@@ -20,16 +33,6 @@ local choices = {
}, },
} }
local text = [[
Misterious man appears
during your sleep.
He says nothing.
He doesn't need to.
He says nothing.
]]
--- Sets the scrolling text content. --- Sets the scrolling text content.
--- @within MysteriousManWindow --- @within MysteriousManWindow
--- @param new_text string The text to display. --- @param new_text string The text to display.
@@ -39,22 +42,49 @@ end
--- Starts the mysterious man window. --- Starts the mysterious man window.
--- @within MysteriousManWindow --- @within MysteriousManWindow
function MysteriousManWindow.start() --- @param[opt] options table Optional window configuration.</br>
--- Fields: </br>
--- * text (string) Override for the scrolling text.<br/>
--- * day_text (string) Override for the centered day label.<br/>
--- * on_text_complete (function) Callback fired once when the text phase ends.<br/>
function MysteriousManWindow.start(options)
options = options or {}
state = STATE_TEXT state = STATE_TEXT
text_y = Config.screen.height text_y = Config.screen.height
day_timer = 0 day_timer = 0
selected_choice = 1 selected_choice = 1
text = options.text or DEFAULT_TEXT
day_text_override = options.day_text
on_text_complete = options.on_text_complete
Meter.hide() Meter.hide()
Window.set_current("mysterious_man") Window.set_current("mysterious_man")
end end
local function go_to_day_state()
if on_text_complete then
on_text_complete()
on_text_complete = nil
end
if Window.get_current_id() ~= "mysterious_man" then
return
end
state = STATE_DAY
day_timer = day_display_frames
end
local function wake_up() local function wake_up()
Context.home_norman_visible = false
Util.go_to_screen_by_id("home") Util.go_to_screen_by_id("home")
MinigameButtonMashWindow.start("game", { MinigameButtonMashWindow.start("game", {
focus_center_x = Config.screen.width / 2, focus_center_x = (Config.screen.width / 2) - 22,
focus_center_y = Config.screen.height / 2, focus_center_y = (Config.screen.height / 2) - 18,
focus_initial_radius = 0, focus_initial_radius = 0,
target_points = 100,
instruction_text = "Wake up Norman!",
show_progress_text = false,
on_win = function() on_win = function()
Audio.music_play_wakingup()
Context.home_norman_visible = true
Meter.show() Meter.show()
Window.set_current("game") Window.set_current("game")
end, end,
@@ -79,13 +109,11 @@ function MysteriousManWindow.update()
end end
if text_y < -lines * 8 then if text_y < -lines * 8 then
state = STATE_DAY go_to_day_state()
day_timer = day_display_frames
end end
if Input.select() then if Input.select() then
state = STATE_DAY go_to_day_state()
day_timer = day_display_frames
end end
elseif state == STATE_DAY then elseif state == STATE_DAY then
day_timer = day_timer - 1 day_timer = day_timer - 1
@@ -117,7 +145,7 @@ function MysteriousManWindow.draw()
local x = (Config.screen.width - 132) / 2 local x = (Config.screen.width - 132) / 2
Print.text(text, x, text_y, Config.colors.light_grey) Print.text(text, x, text_y, Config.colors.light_grey)
elseif state == STATE_DAY then elseif state == STATE_DAY then
local day_text = "Day " .. Context.day_count local day_text = day_text_override or ("Day " .. Context.day_count)
Print.text_center( Print.text_center(
day_text, day_text,
Config.screen.width / 2, Config.screen.width / 2,