Just a quick aside: I’ve now spent more time writing the post-mortem than the challenge itself, and I’m only about halfway done. Okay, moving on.
By the end of part two, we had an object representing the game field, a way to make new blocks and to rotate them, and I had described the process by which I would simulate rising blocks. Now let’s look at the logic for whether a proposed position of a block is a valid location. This is essentially the meat of Loftis, right here:
function Loftis:IsPositionValid(tBlock, iRow, iColumn) local tGame = self.tGame for y = 1,4 do for x = 1,4 do if tBlock[y][x] then if iRow + y - 1 < 1 or iRow + y - 1 > 28 or iColumn + x - 1 < 1 or iColumn + x - 1 > 10 then return false end if tGame.arField[iRow + y - 1][iColumn + x - 1] then return false end end end end return true end
This is pretty straightforward, but it is made a little less clear because of Lua's one-based nature, which is something that I curse on an almost-daily basis. Let's look at what's going on, because this is the function that really determines what happens next at any given point.
Basically what I'm doing here is testing the passed block (tBlock), which is represented by a 4x4 array of boolean values, and testing whether the position passed in (iRow, iColumn) is valid. The way to test that is simple: for each of those sixteen booleans that are true, if the corresponding block in the game's field of blocks is also true, the block cannot be placed there. Also, if the corresponding "on" block would be outside the game field, (x < 1 or x > 10) then the block cannot be placed in the proposed position.
IsPositionValid is called 6 times in Loftis. The first one I wrote is right after creating a new block:
if not self:IsPositionValid(tBlock, 24, 5) then self:DoGameOver() end tGame.tBlock = tBlock tGame.iColumn = 4 tGame.iRow = 24
If it is time to create a new block, and a new block cannot be placed, it's because the field has been filled up with blocks, so in that case, the game is over. And hey look! Remember when I talked about magic numbers before? For those who aren't familiar with the programmer lingo, a "magic number" is a hard-coded literal constant. In this case 24, 5, 24, and.... 4! If you are understanding the code I am writing here, you may have just realized I have a bug in Loftis, a bug I just found in this post-mortem!
Even during the challenge I knew I shouldn't have been using magic numbers. I tweeted this at the 2:25 mark:
This rather conveniently illustrates the exact danger in writing magic numbers. Obviously I had started the new block in column 5, and then realized it needs to be in column 4 instead, but since I used literal 5s when writing the code, changing it required me to change all the instances of 5 when I was referring to the starting column. But I clearly forgot to do that in the call to IsPositionValid. If I had taken the time to create a constant like this:
local kStartCol = 4 local kStartRow = 24
and written the code like this:
if not self:IsPositionValid(tBlock, kStartRow, kStartCol) then self:DoGameOver() end tGame.tBlock = tBlock tGame.iColumn = kStartCol tGame.iRow = kStartRow
There would have been no bug when I decided that starting column needed to be 4 instead of 5. I really couldn't have found a better illustration as to why magic numbers are so bad. Don't use them! Even in 4-hour coding challenges, in case you are ever in one...
Let's move on to the next usage.
if tGame.fProgress >= 1.0 then if tGame.iRow > 1 and self:IsPositionValid(tGame.tBlock, tGame.iRow - 1, tGame.iColumn) then tGame.fProgress = 0 tGame.iRow = tGame.iRow - 1 else self:PlaceBlock() return end end
This second use of IsPositionValid is called after the progress for the current block has exceeded 1.0. When it does so, we check to see if it is still possible to fall or if it has come to rest on another (higher) block. If the new position check fails, it's time to place the block. If it succeeds, we update our new row (subtracting one) and reset progress to be 0.0.
After I had written this code it was possible for me to start a game and watch blocks rise to the top, collide, and get placed. I sent out this tweet:
After that there were two calls to handle attempts to move left and right. These were very straightforward:
if self:IsPositionValid(tGame.tBlock, tGame.iRow, tGame.iColumn - 1) then tGame.iColumn = tGame.iColumn - 1 end if self:IsPositionValid(tGame.tBlock, tGame.iRow, tGame.iColumn + 1) then tGame.iColumn = tGame.iColumn + 1 end
Check the space on your left or right. Can we move there? If so, cool, do it. If not, do nothing. In a more polished game, we might play a buzz sound here when we fail, but not in a 4-hour challenge.
Finally the last two calls to IsPositionValid are used when attempting to rotate the current block. Remember how I said I rewrote RotateBlockLeft and RotateBlockRight to return a new block instead of changing the current block in place? This is why. In
Tetris Loftis, it's possible that your block could be in a position where attempting to rotate would cause the block to collide with the field. This is how I handle it:
function Loftis:OnRotateLeft( wndHandler, wndControl, eMouseButton ) local tGame = self.tGame local tNewBlock = RotateBlockLeft(tGame.tBlock) if self:IsPositionValid(tNewBlock, tGame.iRow, tGame.iColumn) then tGame.tBlock = tNewBlock end self:UpdateBlockPixies() end
I call my rotate function which returns a rotated copy of my current block. I then check to see if that new block's position/orientation is valid. If it is, I assign it to be the current block. (The old current block loses its reference and is collected here. Thanks garbage collector!) If the new block is not valid, I do nothing and it is collected when I exit the function.
Okay, that does it for today. I should be able to finish this thing up tomorrow.