Love2D Game Progress

More Love2D learning and exploration. No actual game yet, but I made some cool buttons!

Love2D Game Progress

I spent the last week doing more proof-of-concept work for my game.

I still don't have a concrete plan for the gameplay, but I'm hoping that having an idea of what I could implement will give me ideas. I don't want to plan to create something that is hard to implement lol.

So for now I'm just building simple UI components that I might use like buttons, scrollable panels, etc.

The first thing I did was rewrite/enhance my code from Love2D Game Setup to render fancier buttons with colors and fonts. I removed the status updates for now.

I also made a layout of panels:

  • The top-left panel will display status
  • The bottom-left panel will display the "world", like the player character and their environment
  • The right panel will display actions organized in sub-menus (only one menu for now)

This will probably change, but just starting with something.

Then I implemented text boxes as a separate component from buttons. These support text wrapping and dynamic height.

At this point, I felt it was time to look at libraries to make building UI components easier and more structured. I sort of had a structure, but there was a lot of common logic that I would probably have to copy to each component. I'm sure there was a library that already does that for me. (I could certainly write my own, but I don't think I have any business writing one right now when I'm still figuring out how Love2D works.)

I looked at most of the libraries listed in the Awesome Love2D: UI collection of resources. Some of the ones I looked at and considered:

And ended up going with Inky. For now at least. Why did I choose Inky? It doesn't come with any pre-built UI components (i.e. widgets). It's React-like, so it's not a huge learning curve for me. The event system seems simple to use as well, which I would use for status updates. And components have internal awareness of pointer collisions (although it can be overridden).

So I refactored everything I wrote so far to use Inky.

In the process, I struggled with handling components with dynamic height in the case of text-wrapping. I'll come back to this later when I actually need it. For now, I can just make my elements tall enough to display 1-2 lines of text; if text is longer, it will display outside of the allocated area and be excluded from pointer collision.

After refactoring was done, I made the actions panel scrollable (by dragging the panel up and down or using the mouse wheel).

Not really shown in the demo, but you could scroll down infinitely, which was not desirable. So I added logic to limit the scroll amount.

Finally, I added back status update logic, with more complexity than before.

Currently there is only one resource "Energy" which depletes at 1 unit/sec. You can eat and sleep to replenish energy. Eat has no cooldown. Sleep has a 5-sec cooldown, indicated by the button background. Bake uses up energy and also has a 5-sec cooldown; if the player doesn't have enough energy, the text is crossed out and clicking does nothing.

Just to give an idea of how the code is structured right now, I have a top-level Game component that contains all the game data and UI components. This includes an ActionsPanel component, which includes ActionsPanelItem components.

Game.lua
local Inky                = require("lib.inky")
local events              = require("src.ui.events")
local ActionsPanel        = require("src.ui.panels.ActionsPanel")
local StatusPanel         = require("src.ui.panels.StatusPanel")
local WorldPanel          = require("src.ui.panels.WorldPanel")

local UPDATE_DEPENDENCIES = {
    [events.UPDATED_GAME] = {},
    [events.UPDATED_PLAYER] = { events.UPDATED_GAME },
    [events.UPDATED_PLAYER_ENERGY] = { events.UPDATED_PLAYER, events.UPDATED_GAME }
}

local Game                = Inky.defineElement(function(self, scene)
    self.props.running = true
    self.props.energyLossCooldownTimer = 1

    self.props.player_energy_current = 50
    self.props.player_energy_max = 50

    local actionsPanel = ActionsPanel(scene)
    local statusPanel = StatusPanel(scene)
    statusPanel.props.initial = {
        player_energy_current = self.props.player_energy_current,
        player_energy_max = self.props.player_energy_max,
    }
    local worldPanel = WorldPanel(scene)

    self:useEffect(function()
        scene:raise(events.UPDATED_PLAYER_ENERGY, self.props)
        for _, event in ipairs(UPDATE_DEPENDENCIES[events.UPDATED_PLAYER_ENERGY]) do
            scene:raise(event, self.props)
        end
    end, "player_energy_current", "player_energy_max")

    self:on(events.TIME_ELAPSED, function(_, duration)
        if self.props.running then
            self.props.energyLossCooldownTimer = self.props.energyLossCooldownTimer - duration
            if self.props.energyLossCooldownTimer <= 0 and self.props.player_energy_current > 0 then
                self.props.player_energy_current = self.props.player_energy_current - 1
                self.props.energyLossCooldownTimer = self.props.energyLossCooldownTimer + 1
            end
        end
    end)

    self:on(events.EXECUTE_ACTION, function(_, callback)
        if callback ~= nil then
            callback(self.props)
        end
    end)

    return function(_, x, y, w, h, depth)
        actionsPanel:render(640, 0, 640, 720)
        statusPanel:render(0, 0, 640, 120)
        worldPanel:render(0, 120, 640, 600)
    end
end)

return Game
ActionsPanel.lua
local Inky               = require("lib.inky")
local actions            = require("src.data.actions")
local colors             = require("src.ui.colors")
local fonts              = require("src.ui.fonts")
local Box                = require("src.ui.common.Box")
local TextBox            = require("src.ui.common.TextBox")
local ActionsPanelItem   = require("src.ui.panels.ActionsPanelItem")

local ActionItems        = actions.ActionItems
local ActionItemImpls    = actions.ActionItemImpls
local ActionSections     = actions.ActionSections
local ActionSectionImpls = actions.ActionSectionImpls

local PANEL_PADDING      = 10
local HEADER_PADDING     = 20
local ITEM_HEIGHT        = 48
local ITEM_MARGIN        = 10

local MENUS              = {
    [ActionSections.HOME] = {
        ActionItems.HOME_EAT,
        ActionItems.HOME_SLEEP,
        ActionItems.HOME_BAKE,
    }
}

local ActionsPanel       = Inky.defineElement(function(self, scene)
    self.props.section = ActionSections.HOME
    self.props.viewY = 0

    local sectionLabel = ActionSectionImpls[self.props.section].label
    local sectionItems = MENUS[self.props.section]

    local panel = Box(scene)
    panel.props.fillColor = colors.ACTIONS_PANEL_BG

    local header = TextBox(scene)
    header.props.align = "center"
    header.props.color = colors.ACTIONS_PANEL_TEXT
    header.props.font = fonts.HeaderFont
    header.props.text = sectionLabel

    local items = {}
    for _, itemId in ipairs(sectionItems) do
        local item = ActionsPanelItem(scene)
        item.props.impl = ActionItemImpls[itemId]
        table.insert(items, item)
    end

    local headerHeight = header.props.font:getHeight()
    local itemsHeight = #items * (ITEM_MARGIN + ITEM_HEIGHT)
    local totalHeight = headerHeight + (2 * HEADER_PADDING) + itemsHeight

    local updateViewY = function(dy)
        local _, _, _, viewHeight = self:getView()
        if totalHeight > viewHeight then
            local viewMaxY = totalHeight - viewHeight
            self.props.viewY = math.min(viewMaxY, math.max(0, self.props.viewY - dy))
        end
    end

    self:onPointer("drag", function(_, pointer, dx, dy)
        updateViewY(dy)
    end)

    self:onPointer("scroll", function(_, pointer, x, y)
        updateViewY(y * 40)
    end)

    return function(_, x, y, w, h, depth)
        local contentY = y - self.props.viewY

        do -- Panel
            panel:render(x, y, w, h)
        end

        do -- Header
            local headerWidth = w - (2 * PANEL_PADDING)
            header:render(x + PANEL_PADDING, (contentY + HEADER_PADDING), headerWidth, headerHeight)
        end

        do -- Items
            local itemY = contentY + headerHeight + (2 * HEADER_PADDING)
            local itemWidth = w - (2 * PANEL_PADDING)
            for _, item in ipairs(items) do
                item:render(x + PANEL_PADDING, itemY, itemWidth, ITEM_HEIGHT)
                itemY = itemY + ITEM_HEIGHT + ITEM_MARGIN
            end
        end
    end
end)

return ActionsPanel
ActionsPanelItem.lua
local Inky             = require("lib.inky")
local colors           = require("src.ui.colors")
local events           = require("src.ui.events")
local fonts            = require("src.ui.fonts")
local Box              = require("src.ui.common.Box")
local TextBox          = require("src.ui.common.TextBox")

local PADDING          = 10
local BORDER           = 2

local ActionsPanelItem = Inky.defineElement(function(self, scene)
    self.props.focused = false
    self.props.enabled = true
    self.props.cooldownTimer = 0

    local box = Box(scene)
    box.props.fillColor = colors.ACTIONS_ITEM_FILL
    box.props.lineColor = colors.ACTIONS_ITEM_LINE
    box.props.lineWidth = BORDER

    local cooldownBox = Box(scene)
    cooldownBox.props.fillColor = colors.ACTIONS_ITEM_FILL_FOCUS

    local textBox = TextBox(scene)
    textBox.props.align = "left"
    textBox.props.color = colors.ACTIONS_PANEL_TEXT
    textBox.props.font = fonts.NormalFont
    textBox.props.text = self.props.impl.label

    self:useEffect(function()
        if self.props.enabled then
            textBox.props.strikethrough = false
        else
            textBox.props.strikethrough = true
        end
    end, "enabled")

    self:on(events.UPDATED_GAME, function(_, gameProps)
        self.props.enabled = self.props.impl.enabled(gameProps)
    end)

    self:on(events.TIME_ELAPSED, function(_, duration)
        if self.props.cooldownTimer > 0 then
            self.props.cooldownTimer = math.max(0, self.props.cooldownTimer - duration)
        end
    end)

    self:onPointer("press", function()
        if self.props.enabled and self.props.cooldownTimer == 0 then
            self.props.focused = true
            box.props.fillColor = colors.ACTIONS_ITEM_FILL_FOCUS
        end
    end)

    self:onPointer("release", function()
        if self.props.focused then
            scene:raise(events.EXECUTE_ACTION, self.props.impl.execute)
            self.props.cooldownTimer = self.props.impl.cooldown
            self.props.focused = false
            box.props.fillColor = colors.ACTIONS_ITEM_FILL
        end
    end)

    self:onPointerExit(function()
        if self.props.focused then
            self.props.focused = false
            box.props.fillColor = colors.ACTIONS_ITEM_FILL
        end
    end)

    return function(_, x, y, w, h, depth)
        do -- Box
            box:render(x, y, w, h)
        end

        do --Cooldown Box
            if self.props.cooldownTimer > 0 then
                local boxWidth = (w - (2 * BORDER)) * (self.props.cooldownTimer / self.props.impl.cooldown)
                cooldownBox:render(x + BORDER, y + BORDER, boxWidth, h - (2 * BORDER))
            end
        end

        do -- Text
            textBox:render(x + PADDING, y, w - (2 * PADDING), h)
        end
    end
end)

return ActionsPanelItem

The actual logic for actions to check or update status is separate from the UI component files. Currently, enabled and execute are the only expected callbacks that should be implemented. They accept the entire gameProps dict so they can check or update any game data (handle with caution lol). And updates will automatically trigger redraw based on how Inky works.

If I pass in a separate dict which is a subset of actually mutable data, I would have to manually propagate those updates to the Game props. Which I could do. But I feel like I don't need that level of abstraction. This ain't enterprise code and I'm the only developer haha.

actions.lua
local labels = require("src.data.labels")

local ActionSections = {
    HOME = "HOME"
}

local ActionSectionImpls = {
    [ActionSections.HOME] = {
        label = labels.action.section.home
    }
}

local ActionItems = {
    HOME_EAT = "HOME_EAT",
    HOME_SLEEP = "HOME_SLEEP",
    HOME_BAKE = "HOME_BAKE",
}

local ActionItemImpls = {
    [ActionItems.HOME_EAT] = {
        label = labels.action.item.home_eat,
        cooldown = 0,
        enabled = function(gameProps)
            return gameProps.player_energy_current < gameProps.player_energy_max
        end,
        execute = function(gameProps)
            gameProps.player_energy_current = math.min(
                gameProps.player_energy_max,
                gameProps.player_energy_current + 1)
        end,
    },
    [ActionItems.HOME_SLEEP] = {
        label = labels.action.item.home_sleep,
        cooldown = 5,
        enabled = function(gameProps)
            return gameProps.player_energy_current < gameProps.player_energy_max
        end,
        execute = function(gameProps)
            gameProps.player_energy_current = math.min(
                gameProps.player_energy_max,
                gameProps.player_energy_current + 10)
        end,
    },
    [ActionItems.HOME_BAKE] = {
        label = labels.action.item.home_bake,
        cooldown = 5,
        enabled = function(gameProps)
            return gameProps.player_energy_current >= 20
        end,
        execute = function(gameProps)
            gameProps.player_energy_current = math.min(
                gameProps.player_energy_max,
                gameProps.player_energy_current - 20)
        end,
    },
}

return {
    ActionItems = ActionItems,
    ActionItemImpls = ActionItemImpls,
    ActionSections = ActionSections,
    ActionSectionImpls = ActionSectionImpls,
}