Isometric Game - Part 1

View Shockwave Demo | Download Source Movie

At the heart of an isometric game is a map of tiles. This can either be a long linear list where, for example, the first 10 items are the first row, the second 10 items are the next row and so on. Alternatively, you could use a list of 'rows', where each row is a list of tiles. In this tutorial, we will use this second approach.

This map of tiles will look something like this:

Map = [

[tile, tile, tile, tile],

[tile, tile, tile, tile],

[tile, tile, tile, tile],

[tile, tile, tile, tile],

[tile, tile, tile, tile],

[tile, tile, tile, tile],

[tile, tile, tile, tile],

]

And when we draw the tiles, we will end up with something like this:

ISO Diamond

To draw these tiles, we need to draw each row from the top (back of the map) down. By painting the tiles this way, the front most tiles can overlap the back tiles.

Direction of drawing

The algorithm for drawing the tiles is a little convoluted. The first step is to work out the starting position of the first (top-middle) tile. Once we know this, we can loop through the map drawing each row a half-tile height below and a half-tile width to the left of the previous row, and each tile in a row a half-tile height below and a half-tile width to the right of the previous tile.

So, to work out the starting position, we need to work out the overall size of the rect enclosing the map. Assuming the number of columns and the number of rows are the same, the width will be equal to the number of columns * the tile width, and the height is equal to the number of rows * the tile height.

Image show overall size of diamond

However, if the number of rows and the number of columns are different, the equation is a little different: The overall width is the number of columns x tilewidth/2 + the number of rows x tilewidth/2.

Calculating the overall size

Also note that since we might have tall tiles that will extend above the top of the diamond, we will push the whole diamond down a little by adding a vertical offset:

Diamond size

So our initial lingo to determine the horizontal offset (hOffset) and vertical offset (vOffset) from the top left of the enclosing rect might look like this:

[Initialise map method - excerpt]

-- basic info about the tile size

BaseTileImage = member(?basetile?).image

Tile_W = BaseTileImage.width

Tile_H = BaseTileImage.height

-- number of rows and columns

NumCols = count(aMap)

NumRows = aMap[1].count

-- decide on a vertical offset (if any)

vOffset = 40 -- or whatever 

-- calculate the overall size of the iso-world

IsoWidth = NumRows * Tile_HalfW + Numcols * Tile_HalfW

IsoHeight = NumRows * Tile_HalfH + Numcols * Tile_HalfH + vOffset

-- now we can find horizontal the position of the first tile

hOffset = IsoWidth/2

Now, hOffset and vOffset tells us the point of the top of the top most tile relative to the top-left of the overall rect enclosing the diamond. However, when we paint the tiles, we will be specifying the top-left of the rect of the tile - so we need to adjust the hOffset offset slightly. We will also need to take into account the 'regPoint' of the base tile. Since some tiles will be taller than others, it is convenient to set set the regPoint of all tiles to the bottom-middle. Therefore, we need to adjust the offsets like this:

  hOffset = hOffset -  baseTile.regPoint.locH - Tile_HalfW

  vOffset = vOffset + baseTile.regPoint.locV

Now to draw the tiles, we will need to know a few properties about the tile: their width, height, and how to position their rect relative to the position of the tile (ie - their offset)

Offsets and other fun fiddling

Tile Objects

So assuming our map will be a list of cast member names, lets create a basic script for a class of "Tile Objects" like this:

[Parent Script "ISO.tile"]

property Name

property image

property offsetX

property offsetY





on new (me, aMemberName)

  Name = aMemberName

  memberRef = member(aMemberName)

  image = memberRef.image

  offsetX = memberRef.regPoint.locH

  offsetY = memberRef.regPoint.locV

  return me

end



So far this script doesn't do much other than define some properties for our tile objects including an offset based on the tile member's regPoint

Tile offsets

Now lets assume we are eventually going to store our maps in files or cast members and these maps are essentially lists defining some properties about the tile (such as the name of the cast member to use, whether the tile is 'walkable', whether it animates etc). The process for reading the map and creating tile objects would be like this

mapList = GetMapList() -- read from file

NumCols = count(mapList)

NumRows = mapList[1].count

repeat with y = 1 to NumCols

  repeat with x = 1 to NumRows

	tileDetails = mapList[y][x]

	createTileObject(tileDetails) -- instantiate with params

  end repeat

end repeat



However, for the purposes of this tutorial we will just create some random maps and the only property we will define for each tile is the name of the cast member to use. The lingo the create a random map of tile objects will look like this:

  aMap= []

  repeat with  y = 1 to 16

    aRow = []

    repeat with x = 1 to 16

      tileName = "tile-" & random(10)

      tileObject = script("ISO.tile").new(tileName)

      aRow.append(tileObject)

    end repeat

    aMap.append(arow)

  end repeat

Mapped Tile Objects

When we place the tiles into the world, they will have some additional properties - such as their position within the world. So lets make another class of "mapped tile' which is a special instance of our basic Tile Object.

[Parent Script "ISO.tile.mapped"]

property ancestor

property worldx, worldy -- world coordinates

property sourceRect, destRect 







on new (me, tileObj, wx, wy)

  ancestor = tileObj

  worldx = wx

  worldy = wy

  sourceRect = tileObj.image.rect

  destRect = tileObj.image.rect.offset(worldx, worldy) 

  return me

end



on Paint (me, buffer)

   buffer.copyPixels(me.image, me.destRect, me.sourceRect, [#Ink: 36])

end



on Overlaps (me, aRect)

  return (destRect.intersect(aRect) <> rect(0,0,0,0))

end



on Inside (me, aPoint)

  return aPoint.inside(destRect)

end

This script doesn't do much at this stage -other than defining some properties for our mapped tiles, as well as a couple of simple methods that may be useful. However, by setting the basic tile object to be an ancestor of this object, our 'mappedTile' objects created from this script will inherit all the properties and methods of the basic tile.

Placing Tiles

To place tiles, we now loop through the map, calculating their x and y locations based on their position in the map. Here's the main lingo routine placing tiles, and created 'mappedTile' objects:

[Initialise map method continued]



  -- Build the map

  my = NumCols -1

  mx = NumRows -1

  

  mapList = []

  repeat with y = 0 to my

    row = []

    repeat with x = 0 to mx

      tile = aMap[y+1][x+1]

      wx = (x * Tile_HalfW + tile.offsetX) - (y * Tile_HalfW) + hOffset

      wy = (x * Tile_HalfH - tile.offsetY) + (y * Tile_HalfH) + vOffset

      mappedTile = script("ISO.Tile.Mapped").new(tile, wx, wy)

      row.append(mappedTile)

    end repeat

    mapList.append(row)

  end repeat

Once we have initialised the map, it is relatively easy to draw the result - we loop through the list of mapped tiles and tell them to paint themselves onto our output image.

on RepaintBuffer (me)

  buffer.fill(buffer.rect, rgb(0,0,0))

  repeat with y = 1 to NumCols

    call (#paint,  mapList[y], buffer)

  end repeat

end

Interacting with the map

Two key methods you need for interacting with the map are a method for translating world coordinates (such as the mouseLocation) to ISO coordinates (ie which tile is clicked), and another method translating ISO coordinates back to world coordinates.

on ISOtoWorld (me, ix, iy)

  -- returns the point in the middle of the specified tile

  wx = (ix * Tile_HalfW + basetile_offsetX) - (iy * Tile_HalfW) + hOffset

  wy = (ix * Tile_HalfH - basetile_offsetY) + (iy * Tile_HalfH) + vOffset

  return point(wx+Tile_HalfW,wy+Tile_HalfH)

end



on WorldToISO (me, wx, wy)

  -- returns ISO coordinates of a world point

  dx = wx - HOffset - Tile_W

  dy = wy - VOffset + Tile_HalfH

  x = integer((dy + dx / TileRatio) * (TileRatio / 2) / Tile_HalfW) 

  y = integer((dy - dx / TileRatio) * (TileRatio / 2) / Tile_HalfW)  

  if x < 0 or y < 0 then return 0

  if x >= (NumRows) or y >= (NumCols) then return 0

  return point(x,y)

end

If you want to get a reference to the tile object at a particular location, you could add a method like this:

on GetTile  (me, ix, iy)

  -- returns a reference to the specified tile object

  return mapList[iy+1][ix+1]

end

So, if you want to send a mouseDown message to a tile, for example, then you could create a behaviour like this

on mouseDown (me)

  p = the mouseLoc

  i = Tiler.WorldToIso(p.locH, p.locV)  

  if i.ilk = #Point then

    -- clicked a tile

    tile = Tiler.GetTile(i.locH, i.locV)

    tile.mouseDown()

  end if

end

The Full Script

Download Source Movie for the finished script.

First published 16/09/2005