A little game integrated with its tools


A week ago, my son (hearing that I was at loose ends) asked for "a game where you drag the player around the screen". This set some wheels in motion inside my head, resulting in this 3-screen app: one screen for a sprite editor, a second screen for a level/maze editor, and the final screen putting them together into a weird little game where you try to solve a maze without letting your player touch the walls. The screens exchange information using global variables for the player sprite and the maze. To try it out after downloading Lua Carousel:

1. Run the following abbreviations:

g = love.graphics
pt, line, rect = g.point, g.line, g.rectangle
color = g.setColor
min, max = math.min, math.max
floor = math.floor

2. In a new screen, run the following sprite editor (100 lines):

palette = {{1,0,0}, {0,0,1}}
W,H = 8,8
sprite = {}
for i=1,H do
  local row = {}
  for j=1,W do
    table.insert(row, (i+j)%#palette + 1)
  end
  table.insert(sprite, row)
end
Ezoom = 32  -- pixels
-- top-left of sprite
EX,EY = 40, 90
Ewidgets = {}
-- one button per pixel of sprite
function sprite_pixel(X,Y)
  local x = EX + (X-1)*Ezoom
  local y = EY + (Y-1)*Ezoom
  local draw = function()
    color(unpack(palette[sprite[Y][X]]))
    rect('fill', x,y, Ezoom-1,Ezoom-1)
  end
  local ispress = function(x2,y2)
    return x2 >= x and x2 <= x+Ezoom and y2 >= y and y2 <= y+Ezoom
  end
  local press = function()
    sprite[Y][X] = sprite[Y][X]%#palette + 1
  end
  return {draw=draw, ispress=ispress, press=press}
end
for y=1,H do
  for x=1,W do
    table.insert(Ewidgets, sprite_pixel(x,y))
  end
end
-- one slider per color component in palette
function palette_component(p, c)
  local x0, x1 = EX+20, 200  -- left and right limit
  local lo, hi = 0, 1  -- what left and right map to
  local x = x0 + (x1-x0)*(palette[p][c]-lo)/(hi-lo)
  local top = EY+Ezoom*H + 30
  local y = top + (p-1)*100 + (c-1)*30
  local w,h = 20,20
  local selected = false
  local slider_color = {0,0,0}
  slider_color[c] = 1
  local draw = function()
    color(unpack(slider_color))
    line(x0,y, x1,y)
    rect('fill', x-w/2, y-h/2, w,h)
  end
  local ispress = function(x2,y2)
    return x2 >= x-w/2 and x2 <= x+w/2 and y2 >= y-h/2 and y2 <= y+h/2
  end
  local press = function() selected = true end
  local update = function(x2,y2)
    if selected then
      x = min(max(x2, x0), x1)
      palette[p][c] = lo + (x-x0)*(hi-lo)/(x1-x0)
    end
  end
  local release = function() selected = false end
  return {draw=draw, ispress=ispress, press=press, update=update, release=release}
end
for p=1,#palette do
  for c=1,#palette[1] do
    table.insert(Ewidgets, palette_component(p, c))
  end
end
function car.draw()
  for name,w in pairs(Ewidgets) do
    w.draw()
  end
end
function car.mousepressed(x,y, b)
  for name,w in pairs(Ewidgets) do
    if w.ispress(x,y) then
      return w.press()
    end
  end
end
function car.update(dt)
  for name,w in pairs(Ewidgets) do
    if w.update then w.update(App.mouse_x(), App.mouse_y()) end
  end
end
function car.mousereleased(x,y, b)
  for name,w in pairs(Ewidgets) do
    if w.release then w.release() end
  end
end

(Many globals here start with 'E' to avoid conflicting with the other screens.)

3. In a new screen, run the following maze editor (100 lines):

Aw = Safe_width-60 -- playing area
Ah = Safe_height-Menu_bottom
Pzoom = 4
local Pw = W*Pzoom  -- player width
local Psx = floor(Pw*1.5)  -- player x space
local Mcw = floor(Aw/Psx)  -- maze width in cells
local Mw = Psx*Mcw  -- Maze width
local Mx = floor((Safe_width-Mw)/2)
local Ph = H*Pzoom
local Psy = floor(Ph*1.5)
local Mch = floor(Ah/Psy)  -- maze height in cells
local Mh = Psy*Mch  -- Maze height
local My = Menu_bottom + floor((Safe_height-Menu_bottom-Mh)/2)
-- maze consists of Mcw*Mch rooms
-- each room has 4 walls around it
-- but the outer walls are implicitly built and so not represented
-- so we have (Mch-1)*Mcw horizontal walls and Mch*(Mcw-1) vertical walls
-- cell x,y has horizontal walls x,y-1 and x,y, and vertical walls x-1,y and x,y
-- walls that are out of bounds of Mhw and Mvw are always solid
Mhw, Mvw = {}, {}
for i=1,Mch-1 do
  local mhwr = {}, {}
  for j=1,Mcw do
    table.insert(mhwr, 0)
  end
  table.insert(Mhw, mhwr)
end
for i=1,Mch do
  local mvwr = {}
  for j=1,Mcw-1 do
    table.insert(mvwr, 0)
  end
  table.insert(Mvw, mvwr)
end
widgets = {}
-- one button per horizontal wall
function maze_hor_wall(X,Y)
  local x = Mx + (X-1)*Psx+10
  local y = My + Y*Psy-10
  local w = Psx-20
  local h = 20
  local draw = function()
    color(0.5,0.5,0.5)
    if Mhw[Y][X] == 1 then
      rect('fill', x,y,w,h, 5)
    else
      rect('line', x,y,w,h, 5)
    end
  end
  local ispress = function(x2,y2)
    return x2 >= x and x2 <= x+w and y2 >= y and y2 <= y+h
  end
  local press = function()
    Mhw[Y][X] = 1-Mhw[Y][X]
  end
  return {draw=draw, ispress=ispress, press=press}
end
for y=1,Mch-1 do
  for x=1,Mcw do
    table.insert(widgets, maze_hor_wall(x,y))
  end
end
-- one button per vertical wall
function maze_vert_wall(X,Y)
  local x = Mx + X*Psx-10
  local y = My + (Y-1)*Psy+10
  local w = 20
  local h = Psy-20
  local draw = function()
    color(0.5,0.5,0.5)
    if Mvw[Y][X] == 1 then
      rect('fill', x,y,w,h, 5)
    else
      rect('line', x,y,w,h, 5)
    end
  end
  local ispress = function(x2,y2)
    return x2 >= x and x2 <= x+w and y2 >= y and y2 <= y+h
  end
  local press = function()
    Mvw[Y][X] = 1-Mvw[Y][X]
  end
  return {draw=draw, ispress=ispress, press=press}
end
for y=1,Mch do
  for x=1,Mcw-1 do
    table.insert(widgets, maze_vert_wall(x,y))
  end
end
function car.draw()
  color(0.8,0.8,0.8)
  rect('line', Mx, My, Mcw*Psx, Mch*Psy)
  for name,w in pairs(widgets) do
    w.draw()
  end
end
function car.mousepressed(x,y, b)
  for name,w in pairs(widgets) do
    if w.ispress(x,y) then
      return w.press()
    end
  end
end
function car.update(dt)
  for name,w in pairs(widgets) do
    if w.update then w.update(App.mouse_x(), App.mouse_y()) end
  end
end
function car.mousereleased(x,y, b)
  for name,w in pairs(widgets) do
    if w.release then w.release() end
  end
end

(The names got a bit cryptic here as I worked on my phone. Hopefully the comments are clear. Definitely comment if something doesn't make sense.)

4. In a new screen,  run the following code for the game itself (100 lines) which uses the sprite and maze defined in the previous screens.

Game_over = nil
Aw = Safe_width-60 -- playing area
Ah = Safe_height-Menu_bottom
Pzoom = 4
local Pw = W*Pzoom  -- player width
local Psx = floor(Pw*1.5)  -- player x space
local Mcw = floor(Aw/Psx)  -- maze width in cells
local Mw = Psx*Mcw  -- Maze width
local Mx = floor((Safe_width-Mw)/2)
local Ph = H*Pzoom
local Psy = floor(Ph*1.5)
local Mch = floor(Ah/Psy)  -- maze height in cells
local Mh = Psy*Mch  -- Maze height
local My = Menu_bottom + floor((Safe_height-Menu_bottom-Mh)/2)
Px,Py = Mx+5, My+5
move = nil
function car.draw()
  draw_board()
  --draw_game_over()
  draw_sprite(sprite, Pzoom, Px,Py, palette)
  color(0.5,0.5,0.5)
  rect('fill', Px, Py+#sprite*Pzoom, #sprite[1]*Pzoom, 30)
  if Game_over then
    color(1,0,0)
    g.print('game over!', 30,30)
  end
end
function car.mousepressed(x,y, b)
  if Game_over then return end
  if x >= Px and x <= Px+#sprite[1]*Pzoom and
      y >= Py+#sprite*Pzoom and y <= Py+#sprite*Pzoom+30 then
    move = {x=x-Px, y=y-Py}
  end
end
function car.mousereleased()
  if Game_over then return end
  move = nil
end
function car.update()
  if Game_over then return end
  if move then
    Px = App.mouse_x() - move.x
    Py = App.mouse_y() - move.y
    Game_over = game_over(Px, Py)
  end
end
function game_over(x, y)
  -- is Px, Py colliding with a solid wall?
  -- borders
  local mcx = floor((x-Mx)/Psx)+1
  local mcy = floor((y-My)/Psy)+1
  if mcx < 1 or mcy < 1 then return true end
  if mcx > Mcw or mcy > Mch then return true end
  local ox = (x-Mx)%Psx
  local oy = (y-My)%Psy
  if ox > Pw/2 then
    if mcx == Mcw then return true end
    if Mvw[mcy][mcx] > 0 then return true end
  end
  if oy > Ph/2 then
    if mcy == Mch then return true end
    if Mhw[mcy][mcx] > 0 then return true end
  end
end
function draw_game_over()
  for x=30,Safe_width do
    for y=Menu_bottom,Safe_height do
      if game_over(x,y) then
        color(1,0,0, 0.2)
      else
        color(0,1,0, 0.2)
      end
      pt(x,y)
    end end end
function draw_board()
  color(0.8,0.8,0.8)
  rect('line', Mx, My, Mcw*Psx, Mch*Psy)
  for y=1,Mch-1 do
    for x=1,Mcw do
      if Mhw[y][x] > 0 then
        line(Mx+(x-1)*Psx, My+y*Psy, Mx+x*Psx, My+y*Psy)
      end
    end
  end
  for y=1,Mch do
    for x=1,Mcw-1 do
      if Mvw[y][x] > 0 then
        line(Mx+x*Psx, My+(y-1)*Psy, Mx+x*Psx, My+y*Psy)
      end
    end
  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

That's it. Once you've pasted and run these screens once, you can bounce between them editing the maze or the player sprite, and see the changes immediately in the game.

One little thing that has frustrated me on touch-screen devices is that my finger often hinders my view of the gestures its performing. In this game I interact with the player using a little handle (akin to a tab or flap you might find in a pop-up book) that is otherwise invisible to the rest of the game.

Get Lua Carousel

Leave a comment

Log in with itch.io to leave a comment.