A little programming game


The kids recently got a little paper computer and, perhaps ill-advisedly, I've been building it on my touchscreen computer. The way I rationalize it is, the paper computer gives no feedback on whether a program works, and checking instructions seems like a tedious, error-prone task that is ideal to be automated away.

My app is "responsive", i.e. it adapts to the size of the screen. If you rotate your device, it adjusts gracefully. It's amazing how few lines of code it takes to obtain responsiveness if you build your "page" in an imperative way rather than atop layers of ostensibly "declarative" notation.

Unfortunately, in the process of making it responsive I found something missing in Carousel: a `car.resize` function akin to `love.resize` that is called when you rotate your device. So I had to ship a new version with this function. Upgrade to try out this program.

This is the largest program I've shown so far, and to keep it manageable I've split it up into 3 screens. Here's the first, copy it into a screen called 'rabbot':

ui_state = {}  -- button state recreated each frame
-- level is 4x4 tiles
-- each tile is 2 digits, connects at most 2 sides
-- up=1 right=2 down=3 left=4
-- examples:
--   12 from upper margin to right
--   13 straight from top to bottom of tile
board = {
  {0, 0, 0, 0},
  {0, 0, 0, 0},
  {0, 0, 0, 0},
  {0, 0, 0, 0},
}
board_color = {0, 0.8, 0}
r = 4 -- road width
road_color = {0.4, 0, 0.4}
code = {}  -- up to 20 steps ('forward', 'left' or 'right')
-- rabbot initially starts out outside the board, above the top-left tile, going down
rabbot_state = {x=1,y=0, dir=3, status=nil}
animation_frame_duration = 0.5  -- seconds
-- at most one tool will be dragged at a time
-- examples: {'board', 42}, {'code', 'forward'}, {'rabbot'}
drag = nil
-- 16x16 rabbot sprite authored elsewhere
rabbot = json.decode([=[
[[1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1],
[1,1,1,1,1,1,2,2,1,1,1,2,2,1,1,1],
[1,1,1,1,1,1,1,2,2,1,1,1,2,2,1,1],
[1,1,1,1,1,1,1,2,2,2,2,1,2,2,1,1],
[1,1,1,1,1,1,1,1,2,2,2,2,2,2,2,1],
[1,1,1,1,1,1,1,1,1,2,1,2,2,1,2,1],
[1,1,1,1,1,1,1,1,1,2,2,2,2,2,2,1],
[1,1,1,1,1,1,1,1,1,2,2,1,2,1,2,1],
[1,1,1,1,1,1,1,1,1,1,2,2,1,2,2,1],
[1,1,1,1,1,1,1,1,1,1,1,2,2,2,1,1],
[1,1,2,2,2,2,2,2,2,2,2,2,2,2,1,1],
[1,2,2,2,2,2,2,2,2,2,2,2,2,2,2,1],
[1,2,2,2,2,2,2,2,2,2,2,2,2,2,2,1],
[1,1,2,2,1,1,2,2,1,2,2,1,1,2,2,1],
[1,1,2,2,1,1,2,2,1,2,2,1,1,2,2,1],
[1,1,1,2,2,2,2,1,1,1,2,2,2,2,1,1]]
]=])
rabbot_palette = {board_color, {1,1,1}}
function car.draw()
  ui_state.button_handlers = {}
  if Safe_width > Safe_height then
    draw_landscape()
  else
    draw_portrait()
  end
  draw_drag()
  draw_rabbot()
  draw_rabbot_path()
end
-- initialize viewport now and any time device rotates
function car.resize(w,h)
  W = Safe_width-30*2  -- next/prev screen buttons
  H = Safe_height - Menu_bottom
end
car.resize()
function draw_landscape()
  -- width: 2 toolbar + 1 gutter + 4 board + 1 gutter + 1 toolbar + 1 gutter + 5 code = 15 units
  -- height: 1 status + 4 code + 1 gutter + 1 run button = 7 units
  side = floor(min(W/15, H/7))
  local left = floor((Safe_width-side*15)/2)
  local top = floor((Safe_height-side*7)/2)
  top = top + side
  draw_board_toolbar(left, top)
  left = left + side*3
  draw_board(left, top)
  left = left + side*5
  draw_code_toolbar(left, top)
  left = left + side*2
  draw_code(left, top)
end
function draw_portrait()
  -- width: 2 toolbar + 1 gutter + 4 board + 1 drop zone = 8 units
  -- height: 1 status + 4 board + 1 gutter + 4 code + 1 gutter + 1 run button = 12 units
  side = floor(min(W/8, H/12))
  local left = floor((Safe_width-side*8)/2)
  local top = floor((Safe_height-side*12)/2)
  top = top+side
  draw_board_toolbar(left, top)
  draw_board(left+side*3, top)
  top = top+side*5
  draw_code_toolbar(left, top)
  draw_code(left+side*2, top)
end
function car.mouse_press(x,y, b)
  rabbot_state.status = nil
  rabbot_state.path = {}
  rabbot_state.ip = nil
  if mouse_press_consumed_by_any_button(ui_state, x,y, b) then
    return
  end
end
function car.mouse_release(x,y)
  if drag == nil then return end
  if drag[1] == 'board' then
    local b = ui_state.board
    local left, top = b.left, b.top
    if x >= left and x < left+4*side
      and y >= top and y < top+4*side then
      board[floor((y-top)/side)+1][floor((x-left)/side)+1] = drag[2]
    end
  elseif drag[1] == 'code' then
    local c = ui_state.code
    local left, top = c.left, c.top
    if x >= left and x < left+5*side
      and y >= top and y < top+4*side then
      local i = floor((y-top)/side)*5 + floor((x-left)/side)+1
      if i <= #code or drag[2] == nil then
        code[i] = drag[2]
      else
        table.insert(code, drag[2])
      end end
  elseif drag[1] == 'rabbot' then
    drop_rabbot()
    rabbot_state.path = {}
    rabbot_state.ip = 1
    rabbot_state.next_update = Current_time + animation_frame_duration
  end
  drag = nil
end
function car.update(dt)
  step_rabbot()
end
function draw_drag()
  if drag == nil then return end
  local x,y = App.mouse_x()-side/2, App.mouse_y()-side/2
  if drag[1] == 'board' then
    draw_tile_bg(x, y)
    draw_tile_fg(drag[2], x,y)
  elseif drag[1] == 'code' then
    draw_code_step('', drag[2], x,y)
  end end
function draw_sprite(sprite, zoom, x,y, palette)
  for j,row in ipairs(sprite) do
    for i,cell in ipairs(row) do
      color(unpack(palette[cell]))
      rect('fill', x+(i-1)*zoom, y+(j-1)*zoom, zoom, zoom)
    end end end

Here's the second screen, save it as `rabbot-draw`:

-- board
function draw_board_toolbar(left, top)
  draw_tile_tool(0, left+side/2, top)
  draw_tile_tool(13, left, top+side)
  draw_tile_tool(24, left+side, top+side)
  draw_tile_tool(23, left, top+2*side)
  draw_tile_tool(34, left+side, top+2*side)
  draw_tile_tool(12, left, top+3*side)
  draw_tile_tool(14, left+side, top+3*side)
end
function draw_tile_tool(t, left, top)
  local b = board_color
  button(ui_state, t, {
    x=left, y=top, w=side-1, h=side-1,
    bg={r=b[1], g=b[2], b=b[3]},
    icon = function(p)
      draw_tile_fg(t, p.x, p.y)
    end,
    onpress1 = function()
      drag = {'board', t}
    end
  })
end
function draw_board(left, top)
  ui_state.board = {left=left, top=top}
  for y,row in ipairs(board) do
    for x,_ in ipairs(row) do
      draw_tile_bg(left+(x-1)*side, top+(y-1)*side)
    end end
  for y,row in ipairs(board) do
    for x,tile in ipairs(row) do
      draw_tile_fg(tile, left+(x-1)*side, top+(y-1)*side)
    end end
  if rabbot_state.status then
    color(0,0,0)
    g.print(rabbot_state.status, left, top-side/2)
  end
  color(0,0,0)
  g.print('board', left+2*side-App.width('board')/2, top+side*4)
end
function draw_tile_bg(x,y)
  color(unpack(board_color))
  rect('fill', x,y, side-1,side-1)
end
function draw_tile_fg(t, x,y)
  if t == 0 then return end
  color(unpack(road_color))
  g.setLineWidth(r)
  if t == 13 then
    line(x+side/2, y, x+side/2, y+side)
  elseif t == 24 then
    line(x, y+side/2, x+side, y+side/2)
  elseif t == 12 then
    arc('line', 'open', x+side,y, side/2, pi/2, pi)
  elseif t == 14 then
    arc('line', 'open', x,y, side/2, 0,pi/2)
  elseif t == 23 then
    arc('line', 'open', x+side,y+side, side/2, pi, pi*3/2)
  elseif t == 34 then
    arc('line', 'open', x,y+side, side/2, pi*3/2, pi*2)
  end end
-- code
function draw_code_toolbar(left, top)
  draw_code_tool(nil, left, top)
  draw_code_tool('forward', left, top+side)
  draw_code_tool('left', left, top+2*side)
  draw_code_tool('right', left, top+3*side)
end
function draw_code_tool(dir, left, top)
  button(ui_state, t, {
    x=left, y=top, w=side-1, h=side-1,
    icon = function(p)
      draw_code_step('', dir, p.x, p.y)
    end,
    onpress1 = function()
      drag = {'code', dir}
    end
  })
end
function draw_code(left, top)
  ui_state.code = {left=left, top=top}
  color(0.5, 0.5, 0.5)
  local x, y = left, top
  for i=1,20 do
    draw_code_step(i, code[i], x, y)
    if i%5 > 0 then
      x = x + side
    else
      y = y + side
      x = left
    end end
  color(0,0,0)
  g.print('instructions', left+2.5*side-App.width('instructions')/2, top+side*4)
end
function draw_code_step(i, dir, x, y)
  if dir == nil then
    color(0.5, 0.5, 0.5)
    g.setLineWidth(1)
    rect('line', x,y, side, side)
    g.print(i, x+5, y+5)
  elseif dir == 'forward' then
    color(0.4, 0.8, 0.8)
    rect('fill', x,y, side, side)
    color(0,0,0)
    g.print(i, x+5, y+5)
    color(1, 0.1, 0)
    g.setLineWidth(4)
    line(x+side/2, y+side/4, x+side/2, y+side-side/8)
    poly('fill', x+side/2, y+side/8, x+side/2-side/8, y+side/4, x+side/2+side/8, y+side/4)
  elseif dir == 'left' then
    color(0, 0.5, 0)
    rect('fill', x,y, side, side)
    color(0,0,0)
    g.print(i, x+5, y+5)
    color(1, 1, 0)
    g.setLineWidth(4)
    arc('line', 'open', x+side/4, y+side-side/8, side/2, 0, -pi/2)
    local x, y = x+side/4-side/16, y+side/2-side/8
    poly('fill', x, y, x+side/8, y-side/8, x+side/8, y+side/8)
  elseif dir == 'right' then
    color(0, 0, 0.4)
    rect('fill', x,y, side, side)
    color(0.6,0.6,0.6)
    g.print(i, x+5, y+5)
    color(1, 1, 0)
    g.setLineWidth(4)
    arc('line', 'open', x+side*3/4, y+side-side/8, side/2, pi, pi*3/2)
    local x, y = x+side*3/4, y+side/2-side/8
    poly('fill', x+side/16, y, x-side/16, y-side/8, x-side/16, y+side/8)
  end end
-- rabbot
function draw_rabbot()
  if drag == nil then
  local x, y = rabbot_position(rabbot_state.x, rabbot_state.y, rabbot_state.dir)
  button(ui_state, 'rabbot', {
    x=x, y=y, w=32, h=32,
    icon = function(p)
      draw_sprite(rabbot, 2, p.x,p.y, rabbot_palette)
    end,
    onpress1 = function()
      drag = {'rabbot'}
    end,
  })
  elseif drag[1] == 'rabbot' then
    draw_rabbot_drop_zones()
    local mx,my = love.mouse.getPosition()
    draw_sprite(rabbot, 2, mx,my, rabbot_palette)
  end end

Finally the third screen, save it as `rabbot-run`:

run_screen('rabbot')
run_screen('rabbot-draw')
-- trigger run by releasing the rabbot sprite
function drop_rabbot()
  local mx,my = love.mouse.getPosition()
  -- up
  for x=1,4 do
    local px, py = rabbot_drop_zone(x, 0, 3)
    if in_rabbot_drop_zone(px,py, mx,my) then
print('drop', x, 0, 3)
      rabbot_state = {x=x, y=0, dir=3}
      return
    end
  end
  -- left
  for y=1,4 do
    local px, py = rabbot_drop_zone(0, y, 2)
    if in_rabbot_drop_zone(px,py, mx,my) then
print('drop', 0, y, 2)
      rabbot_state = {x=0, y=y, dir=2}
      return
    end
  end
  -- down
  for x=1,4 do
    local px, py = rabbot_drop_zone(x, 5, 1)
    if in_rabbot_drop_zone(px,py, mx,my) then
print('drop', x, 5, 1)
      rabbot_state = {x=x, y=5, dir=1}
      return
    end
  end
  -- right
  for y=1,4 do
    local px, py = rabbot_drop_zone(5, y, 4)
    if in_rabbot_drop_zone(px,py, mx,my) then
print('drop', 5, y, 4)
      rabbot_state = {x=5, y=y, dir=4}
      return
    end end
  -- invalid
  print('cannot drop rabbot there')
  status('cannot drop rabbot there')
end
-- valid drop zones
function draw_rabbot_drop_zones()
  color(0.5,0.5,0.5, 0.5)
  -- up
  for x=1,4 do
    local x, y = rabbot_drop_zone(x, 0, 3)
    rect('fill', x,y, side-1,side-1)
  end
  -- left
  for y=1,4 do
    local x, y = rabbot_drop_zone(0, y, 2)
    rect('fill', x,y, side-1,side-1)
  end
  -- down
  for x=1,4 do
    local x, y = rabbot_drop_zone(x, 5, 1)
    rect('fill', x,y, side-1,side-1)
  end
  -- right
  for y=1,4 do
    local x, y = rabbot_drop_zone(5, y, 4)
    rect('fill', x,y, side-1,side-1)
  end end
function step_rabbot()
  if rabbot_state.status then rabbot_state.ip = nil end
  if rabbot_state.ip == nil then return end
  if rabbot_state.ip > 20 then return end
  if Current_time < rabbot_state.next_update then return end
  traverse_edge()
  if rabbot_state.status then return end
  traverse_tile_with_instruction(rabbot_state.ip)
  rabbot_state.ip = rabbot_state.ip+1
  rabbot_state.next_update = Current_time + animation_frame_duration
end
function traverse_edge()
  local r = rabbot_state
  -- compute next tile to move to
  print('jump', r.x, r.y, r.dir)
  if r.dir == 1 then r.y = r.y-1
  elseif r.dir == 2 then r.x = r.x+1
  elseif r.dir == 3 then r.y = r.y+1
  elseif r.dir == 4 then r.x = r.x-1
  else return status('bug: invalid dir')
  end
  r.dir = across(r.dir)
  -- out of bounds after any jumping (i.e. ignoring start) = success
  if r.x < 1 or r.x > #board or r.y < 1 or r.y > #board then
    print('=>', r.x, r.y, r.dir)
    status('across!')
  end
end
function traverse_tile_with_instruction(i)
  local r = rabbot_state
  local tile = board[r.y][r.x]
  print('tile', r.x, r.y, 'is', tile)
  -- check that there's track in the starting direction
  local o, t = tile%10, floor(tile/10)  -- ones, tens
  if r.dir ~= o and r.dir ~= t then return status('fell off the track') end
  -- compare end direction of current tile and instruction
  local a, e = o, nil  -- actual, expected end dir
  if r.dir == o then a = t end
  if code[i] == nil then return status('need more instructions') end
  if code[i] == 'forward' then
    e = across(r.dir)
  elseif code[i] == 'left' then
    e = clockwise(r.dir)
  elseif code[i] == 'right' then
    e = anticlockwise(r.dir)
  else
    return status('bug: invalid instruction')
  end
  if e ~= a then return status('instruction is wrong', e, a) end
  r.dir = e
  table.insert(rabbot_state.path, {r.x,r.y})  -- success
end
function rabbot_position(x,y, d)
  local x, y = rabbot_drop_zone(x,y, d)
  if d == 1 then  -- left
    x = x+side/2-16
  elseif d == 2 then  -- right
    x = x+side-32
    y = y+side/2-16
  elseif d == 3 then  -- down
    x = x+side/2-16
    y = y+side-32
  elseif d == 4 then  -- left
    y = y+side/2-16
  end
  return x, y
end
function rabbot_drop_zone(x,y, d)
  local x = ui_state.board.left+side*(x-1)
  local y = ui_state.board.top+side*(y-1)
  return x, y
end
function in_rabbot_drop_zone(px,py, mx,my)
  return mx >= px and mx < px+side and my >= py and my < py+side
end
function status(msg) rabbot_state.status = msg end
function across(dir) return (dir+2-1)%4+1 end
function clockwise(dir) return (dir+1-1)%4+1 end
function anticlockwise(dir) return (dir+3-1)%4+1 end
function draw_rabbot_path()
  if rabbot_state.path == nil then return end
  color(0,0,0)
  for i,p in ipairs(rabbot_state.path) do
    local x, y = unpack(p)
    g.print(i, ui_state.board.left+(x-1)*side+5, ui_state.board.top+(y-1)*side+5)
  end end

After saving all 3, hit run on the final screen (it'll pull in the others as needed).  You'll need to first run the abbreviations on one of the example screens. Or if you've deleted that screen, here are the abbreviations I used in this post:

g = love.graphics
line, rect, poly = g.line, g.rectangle, g.polygon
arc = g.arc
color = g.setColor
min, max = math.min, math.max
floor, ceil = math.floor, math.ceil
pi = math.pi

Forgive the poor quality art for the bot. I'd appreciate improvements.

Also, if you've read this far, please send me any programs you've built using Lua Carousel. They can be big or tiny, working or broken. I'd appreciate hearing from you.

Files

carousel-bf.love 113 kB
Feb 16, 2024
carousel-bf-safe.love 113 kB
Feb 16, 2024

Get Lua Carousel

Leave a comment

Log in with itch.io to leave a comment.