Dungeon Digression
Today I want to step back and write about screen transformations, translate and rotate. They are fairly natural to me, but some folks are not so familiar with these powerful tools.
Most graphical drawing libraries include some form of “transform” capability, including operations like translate, scale, flip, rotate, and so on. These operations are very valuable, in that they allow us to write much simpler code than we otherwise could.
Today I hope to produce a simple yet fun demonstration of just how these operations can be used.
We’ll start with some simple pictures. We’ll use a square, 64x64.
I’ve created a minimal Codea project, named xform. To begin, I’ll draw our 64x64 square at the obvious coordinates, 0,0.
rectMode(CENTER)
background(23, 223, 236)
fill(255)
stroke(255)
rect(0,0,64,64)
That gives us a square down in the lower left corner. It’s only 32x32, because most of it is off the screen. I have rectMode
set to CENTER
, which means that the square will have its center at 0,0, and will therefore draw 32 pixels in each direction.
We could draw our square at the middle of the screen by changing the 0,0 to WIDTH/2, HEIGHT/2
, but no. Instead we want to move the 0,0 location to the center of the screen. We’ll use the translate
function of Codea. Other graphical systems will have similar operations of similar names.
Translate slides the screen. The Codea documentation says, in part:
Translates all subsequent drawing operations by the specified x and y values. Translations are cumulative, so a call to translate( 50, 0 ) followed by a call to translate( 10, 10 ) will translate all subsequent drawing operations by 60, 10.
function draw()
rectMode(CENTER)
background(23, 223, 236)
fill(255)
stroke(255)
translate(WIDTH/2, HEIGHT/2)
rect(0,0,64,64)
end
Note that the rect is still drawn at 0,0. But the picture now looks like this:
Nice. This means that we can put a square tile anywhere on the screen, without changing how we draw it.
But wait, there’s more. Let’s extract our little square-drawing line of code into a tiny class, which we’ll then enhance with some decorations.
function draw()
rectMode(CENTER)
background(23, 223, 236)
fill(255)
stroke(255)
translate(WIDTH/2, HEIGHT/2)
local t1 = Tile()
t1:draw()
end
Tile = class()
function Tile:draw()
fill(255)
stroke(23,223,236)
rect(0,0,64,64)
end
The picture is the same. Trust me.
I decided to make the tile’s stroke color the same as the background, copying off a paper I saw someplace.
Now let’s draw a few tiles in a row, just to see how we might do it.
function draw()
rectMode(CENTER)
background(23, 223, 236)
fill(255)
stroke(255)
translate(WIDTH/2, HEIGHT/2)
local t1 = Tile()
t1:draw()
translate(64,0)
t1:draw()
translate(64,0)
t1:draw()
end
Now we get this picture:
We get those nice lines between because I changed the stroke color to match the background. Naturally, we’d extract a bunch of these constants, but that’s not today’s mission.
Now I want to have some rooms have doors between them. I want to represent a door by a moderately thick line along that wall, with a square in the middle. You’ll see what I mean in a moment.
Naturally we want doors to be able to appear on any and all of the sides of any room. That means that our class needs a bit more ability. Let’s start with the ability to add a door on the left side, just because I feel like it’s easiest.
I start by drawing the tall part, this way:
function draw()
rectMode(CENTER)
background(23, 223, 236)
fill(255)
stroke(255)
translate(WIDTH/2, HEIGHT/2)
local t1 = Tile()
local t2 = Tile()
t2:addDoor()
t1:draw()
translate(64,0)
t2:draw()
translate(64,0)
t1:draw()
end
function Tile:drawDoor()
if self.door then
fill(0)
rect(0,0, 8, 64)
end
end
The wall part of the door is a rectangle, centered at 0,0 (remember, we’re using rectMode CENTER) and it is 8 wide and 64 high. That draws, unsurprisingly, in the center of the center tile.
But that isn’t what we want. We want the door drawn on the left. But let’s finish its look first, with the other square in the middle:
function Tile:drawDoor()
if self.door then
fill(0)
rect(0,0, 8, 64)
rect(0,0, 16,16)
end
end
That gives us this:
Perfect, except not on the left. Easily fixed, we’ll just translate a bit further.
function Tile:drawDoor()
translate(-32,0)
if self.door then
fill(0)
rect(0,0, 8, 64)
rect(0,0, 16,16)
end
translate(32,0)
end
We slide the center to the left 32, half a square, draw, then slide the picture back. We get this:
Cool. We also realize something: since we’re drawing our squares left to right, a door on the right side of the middle tile would get covered by the right hand tile. Let me demonstrate, by drawing the door on the right of our middle tile.
function Tile:drawDoor()
rotate(180)
translate(-32,0)
if self.door then
fill(0)
rect(0,0, 8, 64)
rect(0,0, 16,16)
end
translate(32,0)
rotate(180)
end
We see the problem, the door’s cut in half, because the rightmost tile drew over it. We really need to draw doors separately from the tile itself. But first, what about those two rotate
deals?
The first rotate(180)
rotates the whole picture upside down, around the current center, which is the center of the current tile. So now when we draw the door, the code thinks it’s still drawing on the left, but we know it’s actually drawing on the right. (And upside down, but that doesn’t affect our door.) Then we back out our rotation, as we did with our translation, so that no one after us is affected.
In Codea we have a better way to do and then undo a transformation, the operations pushMatrix
and popMatrix
. They’re called that because translations and rotations and scaling and all that jazz are done with matrix transformation operations way down under the covers someplace. So we can improve our code with this:
function Tile:drawDoor()
pushMatrix()
rotate(180)
if self.door then
translate(-32,0)
fill(0)
rect(0,0, 8, 64)
rect(0,0, 16,16)
end
popMatrix()
end
That gives us the same picture, with the door sliced off by the tile on the right. Let’s accept the bad news, that we have to draw the doors after we draw the tiles. We’ll remove the automatic drawing of the door from Tile:draw()
and do it explicitly:
function draw()
rectMode(CENTER)
background(23, 223, 236)
fill(255)
stroke(255)
translate(WIDTH/2, HEIGHT/2)
local t1 = Tile()
local t2 = Tile()
t2:addDoor()
local t3 = Tile()
t1:draw()
translate(64,0)
t2:draw()
translate(64,0)
t3:draw()
translate(-128,0)
t1:drawDoor()
translate(64,0)
t2:drawDoor()
translate(64,0)
t3:drawDoor()
end
This works as intended:
But wow, all those translates, including that nasty -128 one to move us back to the beginning of the line. We clearly need for the Tile to handle that for us. Let’s have the Tile know its center:
function draw()
rectMode(CENTER)
background(23, 223, 236)
fill(255)
stroke(255)
translate(WIDTH/2, HEIGHT/2)
local t1 = Tile(0,0)
local t2 = Tile(64,0)
t2:addDoor()
local t3 = Tile(128,0)
t1:draw()
t2:draw()
t3:draw()
t1:drawDoor()
t2:drawDoor()
t3:drawDoor()
end
Here, we just pass in the desired centers, then when we want to draw something, tell the tile to do the job. The Tile needs to remember, and use, those coordinates:
function Tile:init(x,y)
self.x = x
self.y = y
end
function Tile:draw()
pushMatrix()
translate(self.x, self.y)
fill(255)
stroke(23,223,236)
rect(0,0,64,64)
popMatrix()
end
function Tile:addDoor()
self.door = true
end
function Tile:drawDoor()
pushMatrix()
translate(self.x, self.y)
rotate(180)
if self.door then
translate(-32,0)
fill(0)
rect(0,0, 8, 64)
rect(0,0, 16,16)
end
popMatrix()
end
And the picture is the same:
Now of course we really don’t want to draw each tile explicitly. Let’s put them in a collection:
function draw()
rectMode(CENTER)
background(23, 223, 236)
fill(255)
stroke(255)
translate(WIDTH/2, HEIGHT/2)
local t1 = Tile(0,0)
local t2 = Tile(64,0)
t2:addDoor()
local t3 = Tile(128,0)
local tiles = {t1,t2,t3}
for _,tile in ipairs(tiles) do
tile:draw()
end
for _,tile in ipairs(tiles) do
tile:drawDoor()
end
end
Same thing, same picture. Let’s have a square of tiles instead of just three.
function draw()
rectMode(CENTER)
background(23, 223, 236)
fill(255)
stroke(255)
translate(WIDTH/2, HEIGHT/2)
local tiles = {}
for y = 0,2 do
for x = 0,2 do
local xx = x*64
local yy = y*64
tiles[#tiles+1] = Tile(xx,yy)
end
end
tiles[2]:addDoor()
for _,tile in ipairs(tiles) do
tile:draw()
end
for _,tile in ipairs(tiles) do
tile:drawDoor()
end
end
Now we get this picture:
We notice that we’re creating these tiles every time through the draw loop, so we extract that out to setup, and renaming the tile array for clarity that we’re using a global:
function setup()
TileArray = {}
for y = 0,2 do
for x = 0,2 do
local xx = x*64
local yy = y*64
TileArray[#TileArray+1] = Tile(xx,yy)
end
end
TileArray[2]:addDoor()
end
function draw()
rectMode(CENTER)
background(23, 223, 236)
fill(255)
stroke(255)
translate(WIDTH/2, HEIGHT/2)
for _,tile in ipairs(TileArray) do
tile:draw()
end
for _,tile in ipairs(TileArray) do
tile:drawDoor()
end
end
Still the same nice picture of nine tiles. And, finally, we’ll get around to the main point of this exercise. Let’s suppose that what we want is for the center tile of this array, number 5 (because we start at 1 in Lua) to have doors on all four sides, and (for now) no other doors in the picture.
We need to teach our Tile a bit more about doors. It can have from zero to four doors. We’ll call them 1,2,3, and … you guessed it … 4. And, let’s have door number 1 be on the right side, door number 2 on the top, and so on.
So addDoor will take a parameter for door number.
I’ll start with just my original door on the right of room 2. That’s door number 1, so:
function setup()
TileArray = {}
for y = 0,2 do
for x = 0,2 do
local xx = x*64
local yy = y*64
TileArray[#TileArray+1] = Tile(xx,yy)
end
end
TileArray[2]:addDoor(1)
end
I change init to have a table of doors (this table could be sparse):
function Tile:init(x,y)
self.x = x
self.y = y
self.doors = {}
end
I modify addDoor
to track which doors have been added:
function Tile:addDoor(doorNumber)
self.doors[doorNumber] = true
end
And finally, drawDoor, which we should rename to drawDoors:
function Tile:drawDoor()
pushMatrix()
translate(self.x, self.y)
rotate(180)
for doorNumber = 1,4 do
if self.doors[doorNumber] then
pushMatrix()
rotate(90*(doorNumber-1))
translate(-32,0)
fill(0)
rect(0,0, 8, 64)
rect(0,0, 16,16)
popMatrix()
end
end
popMatrix()
end
This is a bit odd, because we have our initial rotate 180 that moved the door over to the right. We can and will fix that up real soon now, but first let’s get the picture we want. I’ll leave the door in 2, just because, and add doors to 5:
function setup()
TileArray = {}
for y = 0,2 do
for x = 0,2 do
local xx = x*64
local yy = y*64
TileArray[#TileArray+1] = Tile(xx,yy)
end
end
TileArray[2]:addDoor(1)
TileArray[5]:addDoor(1)
TileArray[5]:addDoor(2)
TileArray[5]:addDoor(3)
TileArray[5]:addDoor(4)
end
And the picture is:
Now let’s improve this code just a bit, so that we can remove the 180 rotate. We’ll do that by making the door draw directly on the right side:
function Tile:drawDoor()
pushMatrix()
translate(self.x, self.y)
for doorNumber = 1,4 do
if self.doors[doorNumber] then
pushMatrix()
rotate(90*(doorNumber-1))
translate(32,0)
fill(0)
rect(0,0, 8, 64)
rect(0,0, 16,16)
popMatrix()
end
end
popMatrix()
end
Wait, what did he do? He removed the rotate 180, and changed the translate to (32,0) from (-32,0). That moved the door to the right. Since it’s symmetric, that’s all we need.
Where are we? Where we are is that we have absolutely constant door-drawing code:
rect(0,0, 8, 64)
rect(0,0, 16,16)
That just draws those two black rectangles. But we can use that unmodified code to draw a door on any wall of any tile. We do it all by sliding the picture with translate, to the center of the tile we’re interested in, then rotating around that center until the wall we want to draw on is “on the right”, then drawing, then undoing all that sliding and rotating.
Want another door, between rooms 7 and 4 i.e. on the south of room 7? Easy, we add this line to setup.
TileArray[7]:addDoor(4)
That’s how you do that.
If after a bit of thought this isn’t crystal clear, please tweet me or email me with questions and I’ll try to clarify this article or do a further one.
Inner Digression
A question one might ask relates to the translation when we draw the door. We do this:
function Tile:drawDoor()
pushMatrix()
translate(self.x, self.y)
for doorNumber = 1,4 do
if self.doors[doorNumber] then
pushMatrix()
rotate(90*(doorNumber-1))
translate(32,0)
fill(0)
rect(0,0, 8, 64)
rect(0,0, 16,16)
popMatrix()
end
end
popMatrix()
end
We could just as well change the 0,0 coordinates in the two calls to rect
to self.x, self.y
. An efficiency fanatic might do that, thinking it would be faster than calling translate. It might or might not be, because the call to translate just updates the matrix and the matrix is always applied.
Still, it’s a judgment call for this case. In the case of the doors, the rotate
is a clear winner, because it moves whatever stuff we draw without updating the values in the calls.
My preference, when I’m on my game, is to do everything the same way, that is always to use the translate
and rotate
. But I am often not on my game and wind up with a mixture. Even then, I try to use the transforms instead of any kind of calculations inside the draw function.
You’ll see in my real code that it isn’t perfect. This code isn’t perfect eiher. I work toward better, not perfect, because I have a decent shot at better and no chance at all for perfect.
End Inner Digression … but …
One more thing. Note that I’ve not yet defined north south east west, though we can see that 1 = east and 2 = north and so on. I might find it convenient for my test code here to know those values, but in the dungeon program itself, I think the question of compass direction, or up-screen down-screen, may never occur. In fact, I’d try to make sure that it never did.
Soon, back to our regularly scheduled program. See you then!