Add Lua 5.4 scripting integration for dynamic game loading

- Integrated Lua 5.4 engine (32-bit mode for embedded ARM)
- Created LuaGame wrapper class implementing Game interface
- Added C++ bindings exposing renderer, game state, and input to Lua
- Implemented SD card loader for automatic .lua game discovery
- Updated GameLauncher to support std::function for lambda captures
- Made Game class members public for Lua bindings access
- Added example Lua games: counter, snake, bouncing ball
- Included comprehensive API documentation

Games can now be written as .lua text files on SD card and loaded
without recompilation. Build size: 747KB UF2, Lua VM uses ~50-80KB RAM.
This commit is contained in:
Adolfo Reyna
2026-02-07 11:56:03 -05:00
parent c8af4f6638
commit e6e4eca188
74 changed files with 29098 additions and 13 deletions

233
games/lua_examples/API.md Normal file
View File

@@ -0,0 +1,233 @@
# Lua Game API Documentation
This document describes the API available to Lua game scripts on the Pico game console.
## Script Structure
Every Lua game must define three functions:
```lua
function init()
-- Initialize game state
end
function update(event)
-- Process input and update game logic
-- Return true if redraw needed, false otherwise
return needs_redraw
end
function draw()
-- Render the game to screen
end
```
## Metadata Comments
Add metadata at the top of your script:
```lua
-- NAME: My Awesome Game
-- DESC: A fun game about squares
```
## Global Tables
### `game` - Game State and Info
- `game.width()` - Get display width in pixels
- `game.height()` - Get display height in pixels
- `game.exit()` - Request exit to game launcher
- `game.vars` - Table for persistent state variables
Example:
```lua
function init()
game.vars.score = 0
game.vars.player_x = 100
game.vars.state = 0
end
```
### `renderer` - Drawing Functions
All coordinates are in pixels. Color is boolean: `true` = black/on, `false` = white/off.
#### Basic Shapes
- `renderer.clear(white)` - Clear screen to white (true) or black (false)
- `renderer.pixel(x, y, on)` - Set single pixel
- `renderer.line(x0, y0, x1, y1, on, width)` - Draw line (width optional, default 1)
- `renderer.rect(x, y, w, h, on, filled)` - Draw rectangle (filled optional, default false)
- `renderer.circle(x, y, radius, on, filled)` - Draw circle (filled optional, default false)
- `renderer.triangle(x0, y0, x1, y1, x2, y2, on, filled)` - Draw triangle
#### Text
- `renderer.text(x, y, text, on)` - Draw text string
Example:
```lua
function draw()
renderer.clear(false) -- Clear to black
renderer.rect(50, 50, 100, 80, true, true) -- Filled white rectangle
renderer.circle(100, 100, 30, true, false) -- White circle outline
renderer.text(10, 10, "Score: " .. tostring(game.vars.score), true)
end
```
### `INPUT` - Input Event Types
Constants for checking `event.type`:
- `INPUT.NONE` - No input
- `INPUT.TOUCH_DOWN` - Touch/click started
- `INPUT.TOUCH_MOVE` - Touch/drag in progress
- `INPUT.TOUCH_UP` - Touch/click released
- `INPUT.BUTTON_0` - Physical button 0 pressed
- `INPUT.BUTTON_1` - Physical button 1 pressed
- `INPUT.GESTURE` - Gesture detected
## Input Event Structure
The `event` parameter passed to `update()` has these fields:
- `event.type` - Event type (see INPUT constants)
- `event.x` - X coordinate (for touch events)
- `event.y` - Y coordinate (for touch events)
- `event.button_id` - Button identifier (for button events)
- `event.valid` - Boolean, true if event is valid
Example:
```lua
function update(event)
if event.type == INPUT.TOUCH_DOWN then
print("Touch at: " .. event.x .. ", " .. event.y)
game.vars.last_touch_x = event.x
game.vars.last_touch_y = event.y
return true -- Request redraw
end
return false
end
```
## State Machine Pattern
Use `game.vars` to implement state machines:
```lua
local STATE_MENU = 0
local STATE_PLAYING = 1
local STATE_GAME_OVER = 2
function init()
game.vars.state = STATE_MENU
end
function update(event)
if game.vars.state == STATE_MENU then
-- Handle menu input
if event.type == INPUT.TOUCH_DOWN then
game.vars.state = STATE_PLAYING
return true
end
elseif game.vars.state == STATE_PLAYING then
-- Handle gameplay input
elseif game.vars.state == STATE_GAME_OVER then
-- Handle game over screen
end
return false
end
function draw()
if game.vars.state == STATE_MENU then
-- Draw menu
elseif game.vars.state == STATE_PLAYING then
-- Draw game
elseif game.vars.state == STATE_GAME_OVER then
-- Draw game over screen
end
end
```
## Standard Lua Libraries
Available libraries:
- `math` - Math functions (`math.sin`, `math.random`, etc.)
- `string` - String manipulation
- `table` - Table operations (`table.insert`, `table.remove`, etc.)
- `coroutine` - Coroutines for advanced flow control
**Not available** (embedded environment):
- `io` - File I/O (use SD card APIs in C++ if needed)
- `os` - Operating system functions
- `debug` - Debugging functions
## Tips and Best Practices
1. **Keep it Simple** - Lua is slower than C++. Keep game logic simple for smooth performance.
2. **Use Local Variables** - Local variables are faster than globals:
```lua
local function move_player() -- Local function
local speed = 5 -- Local variable
game.vars.player_x = game.vars.player_x + speed
end
```
3. **Minimize Allocations** - Reuse tables instead of creating new ones:
```lua
-- Good: Reuse existing table
game.vars.snake[1].x = new_x
-- Bad: Creates garbage
game.vars.snake[1] = {x = new_x, y = new_y}
```
4. **Efficient Drawing** - Only redraw when needed. Return `false` from `update()` if nothing changed.
5. **Frame Limiting** - For animation, use frame counters:
```lua
function update(event)
game.vars.frame = (game.vars.frame or 0) + 1
if game.vars.frame % 5 == 0 then -- Every 5 frames
-- Update animation
return true
end
return false
end
```
## Example: Complete Minimal Game
```lua
-- NAME: Click Counter
-- DESC: Count your clicks
function init()
game.vars.count = 0
end
function update(event)
if event.type == INPUT.TOUCH_DOWN then
game.vars.count = game.vars.count + 1
return true
end
return false
end
function draw()
renderer.clear(false)
local text = "Clicks: " .. tostring(game.vars.count)
renderer.text(20, 20, text, true)
end
```
## Deployment
1. Save your `.lua` file to SD card in `/games/` directory
2. Eject SD card and insert into Pico console
3. Power on - your game will appear in the launcher menu
4. Select and play!
No recompilation needed - edit scripts directly on SD card for rapid iteration.

View File

@@ -0,0 +1,81 @@
-- NAME: Bouncing Ball
-- DESC: Physics demo with state management
-- States
local STATE_PAUSED = 0
local STATE_RUNNING = 1
function init()
game.vars.state = STATE_PAUSED
game.vars.ball_x = game.width() / 2
game.vars.ball_y = game.height() / 2
game.vars.vel_x = 3
game.vars.vel_y = 2
game.vars.radius = 10
game.vars.frame_count = 0
print("Bouncing Ball initialized")
end
function update(event)
-- Toggle pause on tap
if event.type == INPUT.TOUCH_DOWN or event.type == INPUT.BUTTON_0 or event.type == INPUT.BUTTON_1 then
if game.vars.state == STATE_PAUSED then
game.vars.state = STATE_RUNNING
else
game.vars.state = STATE_PAUSED
end
return true
end
-- Update physics if running
if game.vars.state == STATE_RUNNING then
-- Move ball
game.vars.ball_x = game.vars.ball_x + game.vars.vel_x
game.vars.ball_y = game.vars.ball_y + game.vars.vel_y
-- Bounce off walls
if game.vars.ball_x - game.vars.radius < 0 or game.vars.ball_x + game.vars.radius > game.width() then
game.vars.vel_x = -game.vars.vel_x
game.vars.ball_x = math.max(game.vars.radius, math.min(game.width() - game.vars.radius, game.vars.ball_x))
end
if game.vars.ball_y - game.vars.radius < 0 or game.vars.ball_y + game.vars.radius > game.height() then
game.vars.vel_y = -game.vars.vel_y
game.vars.ball_y = math.max(game.vars.radius, math.min(game.height() - game.vars.radius, game.vars.ball_y))
end
game.vars.frame_count = game.vars.frame_count + 1
return true -- Always redraw when running
end
return false
end
function draw()
renderer.clear(false)
-- Draw ball
renderer.circle(game.vars.ball_x, game.vars.ball_y, game.vars.radius, true, true)
-- Draw trail (previous positions)
local trail_radius = game.vars.radius - 2
if trail_radius > 2 then
renderer.circle(game.vars.ball_x - game.vars.vel_x,
game.vars.ball_y - game.vars.vel_y,
trail_radius, true, false)
end
-- Draw status
if game.vars.state == STATE_PAUSED then
renderer.text(10, 10, "PAUSED - Tap to start", true)
else
renderer.text(10, 10, "Frames: " .. tostring(game.vars.frame_count), true)
renderer.text(10, 25, "Tap to pause", true)
end
-- Draw velocity vector
local arrow_x = game.vars.ball_x + game.vars.vel_x * 3
local arrow_y = game.vars.ball_y + game.vars.vel_y * 3
renderer.line(game.vars.ball_x, game.vars.ball_y, arrow_x, arrow_y, true, 2)
end

View File

@@ -0,0 +1,49 @@
-- NAME: Touch Counter
-- DESC: Simple tap counter demo
-- Initialize game state
function init()
game.vars.count = 0
game.vars.last_x = 0
game.vars.last_y = 0
print("Counter initialized")
end
-- Update game logic based on input
function update(event)
-- Check if touch/button pressed
if event.type == INPUT.TOUCH_DOWN or event.type == INPUT.BUTTON_0 or event.type == INPUT.BUTTON_1 then
game.vars.count = game.vars.count + 1
game.vars.last_x = event.x
game.vars.last_y = event.y
print("Count: " .. game.vars.count)
return true -- Request redraw
end
return false -- No redraw needed
end
-- Draw the game
function draw()
-- Clear screen
renderer.clear(true)
-- Draw title
renderer.text(20, 20, "Touch Counter", true)
-- Draw count (centered)
local count_text = "Count: " .. tostring(game.vars.count)
renderer.text(game.width() / 2 - 40, game.height() / 2 - 10, count_text, true)
-- Draw last touch position
if game.vars.count > 0 then
local pos_text = "Last: (" .. tostring(game.vars.last_x) .. ", " .. tostring(game.vars.last_y) .. ")"
renderer.text(20, game.height() - 30, pos_text, true)
-- Draw marker at last touch
renderer.circle(game.vars.last_x, game.vars.last_y, 5, true, false)
end
-- Draw instructions
renderer.text(20, 50, "Tap screen to increment", true)
end

View File

@@ -0,0 +1,216 @@
-- NAME: Snake Game
-- DESC: Classic snake with state machine
-- Game states
local STATE_MENU = 0
local STATE_PLAYING = 1
local STATE_GAME_OVER = 2
-- Game constants
local CELL_SIZE = 10
local GRID_W = 40
local GRID_H = 28
-- Initialize game
function init()
game.vars.state = STATE_MENU
game.vars.score = 0
game.vars.high_score = 0
-- Snake as array of {x, y} positions
game.vars.snake = {}
game.vars.snake[1] = {x = 20, y = 14}
game.vars.snake[2] = {x = 19, y = 14}
game.vars.snake[3] = {x = 18, y = 14}
game.vars.dir_x = 1
game.vars.dir_y = 0
game.vars.food_x = 30
game.vars.food_y = 14
game.vars.frame_count = 0
game.vars.move_speed = 10 -- Frames between moves
print("Snake Game initialized")
end
-- Update game logic
function update(event)
local state = game.vars.state
-- State: MENU
if state == STATE_MENU then
if event.type == INPUT.TOUCH_DOWN or event.type == INPUT.BUTTON_0 or event.type == INPUT.BUTTON_1 then
game.vars.state = STATE_PLAYING
game.vars.score = 0
init_snake()
spawn_food()
return true
end
-- State: PLAYING
elseif state == STATE_PLAYING then
-- Handle input for direction change
if event.type == INPUT.TOUCH_DOWN then
local head = game.vars.snake[1]
local head_screen_x = head.x * CELL_SIZE
local head_screen_y = head.y * CELL_SIZE
local dx = event.x - head_screen_x
local dy = event.y - head_screen_y
-- Change direction based on touch relative to head
if math.abs(dx) > math.abs(dy) then
if dx > 0 and game.vars.dir_x ~= -1 then
game.vars.dir_x = 1
game.vars.dir_y = 0
elseif dx < 0 and game.vars.dir_x ~= 1 then
game.vars.dir_x = -1
game.vars.dir_y = 0
end
else
if dy > 0 and game.vars.dir_y ~= -1 then
game.vars.dir_x = 0
game.vars.dir_y = 1
elseif dy < 0 and game.vars.dir_y ~= 1 then
game.vars.dir_x = 0
game.vars.dir_y = -1
end
end
end
-- Move snake every N frames
game.vars.frame_count = game.vars.frame_count + 1
if game.vars.frame_count >= game.vars.move_speed then
game.vars.frame_count = 0
move_snake()
return true
end
-- State: GAME_OVER
elseif state == STATE_GAME_OVER then
if event.type == INPUT.TOUCH_DOWN or event.type == INPUT.BUTTON_0 or event.type == INPUT.BUTTON_1 then
game.vars.state = STATE_MENU
return true
end
end
return false
end
-- Draw game
function draw()
renderer.clear(false)
local state = game.vars.state
-- Draw: MENU
if state == STATE_MENU then
renderer.text(game.width() / 2 - 30, game.height() / 2 - 20, "SNAKE", true)
renderer.text(game.width() / 2 - 50, game.height() / 2, "Tap to Start", true)
if game.vars.high_score > 0 then
local hs_text = "High: " .. tostring(game.vars.high_score)
renderer.text(game.width() / 2 - 30, game.height() / 2 + 20, hs_text, true)
end
-- Draw: PLAYING
elseif state == STATE_PLAYING then
-- Draw snake
for i = 1, #game.vars.snake do
local seg = game.vars.snake[i]
local filled = (i == 1) -- Head filled, body outline
renderer.rect(seg.x * CELL_SIZE, seg.y * CELL_SIZE, CELL_SIZE, CELL_SIZE, true, filled)
end
-- Draw food
renderer.circle(game.vars.food_x * CELL_SIZE + CELL_SIZE / 2,
game.vars.food_y * CELL_SIZE + CELL_SIZE / 2,
CELL_SIZE / 2, true, true)
-- Draw score
local score_text = "Score: " .. tostring(game.vars.score)
renderer.text(5, 5, score_text, true)
-- Draw: GAME_OVER
elseif state == STATE_GAME_OVER then
renderer.text(game.width() / 2 - 40, game.height() / 2 - 20, "GAME OVER", true)
local score_text = "Score: " .. tostring(game.vars.score)
renderer.text(game.width() / 2 - 40, game.height() / 2, score_text, true)
renderer.text(game.width() / 2 - 60, game.height() / 2 + 20, "Tap to Continue", true)
end
end
-- Helper: Initialize snake
function init_snake()
game.vars.snake = {}
game.vars.snake[1] = {x = 20, y = 14}
game.vars.snake[2] = {x = 19, y = 14}
game.vars.snake[3] = {x = 18, y = 14}
game.vars.dir_x = 1
game.vars.dir_y = 0
end
-- Helper: Spawn food at random position
function spawn_food()
game.vars.food_x = math.random(0, GRID_W - 1)
game.vars.food_y = math.random(0, GRID_H - 1)
end
-- Helper: Move snake
function move_snake()
local head = game.vars.snake[1]
local new_head = {
x = head.x + game.vars.dir_x,
y = head.y + game.vars.dir_y
}
-- Check wall collision
if new_head.x < 0 or new_head.x >= GRID_W or new_head.y < 0 or new_head.y >= GRID_H then
game_over()
return
end
-- Check self collision
for i = 1, #game.vars.snake do
local seg = game.vars.snake[i]
if new_head.x == seg.x and new_head.y == seg.y then
game_over()
return
end
end
-- Check food collision
local ate_food = false
if new_head.x == game.vars.food_x and new_head.y == game.vars.food_y then
ate_food = true
game.vars.score = game.vars.score + 10
spawn_food()
-- Increase speed slightly
if game.vars.move_speed > 3 then
game.vars.move_speed = game.vars.move_speed - 1
end
end
-- Move snake
table.insert(game.vars.snake, 1, new_head)
if not ate_food then
table.remove(game.vars.snake) -- Remove tail
end
end
-- Helper: Game over
function game_over()
game.vars.state = STATE_GAME_OVER
if game.vars.score > game.vars.high_score then
game.vars.high_score = game.vars.score
end
print("Game Over! Score: " .. game.vars.score)
end