LiveCode 3D Isometric Maze

Posted by Scott McDonald

The game this time is more of a puzzle. It is a maze that uses an isometric projection to achieve a 3D effect. The maze is easy to solve, but it can easily be expanded and have more complicated paths to increase the challenge. To make it a little more interesting a timer is included that counts the seconds until you finish. It is you against the timer.

In this game you will see:

  • One way to store room data to allow easy changes to the maze
  • How 2D images and layers give the illusion of depth
  • A technique to scroll a maze that is larger than the viewport
  • A minimap to show the entire maze

Before examining this game, a warning about versions of LiveCode from 6.0 and newer. Close the Project Browser before testing games like this that frequently change the layer property of controls. If the Project Browser is open when layers are changing, your program will not run smoothly and the resulting jerky animation may mislead you to think that the LiveCode Engine is not up to the task.

StartGame is the longest handler in this game, and so it is split up into smaller pieces of code below.

The first 3 lines remove any messages that are in the message queue. This is a good idea when starting a game, because if there are already messages pending that call your GameLoop (which can happen during development) if you don't first remove them the game animation will be unpredictable.

repeat for each line loopLine in the pendingMessages
  cancel item 1 of loopLine
end repeat

During the rest of StartGame there is a lot of creating and deleting of images and graphics so the screen is locked to speed up the execution. Here all the images and graphics from the previous run of the game are deleted. In a finished game this would not be necessary, but since this a demo and the maze may be changed those controls are deleted. Both loops use the way that every image and graphic that is created from code at runtime has a name that begins with the image or graphic respectively. This means that controls that I want to keep between each run have names that do not begin with either of these.

set the cursor to busy
lock screen
repeat with loopIndex = the number of images down to 1
  if the short name of image loopIndex begins with "image" then delete image loopIndex
end repeat
repeat with loopIndex = the number of graphics down to 1
  if the short name of graphic loopIndex begins with "graphic" then delete graphic loopIndex
end repeat

The roomMap list represents the maze which is made up of equal sized squares which I will refer to as cells. Each character of each line in roomMap is a represents a cell where W is a wall, B is the initial position of the ball and E the exit. The size of the maze is then put into sRoomMaxX and sRoomMaxY. Variables that include Room in the name refers to the cells of the maze which in this case is 11 by 11 in size. While variables you later see that refer to X and Y and do not include Room are screen coordinates.

put empty into roomMap
put "WWWWWWWWWWWWWWWWWWWWW" & cr after roomMap
put "WBW      WWW        W" & cr after roomMap
put "W W WWWW     WWWWWW W" & cr after roomMap
put "W W WW WWWWWWWW     W" & cr after roomMap
put "W W    W      W WWWWW" & cr after roomMap
put "W W WW WWWWWW W     W" & cr after roomMap
put "W W W  W      WWWWW W" & cr after roomMap
put "W   W  W WW WWW     W" & cr after roomMap
put "W WWWW W WW   W WWWWW" & cr after roomMap
put "W W      WW WWW     W" & cr after roomMap
put "W WWWWWWWWW WWWWWWW W" & cr after roomMap
put "W   W   W   W       W" & cr after roomMap
put "W W   W   W W   WWWWW" & cr after roomMap
put "WWWWWWWWWWW WWW W   W" & cr after roomMap
put "W           W W W W W" & cr after roomMap
put "W WWWWWWWWWWW W W W W" & cr after roomMap
put "W             W   W W" & cr after roomMap
put "WWWWWWWWWWWWWWWWWWWEW" & cr after roomMap
put the number of chars in line 1 of roomMap into sRoomMaxX
put the number of lines in roomMap into sRoomMaxY

A single 64 by 64 bitmap is used for the walls and since they move when scrolling the layer mode is set to dynamic. These two properties are set in the templateImage, so they are inherited by every image for the walls of the maze.

set the rectangle of the templateImage to 1,1,64,64
set the layerMode of the templateImage to "dynamic"
set the acceleratedRendering of this stack to true

Here is the single image made in Paint.NET for the walls. The shading makes it look interesting, but was tricky to get right so it flows smoothly when the images are repeated to make a long wall.

Next some variables are set. There will be more detail about the viewport later. The sFloorList stores information for translation from the screen coordinates to each cell in the room.

put 0 into sMaxX
put 0 into sMaxY
put 0 into sScreenOffsetX
put 0 into sScreenOffsetY
put kViewPortX1 + 200,kViewPortY1 + 100,kViewPortX2 - 200,kViewPortY2 - 100 into sViewPortMiddle
put kCornerX into cornerX
put kCornerY into cornerY
put empty into sImageList
put empty into sFloorList

The next loop fills the sRoomArray, sFloorList and sImageList. With this isometric projection the floor of each cell is represented by a rectangle that is 64 pixels wide and 32 pixels high. Part of the 3D illusion is because your brain assumes the room is a grid of squares, but since they look like diamonds you think they are being viewed at an angle. Each image overlaps with those adjacent, by 32 pixels along the horizontal axis and 16 pixels along the vertical axis. Next when the layer of image is set the 3D illusion is complete.

put 1 into roomY
repeat for each line loopRoomRow in roomMap
  put cornerX into x
  put cornerY into y
  put 1 into roomX
  repeat for each char loopRoomItem in loopRoomRow
    put loopRoomItem into sRoomArray[roomX, roomY]
    put y,y+32,x,y+16,roomX,roomY & cr after sFloorList
    switch loopRoomItem
      case "B"
        put roomX into sBallRoomX
        put roomY into sBallRoomY
        break
      case "E"
        put roomX into sExitRoomX
        put roomY into sExitRoomY
        break
      case "W"
        create image
        put the short ID of the last image into imageID
        put image "tileWall" into image ID imageID
        set the loc of image ID imageID to x,y
        put x,y,imageID & cr after sImageList
        break
    end switch
    put max(x,sMaxX) into sMaxX
    put max(y,sMaxY) into sMaxY
    add 32 to x
    add 16 to y
    add 1 to roomX
  end repeat
  subtract 32 from cornerX
  add 16 to cornerY
  add 1 to roomY
end repeat
put (sMaxX div kScrollDelta) * kScrollDelta into sMaxX
put (sMaxY div kScrollDelta) * kScrollDelta into sMaxY

After creating the images and filling the image list with each image ID and the screen coordinates, the list is sorted so the images with a smaller y coordinate (i.e. those at the back) are first and each image with the same y coordinate is sorted so those with a smaller x coordinate (i.e. those on the left) come before those on the right. Then the layer of each image is set based on this sorted order. This is all about getting the wall images to overlap in a way that those closer to you are always in front of those further away.

But what about the ball? Later on when the ball is rolling in the maze, it's layer needs to change as it moves closer and further away. To speed up the calculations, for every possible y screen coordinate the layer for the ball is stored in sLayerArray

sort sImageList ascending numeric by item 1 of each
sort sImageList ascending numeric by item 2 of each
put 1 into layerIndex
put 1 into prevY
repeat for each line loopLine in sImageList
  set the layer of image ID (item 3 of loopLine) to layerIndex
  put item 2 of loopLine into y
  if y <> prevY then
    repeat with loopY = prevY to y
      put layerIndex + 1 into sLayerArray[loopY]
    end repeat
    put y + 1 into prevY
  end if
  add 1 to layerIndex
end repeat

To increase the challenge, and to allow mazes that are larger than the stack window, only part of the maze is visible at any time. If the entire card displays the maze, then the areas of the maze that don't fit would be clipped at the edge of the window of the stack. But here there is a border around the maze to display the minimap, the time and the Play Again button.

So 4 graphics are created to cover the areas of the card where the maze is hidden. A single image with a transparent rectangle could have been used instead. But instead separate graphics are used to allow the size and position of the viewport to be more easily changed during development. In a polished game an image would be used to allow for a more interesting border.

set the style of the templateGraphic to "rectangle"
set the backgroundColor of the templateGraphic to 28,95,112
set the penColor of the templateGraphic to 28,95,112
set the showFill of the templateGraphic to true

set the rectangle of the templateGraphic to 0,0,960,kViewPortY1
create graphic
set the layer of the last image to top
set the rectangle of the templateGraphic to 0,kViewPortY2,960,640
create graphic
set the layer of the last image to top
set the rectangle of the templateGraphic to 0,kViewPortY1,kViewPortX1,kViewPortY2
create graphic
set the layer of the last image to top
set the rectangle of the templateGraphic to kViewPortX2,kViewPortY1,960,kViewPortY2
create graphic
set the layer of the last image to top

set the layer of button "butnPlayAgain" to top
set the layer of field "Time" to top
set the layer of image "tileWall" to bottom

The properties of the ball are next set, converting the room coordinates to the screen and setting the move to values to the current position so the ball is stationary at the start of the game. The layer is set based on the screen y coordinate so the ball is in front of and behind the appropriate wall images.

ConvertRoomToScreenXY sBallRoomX,sBallRoomY,sBallX,sBallY
put sBallX into sMoveToX
put sBallY into sMoveToY
put sLayerArray[sBallY - sScreenOffsetY] into sBallLayer
set the loc of image "Ball" to sBallX,sBallY
set the layer of image "Ball" to sBallLayer
set the layerMode of the image "Ball" to "dynamic"

Lastly the timer and minimap are initialised and drawn.

put 0 into sTime
put 0 into sGameCounter
UpdateTime
DrawMiniMap
GameLoop
set the cursor to arrow

unlock screen

The DrawMiniMap handler is only called once from StartGame, but the StartGame handler is already way too long, so it is a handler of it's own. It uses the information now in sRoomArray and creates the graphic controls to make a top down representation of the maze. Once it is drawn, UpdateMiniMap is called to show the position of the ball in the minimap.

command DrawMiniMap
  local x,y
  reset templateGraphic
  set the style of the templateGraphic to "rectangle"
  set the rect of the templateGraphic to kMiniMapCornerX,kMiniMapCornerY,kMiniMapCornerX + kMiniMapUnit * sRoomMaxX,kMiniMapCornerY + kMiniMapUnit * sRoomMaxY
  set the brushColor of the templateGraphic to 138,193,208
  set the penColor of the templateGraphic to "black"
  set the filled of the templateGraphic to true
  set the layerMode of the templateGraphic to "static"
  create graphic
  
  set the brushColor of the templateGraphic to 42,143,170
  set the penColor of the templateGraphic to 42,143,170
  put kMiniMapCornerY into y
  repeat with loopY = 1 to sRoomMaxY
    put kMiniMapCornerX into x
    repeat with loopX = 1 to sRoomMaxX
      if sRoomArray[loopX, loopY] = "W" then
        create graphic
        set the rect of the last graphic to x,y,x + kMiniMapUnit,y + kMiniMapUnit
      end if
      add kMiniMapUnit to x
    end repeat
    add kMiniMapUnit to y
  end repeat
  
  set the style of the templateGraphic to "oval"
  set the brushColor of the templateGraphic to "black"
  set the penColor of the templateGraphic to "black"
  create graphic
  set the width of the last graphic to kMiniMapUnit
  set the height of the last graphic to kMiniMapUnit
  set the loc of the last graphic to kMiniMapCornerX,kMiniMapCornerY
  put the short ID of the last graphic into sMiniMapBallID
  
  UpdateMiniMap
end DrawMiniMap

Next are two handlers that translate between the room and screen coordinates. The first calculates the screen position of the centre of the floor for each cell. There is a little complication because the cells go diagonally down the screen and there is an offset to allow the maze to scroll. The screen coordinates are returned as reference parameters so the x and y values do not need to be separated after returning.

ConvertScreenToRoomXY does the reverse, from a screen coordinate calculate what cell (if any) is at that position. It is a function because a click outside the maze returns false as there is no cell at the point. I wanted to use a 2 line formula like ConvertRoomToScreenXY, but I am embarrassed to reveal that I had trouble getting it working and instead choose a brute force algorithm. Not elegant, but it works and was quick to code. The sFloorList variable that was filled back in GameStart stores the screen coordinates of the rectangle around the floor of each cell. By running through this list (which is fast because LiveCode is efficient with this type of "for each" loop) and keeping track of which cell is closest to pScreenX and pScreenY the room coordinates are calculated.

command ConvertRoomToScreenXY pRoomX,pRoomY,@qScreenX,@qScreenY
  put kCornerX + sScreenOffsetX + 32 * (pRoomX - pRoomY) into qScreenX
  put kCornerY + sScreenOffsetY + 16 * (pRoomX + pRoomY - 2) into qScreenY
end ConvertRoomToScreenXY

function ConvertScreenToRoomXY pScreenX,pScreenY,@qRoomX,@qRoomY
  local mazeXY,delta,bestDelta
  subtract sScreenOffsetX from pScreenX
  subtract sScreenOffsetY from pScreenY
  put empty into qRoomX
  put empty into qRoomY
  put 640 into bestDelta
  repeat for each line loopLine in sFloorList
    if (pScreenY >= item 1 of loopLine) and (pScreenY <= item 2 of loopLine) then
      put abs(pScreenX - item 3 of loopLine) + abs(pScreenY - item 4 of loopLine) into delta
      if delta <= bestDelta then
        put item 5 of loopLine into qRoomX
        put item 6 of loopLine into qRoomY
        put delta into bestDelta
      end if
    end if
  end repeat
  return bestDelta < 48
end ConvertScreenToRoomXY

The GameLoop does the following. Calls MoveBall which updates the position of the ball if the current position is not at the MoveTo point. If the ball is at the coordinates of sMoveToX,sMoveToY the view is scrolled if the ball is at the edge of the viewport. Then the minimap is updated. Unless the game is over (you reached the exit) GameLoop is called again in 2 ticks so the game runs at 30 fps.

on GameLoop
  local roomX,roomY
  MoveBall
  if (sBallX = sMoveToX) and (sBallY = sMoveToY) then
    ScrollViewPort
    UpdateMiniMap
    if ConvertScreenToRoomXY(sBallX,sBallY,roomX,roomY) then
      put roomX = sExitRoomX and roomY = sExitRoomY into sGameOver
    end if
  end if
  add 1 to sGameCounter
  if sGameCounter = 30 then
    UpdateTime
    add 1 to sTime
    put 0 into sGameCounter
  end if
  if sGameOver then
    answer information "You reached the exit."
  else
    send "GameLoop" to me in 2 ticks
  end if
end GameLoop

MoveBall moves the ball if the current coordinates are not where the ball should be. The sDeltaX and sDeltaY variables are set when you click on the screen and move the ball a small amount in the right direction. Since MoveBall is called 30 times a second, the delta is kept small to keep the motion smooth. A comparison is made to check whether the ball is at the move to point. The motion of the ball may mean it does not directly go over the move to point, so provided it is close enough it is stopped and snaps to the middle of the cell.

Then the layer is set so it is in front of the walls behind it, while behind the walls in front of it.

command MoveBall
  local y,layerIndex
  if (sBallX <> sMoveToX) or (sBallY <> sMoveToY) then
    lock screen
    add sDeltaX to sBallX
    add sDeltaY to sBallY
    if (abs(sMoveToX - sBallX) <= kSpeedX) or (abs(sMoveToY - sBallY) <= kSpeedY) then
      put sMoveToX into sBallX
      put sMoveToY into sBallY
      put sMoveToRoomX into sBallRoomX
      put sMoveToRoomY into sBallRoomY
    end if
    put round(sBallY) into y
    set the loc of image "Ball" to round(sBallX),y
    put sLayerArray[y - sScreenOffsetY] into layerIndex
    if layerIndex <> sBallLayer then
      put layerIndex into sBallLayer
      set the layer of image "Ball" to sBallLayer
    end if
    unlock screen
  end if
end MoveBall

In the GameLoop after MoveBall, ScrollViewPort is called. It checks whether the ball is near the edge of the viewport and if necessary enters a repeat loop that updates these variables: sBallX, sMoveToX, sBallY, sMoveToY, screenOffsetX, screenOffsetY to move the ball closer to the middle of the screen. You may be wondering why go to all this trouble, and why a LiveCode group has not been used?

While a LiveCode group is powerful and requires little work to implement scrolling, it has one major problem for this game. When controls are in a group, the layer property of each control cannot be uniquely set. For an isometric 3D game this rules out using groups for the tiles of the room.

command ScrollViewPort
  local deltaX,screenOffsetX,deltaY,screenOffsetY
  put sScreenOffsetX into screenOffsetX
  put sScreenOffsetY into screenOffsetY
  if kViewPortX2 - sBallX < 32 then
    put -kScrollDelta into deltaX
    put kViewPortX2 - sMaxX - 32 into screenOffsetX
  end if
  if sBallX - kViewPortX1 < 32 then
    put kScrollDelta into deltaX
    put 0 into screenOffsetX
  end if
  if kViewPortY2 - sBallY < 32 then
    put -kScrollDelta into deltaY
    put kViewPortY2 - sMaxY - 32 into screenOffsetY
  end if
  if sBallY - kViewPortY1 < 32 then
    put kScrollDelta into deltaY
    put 0 into screenOffsetY
  end if
  repeat while (sScreenOffsetX <> screenOffsetX) or (sScreenOffsetY <> screenOffsetY)
    add deltaX to sScreenOffsetX
    add deltaY to sScreenOffsetY
    lock screen
    repeat for each line loopImage in sImageList
      set the loc of image ID (item 3 of loopImage) to item 1 of loopImage + sScreenOffsetX,item 2 of loopImage + sScreenOffsetY
    end repeat
    add deltaX to sBallX
    add deltaX to sMoveToX
    add deltaY to sBallY
    add deltaY to sMoveToY
    set the loc of image "Ball" to sBallX,sBallY
    unlock screen
    if sScreenOffsetX = screenOffsetX then put 0 into deltaX
    if sScreenOffsetY = screenOffsetY then put 0 into deltaY
    if sBallX,sBallY is within sViewPortMiddle then
      put sScreenOffsetX into screenOffsetX
      put sScreenOffsetY into screenOffsetY
    end if
  end repeat
end ScrollViewPort

These two commands update the position of the ball in the minimap and the elapsed time.

command UpdateMiniMap
  set the loc of the graphic ID sMiniMapBallID to kMiniMapCornerX + sBallRoomX * kMiniMapUnit - kMiniMapUnit div 2,kMiniMapCornerY + sBallRoomY * kMiniMapUnit - kMiniMapUnit div 2
end UpdateMiniMap

command UpdateTime
  put (sTime div 60) & ":" & sTime mod 60 into field "Time"
end UpdateTime

The code so far does all the calculations and drawing to make a convincing 3D maze using isometric techniques. But there is one last element missing: responding to the user input. This mouseDown handler first checks if the click is inside the maze and what cell in the room it is. Most of the code that follows is about what values are needed to move the ball to the mouse click, and whether there is a wall in the way of where you have clicked. Since the ball only travels in straight lines, a loop starts from the current position of the ball and stops and changes the move to value if a wall is reached.

Once valid values for sMoveToRoomX and sMoveToRoomY are found they are converted to screen coordinates. The difference between the current position and the move to position is used to set appropriate values for sDeltaX and sDeltaY that are used in MoveBall.

on mouseDown pButton
  local dX,dY
  if ConvertScreenToRoomXY(the mouseH,the mouseV,sMoveToRoomX,sMoveToRoomY) then
    -- can only go is straight line
    if not (sMoveToRoomX <> sBallRoomX and sMoveToRoomY <> sBallRoomY) then
      if sMoveToRoomX <> sBallRoomX then
        if sMoveToRoomX > sBallRoomX then
          put 1 into dX
        else
          put -1 into dX
        end if
        repeat with loopX = sBallRoomX + dX to sMoveToRoomX step dX
          if sRoomArray[loopX,sBallRoomY] = "W" then
            put loopX - dX into sMoveToRoomX
            exit repeat
          end if
        end repeat
      end if
      if sMoveToRoomY <> sBallRoomY then
        if sMoveToRoomY > sBallRoomY then
          put 1 into dY
        else
          put -1 into dY
        end if
        repeat with loopY = sBallRoomY + dY to sMoveToRoomY step dY
          if sRoomArray[sBallRoomX,loopY] = "W" then
            put loopY - dY into sMoveToRoomY
            exit repeat
          end if
        end repeat
      end if
      
      if sMoveToRoomX <> sBallRoomX or sMoveToRoomY <> sBallRoomY then
        ConvertRoomToScreenXY sMoveToRoomX,sMoveToRoomY,sMoveToX,sMoveToY
        if sMoveToX > sBallX then
          put kSpeedX into sDeltaX
        else
          put -kSpeedX into sDeltaX
        end if
        if sMoveToY > sBallY then
          put kSpeedY into sDeltaY
        else
          put -kSpeedY into sDeltaY
        end if
      end if
    end if
  end if
end mouseDown

That's about it. I'll be the first to admit that the maze itself is rather pathetic, but you can modify the roomMap in StartGame to make it bigger or more complex. Note, StartGame does not check that the number of rooms in each row are the same. If they are not, you will get unexpected results. When editing the map, using a fixed width font and setting your editor to overwrite mode can make it easier.

Download the complete LiveCode 3D Isometric Maze stack made in LiveCode 6.1.2 from here: LiveCode 3D Isometric Maze.livecode.

Happy LiveCoding.

Credit: Isometric layer technique inspired by Game Maker Tutorial: Adding Depth to Games by Mark Overmars. 2007.

Tagged: 3D intermediate isometric puzzle

Tuesday, December 24, 2013

0 Responses


COMMENTS ARE CLOSED

 

Legal Stuff

All blog posts are copyright and cannot be re-used without permission. But all the code and scripts are dedicated to the public domain. Use such code and scripts in any way you want, but I am not responsible if they don't work for you.

Contact

Further comments or feedback? You can contact me by email at: