12345678Build a ball pit · Step 3 of 8
Make it interactive
You'll learn
- Build buttons and labels
- React to clicks and keys
- Color widgets with theme tokens
- Mount a canvas into a layout
- Time
- 15 min
- Builds on
- Step 2: Draw and animate
- File
pit.lua
Your ball pit gets controls: buttons to add balls and to clear them, a counter, and a key that adds a ball. Keeping track of many balls takes a table.
Lay out the window
Until now the canvas has had the window to itself. To put a toolbar above
it, build a small layout out of panels. A panel arranges what it holds in
a row or in a column, with gap between them. Change pit.lua to this:
local color = datumhue.color
New line: local theme = datumhue.theme
local vec2 = datumhue.math.vec2
local W, H, R = 480, 320, 12
New line: local screen = datumhue.ui.root:panel({
New line: width = "100%",
New line: height = "100%",
New line: flex_direction = "column",
New line: justify_content = "center",
New line: align_items = "center",
New line: gap = 12,
New line: })
New line:
New line: local toolbar = screen:panel({
New line: flex_direction = "row",
New line: align_items = "center",
New line: gap = 8,
New line: })
New line: local add = toolbar:button({ text = "Add ball" })
New line: local clear = toolbar:button({ text = "Clear" })
New line: local count = toolbar:label({
New line: text = "Balls: 1",
New line: color = theme.token("accent_primary"),
New line: })
New line:
local canvas = datumhue.draw.new({
width = W,
height = H,
background = color("#232a2e"),
})
New line: canvas:mount({ parent = screen, width = W, height = H })
local ball = canvas:circle({
pos = vec2(0, 0),
radius = R,
color = color("#e67e80"),
})
local pos = vec2(0, 0)
local vel = vec2(160, 120)
local edge = vec2(W / 2 - R, H / 2 - R)
datumhue.on_update(function(dt)
pos = pos + vel * dt
if math.abs(pos.x) > edge.x then
vel = vel:with_x(-vel.x)
end
if math.abs(pos.y) > edge.y then
vel = vel:with_y(-vel.y)
end
pos = pos:clamp(-edge, edge)
ball.pos = pos
end)
In this example: datumhue.color datumhue.theme datumhue.math.vec2 datumhue.ui.root UiElement:panel UiElement:button UiElement:label datumhue.theme.token datumhue.draw.new DrawCanvas:mount DrawCanvas:circle datumhue.on_update Vec2.x Vec2.y DrawPrimitive.pos
screenfills the window and stacks what it holds in a column, centered.toolbaris a row inside it, with two buttons and a label. The buttons do nothing yet.theme.token("accent_primary")colors the label with the theme's accent color. A token names a role, not a color, so the label follows the theme when someone switches between light and dark.parent = screenmounts the canvas insidescreen, below the toolbar, instead of on its own.
The buttons and the label take their colors from the theme; the canvas keeps the colors you gave it. That is the rule for the rest of the tutorial: interface colors come from the theme, and your art keeps its own.
Add balls with a click
One ball needed three variables. Many balls need a table, with one record per ball holding its shape, its position and its speed. Two functions keep that table and the counter in step: one adds a ball, the other clears them all. The buttons and a key call them:
local color = datumhue.color
local theme = datumhue.theme
local vec2 = datumhue.math.vec2
local W, H, R = 480, 320, 12
New line: local COLORS = { "#e67e80", "#dbbc7f", "#7fbbb3" }
local screen = datumhue.ui.root:panel({
width = "100%",
height = "100%",
flex_direction = "column",
justify_content = "center",
align_items = "center",
gap = 12,
})
local toolbar = screen:panel({
flex_direction = "row",
align_items = "center",
gap = 8,
})
local add = toolbar:button({ text = "Add ball" })
local clear = toolbar:button({ text = "Clear" })
local count = toolbar:label({
New line: text = "Balls: 0",
color = theme.token("accent_primary"),
})
local canvas = datumhue.draw.new({
width = W,
height = H,
background = color("#232a2e"),
})
canvas:mount({ parent = screen, width = W, height = H })
local edge = vec2(W / 2 - R, H / 2 - R)
New line: local balls = {}
New line: local function add_ball()
New line: local angle = math.random() * 2 * math.pi
New line: local shape = canvas:circle({
New line: radius = R,
New line: color = color(COLORS[#balls % #COLORS + 1]),
New line: })
New line: table.insert(balls, {
New line: shape = shape,
New line: pos = vec2(0, 0),
New line: vel = vec2(math.cos(angle), math.sin(angle)) * 200,
New line: })
New line: count.text = "Balls: " .. #balls
New line: end
New line:
New line: local function clear_balls()
New line: for _, ball in ipairs(balls) do
New line: ball.shape:remove()
end
New line: balls = {}
New line: count.text = "Balls: 0"
New line: end
New line:
New line: add:on_click(add_ball)
New line: clear:on_click(clear_balls)
New line:
New line: datumhue.input.on_key(function(key, pressed)
New line: if key == "a" and pressed then
New line: add_ball()
end
end)
New line:
New line: datumhue.on_update(function(dt)
New line: for _, ball in ipairs(balls) do
New line: local pos = ball.pos + ball.vel * dt
New line: if math.abs(pos.x) > edge.x then
New line: ball.vel = ball.vel:with_x(-ball.vel.x)
New line: end
New line: if math.abs(pos.y) > edge.y then
New line: ball.vel = ball.vel:with_y(-ball.vel.y)
New line: end
New line: ball.pos = pos:clamp(-edge, edge)
New line: ball.shape.pos = ball.pos
New line: end
New line: end)
New line:
New line: for _ = 1, 10 do
New line: add_ball()
New line: end
In this example: datumhue.color datumhue.theme datumhue.math.vec2 datumhue.ui.root UiElement:panel UiElement:button UiElement:label datumhue.theme.token datumhue.draw.new DrawCanvas:mount DrawCanvas:circle UiElement.text UiElement:on_click datumhue.input.on_key datumhue.on_update Vec2.x Vec2.y
add_balldraws a circle at the center, sends it off in a random direction at 200 pixels per second, adds its record toballsand updates the counter.- The color cycles through
COLORS:#balls % #COLORS + 1counts 1, 2, 3, 1, 2, and so on. clear_ballstakes each shape off the canvas withDrawPrimitive:remove, then empties the table.UiElement:on_clickgives a button the function to run when it is clicked.datumhue.input.on_keyruns a function for every key pressed or released while your app has focus, with the key's name and whether it went down.- The update loop from step 2 now moves every ball in the table, and the loop at the end starts the pit with ten.
Click Add ball, press A, then click Clear. The counter follows each one.
Try this
- Add a Faster button that doubles every ball's speed.
- Press C to clear the pit, as A adds a ball.
- Start each new ball at a random place instead of the center.
Recap
- A panel lays out what it holds in a row or a column,
gapapart. - A button runs a function when clicked;
input.on_keyhears the keyboard. - Theme tokens color your interface so it follows the theme. Canvas art keeps its own colors.
- Keep many things in a table, and loop over it every frame.
New in this step