Dungeon 33
Today I’m interested in learning something about how far to push the ‘tile’ design.
Vague maunderings …
To the extent that these game articles have a purpose, for me they are about trying to build software in short increments, while keeping the design habitable enough to permit continued progress. For my audience (all seven of you), I try to illuminate what I’m thinking and doing, so that you can get a view of the things that happen when a thoughtful programmer works in this fashion.
I don’t claim to be good, but maybe I’m a good example. Or a bad one. Works for me either way.
Not very ancient history …
In my first cut at this program, I had a fairly elaborate object model, with rooms, doors, connections, and such. The dungeon was an abstract construction of those things, and the drawing and the playing worked with those abstractions and rendered to the screen.
It began to come to me that this was too complicated, and the objects weren’t helping me. When objects are well-organized and useful, things go smoothly. It’s like the objects fall right to hand when we need them. When the objects aren’t helping, there’s something wrong with them.
In this case, I chose to start over with a new model, based on an array of tiles. That has worked rather nicely, and we quickly got back to a running version of the program, more capable than the earlier version, and much easier to work with.
If some’s good …
Today I want to explore pushing the tile model further. In particular, I want to explore making the objects in the program more focused on tiles and less on their “coordinates”. The monsters and the player have x and y “coordinates”, which are indices into the tile array. When we go to draw a monster, for example, it goes like this:
function Monster:draw()
pushStyle()
spriteMode(CORNER)
local gx,gy = self.runner:graphicXY(self.tileX,self.tileY)
sprite(self.sprite1, gx,gy)
end
Now this is pretty compact, but it’s odd. The monster doesn’t know what tile it is on: it knows the coordinates of the tile. And it wants to know where to draw itself on the screen, so it asks the GameRunner to give it the graphical coordinates to use:
function GameRunner:graphicXY(tileX,tileY)
return (tileX-1)*self.tileSize,(tileY-1)*self.tileSize
end
This code knows that the screen has tile (1,1) at screen coordinates (0,0), and since all tiles are of constant size, the graphical point at which to draw is as shown there.
Elsewhere, we draw the tiles:
for i,row in ipairs(self.tiles) do
for j,tile in ipairs(row) do
tile:draw()
end
end
function Tile:draw()
pushMatrix()
pushStyle()
spriteMode(CORNER)
tint(self:getTint())
local sp = self:getSprite()
local gx,gy = self.runner:graphicXY(self.x,self.y)
sprite(sp, gx,gy, self.runner.tileSize)
popStyle()
popMatrix()
end
Here again, the tile asks the GameRunner for the tile’s graphical x and y. Then it draws the suitable floor tile sprite, scaled to tile size.
All over the code there’s work being done on a pair of coordinates, something-x and something-y, either representing a tile index or a graphical address, which is a fixed function of tile size.
When things move, they decide whether to move by +1 or -1 in the x or y direction, and then they adjust their current tile coordinates and ask the game runner to move them to those coordinates. GameRunner does that by looking up the tile at those new coordinates and checking to see whether it’s a floor tile, in which case the move is OK. We could imagine it doing other checks at that point.
But it’s all about coordinate pairs. Too much about them.
At a rough count, there are around 90 lines of code in the program that are dealing with a pair of coordinates, either graphical- or tile-related, not counting tests. It has to be possible to make that better.
And I’m thinking that relying more on tiles is a big part of the answer.
A Tentative Plan
I don’t have a real plan: I intend to find my way bit by bit, and I’m prepared to have this turn out to be a bad idea, so I’ll be sure to have a revert point. But I have in mind these ideas:
- After the map is created, complete with hallways, it will be fixed. No tile will ever be replaced with a new one. This will enable:
- Player and monsters and any other entities in the system will know their tile, not their coordinates. The tile already knows its coordinates in the tile array.
- Drawing and motion will be done in terms of tiles. Moving objects will propose a direction of movement to their tile, and the tile will move them or not, by returning a new tile or the same old one. This is a callback algorithm now. I think it may come down to a simple call-return instead of a callback.
Once the basic tile info is in place, which I think it mostly is already, I’ll just go through the code changing two-coordinate focus to defer to the tiles.
It seems that this is a “large refactoring”, if there are in fact 90 places that need changing. So I’ll try to work so that the changes are all incremental and can be done bit by bit. Maybe we’ll even add in some new features while the changes go on. That should be fun.
How Will We Know Whether This Works?
We may never know, but if it works, the code should get discernibly more simple, with more referrals down to very simple bottom-level methods.
Let’s Get Started
I think I’d like to start by locking the tile array so that tiles never change after it’s set up. If I’m going to focus the live objects on tiles, it could be bad if they ever wound up looking at a tile that wasn’t in the map, and it could be bad if the map changed under them. So I want to lock out storing new tiles, and creating new ones.
We’ll do that without getting too fancy:
function setup()
if CodeaUnit then
codeaTestsVisible(true)
runCodeaUnitTests()
end
showKeyboard()
TileLock = false
Runner = GameRunner()
Runner:createRandomRooms(12)
--Runner:createTestRooms()
DisplayToggle = true
TileLock = true
end
function Tile:init(x,y,kind, runner)
assert(TileLock, "Attempt to create tile when locked")
self.x = x
self.y = y
self.kind = kind
self.runner = runner
self.sprites = {room=asset.builtin.Blocks.Brick_Grey, wall=asset.builtin.Blocks.Dirt, edge=asset.builtin.Blocks.Greystone}
end
I reckon this assert may trigger: I sort of recall that someone creates tiles at will. Easy to find out, just press the go button.
Well, to begin with we’d better unlock tiles during tests.
_:describe("Tiled Game", function()
_:before(function()
_TileLock = TileLock
TileLock = false
end)
_:after(function()
TileLock = _TileLock
end)
The next thing might be to check that we’re NOT locked:
function Tile:init(x,y,kind, runner)
assert(not TileLock, "Attempt to create tile when locked")
Now let’s see what happens … Tests run, and …
Tile:26: Attempt to create tile when locked
stack traceback:
[C]: in function 'assert'
Tile:26: in field 'init'
... false
end
setmetatable(c, mt)
return c
end:24: in function <... false
end
setmetatable(c, mt)
return c
end:20>
(...tail calls...)
GameRunner:134: in method 'moveMeIfPossible'
Monster:30: in field 'callback'
...in pairs(tweens) do
c = c + 1
end
return c
end
:158: in upvalue 'finishTween'
...in pairs(tweens) do
c = c + 1
end
return c
end
:589: in function <...in pairs(tweens) do
c = c + 1
end
return c
end
:582>
The operative info is moveMeIfPossible
. I was thinking that might do something like that. Let’s see:
function GameRunner:moveMeIfPossible(mover, x,y)
local tile = self:getTile(x,y)
if tile.kind == "room" then
mover:moveTo(x,y)
end
end
Does getTile
create a tile when out of range? I think it does:
function GameRunner:getTile(x,y)
if x<=0 or x>self.tileCountX or y<=0 or y>self.tileCountY then
return Tile:edge(x,y, self)
end
return self.tiles[x][y]
end
Yes. When you’re going off the edge, we return a new edge tile. We could cache one to use. Or we could allow edge tiles to be created will’e-nill’e. Let’s do that:
function Tile:room(x,y, runner)
assert(not TileLock, "Attempt to create room tile when locked")
return Tile(x,y,TileRoom, runner)
end
function Tile:wall(x,y, runner)
assert(not TileLock, "Attempt to create wall tile when locked")
return Tile(x,y,TileWall, runner)
end
function Tile:edge(x,y, runner)
return Tile(x,y,TileEdge, runner)
end
function Tile:init(x,y,kind, runner)
self.x = x
self.y = y
self.kind = kind
self.runner = runner
self.sprites = {room=asset.builtin.Blocks.Brick_Grey, wall=asset.builtin.Blocks.Dirt, edge=asset.builtin.Blocks.Greystone}
end
Running … everything looks good. I think I’ll allow that. Now there is a place that sets tiles:
function GameRunner:setTile(aTile)
assert(not TileLock, "attempt to set tile while locked")
self.tiles[aTile.x][aTile.y] = aTile
end
I’m pretty sure this is only used in creation, but we want to be safe. Run … we’re still good. Commit: tiles locked after setup.
Now let’s see about converting the Player to a tile focus. I apologize but we need to scan the whole class:
-- Player
-- RJ 20201205
Player = class()
PlayerSteps = {a={x=-1,y=0}, w={x=0,y=1}, s={x=0,y=-1}, d={x=1,y=0}}
function Player:__tostring()
return string.format("Player (%d,%d)", self.tileX,self.tileY)
end
function Player:init(tileX,tileY, runner)
self.tileX,self.tileY = tileX,tileY
self.runner = runner
end
function Player:distanceXY(x,y)
local dx = self.tileX-x
local dy = self.tileY-y
return math.sqrt(dx*dx+dy*dy)
end
function Player:draw()
local dx = -2
local dy = -3
pushMatrix()
pushStyle()
spriteMode(CORNER)
local gx,gy = self.runner:graphicXY(self.tileX,self.tileY)
sprite(asset.builtin.Planet_Cute.Character_Princess_Girl,gx+dx,gy+dy, 80,136)
popStyle()
popMatrix()
end
function Player:keyPress(key)
local step = PlayerSteps[key]
if step then
sx,sy = step.x,step.y
psx,psy = sx + self.tileX, sy + self.tileY
self.runner:moveMeIfPossible(self, psx,psy)
end
end
function Player:moveTo(x,y)
self.tileX = x
self.tileY = y
end
function Player:position()
return self.tileX,self.tileY
end
function Player:tileCoordinates()
return self.tileX, self.tileY
end
We see that everything we do seems to use our tileX
and tileY
variables. Let’s work right through from init, converting to know only our tile, then see what happens. The player is created here:
self:convertEdgesToWalls()
local r1 = self.rooms[1]
local rcx,rcy = r1:center()
self.player = Player(rcx,rcy,self)
self:createMonsters(5)
Let’s do this. We’ll make the Player store only a tile, but we’ll allow creation from XY with a factory method:
function Player:fromXY(tileX,tileY,runner)
local tile = runner:getTile(tileX,tileY)
return Player(tile,runner)
end
function Player:init(tile, runner)
self.tile = tile
self.runner = runner
end
Now we can just convert all the current creation calls to use fromXY
. And then we need to fix all the tileX
and tileY
in Player. Some of these we’ll just defer to our tile, I think:
function Player:distanceXY(x,y)
local dx = self.tileX-x
local dy = self.tileY-y
return math.sqrt(dx*dx+dy*dy)
end
This can defer to Tile
:
function Player:distanceXY(x,y)
return self.tile:distanceXY(x,y)
end
function Tile:distanceXY(x,y)
local dx = self.x-x
local dy = self.y-y
return math.sqrt(dx*dx+dy*dy)
end
The scary bit here is that we’re not running, so I’d better focus on that aspect first. We’re getting errors like this:
7: distance from avatar -- Player:13: attempt to index a nil value (local 'runner')
Ah, this is because we used to be able to create a Tile with a nil runner. Now we have this:
function Player:fromXY(tileX,tileY,runner)
local tile = runner:getTile(tileX,tileY)
return Player(tile,runner)
end
This is a bit nasty in my tests. Let’s see what we have:
_:test("distance from avatar", function()
local x,y = 100,100
local player = Player:fromXY(104,104)
local runner = GameRunner()
runner.player = player
local d = runner:playerDistanceXY(x,y)
_:expect(d).is(5.6,0.1)
end)
We’ll try reordering this. Might be OK, might still be a problem because the runner won’t be populated.
_:test("distance from avatar", function()
local x,y = 100,100
local runner = GameRunner()
local player = Player:fromXY(104,104, runner)
runner.player = player
local d = runner:playerDistanceXY(x,y)
_:expect(d).is(5.6,0.1)
end)
That one passes. What’s next?
_:test("tint as function of distance from avatar", function()
local tile
local tint
local player = Player:fromXY(100,100)
local disp = DisplayToggle
DisplayToggle = true
local runner = GameRunner()
runner.player = player
tile = Tile:room(100,100, runner)
tint = tile:getTint()
_:expect(tint.r).is(255)
tile = Tile:room(107,100, runner)
tint = tile:getTint()
_:expect(tint.r).is(0)
tile = Tile:room(150,150, runner)
tint = tile:getTint()
_:expect(tint.r).is(0)
DisplayToggle = disp
end)
I’ll try the same thing here: creating the player soon enough to use it there at the top.
_:test("tint as function of distance from avatar", function()
local tile
local tint
local runner = GameRunner()
local player = Player:fromXY(100,100,runner)
local disp = DisplayToggle
DisplayToggle = true
...
And that works. Now:
_:test("player direction", function()
local dx,dy
local runner = GameRunner()
local player = Player:fromXY(100,100)
runner.player = player
dx,dy = runner:playerDirection(101,101)
_:expect(dx).is(-1)
_:expect(dy).is(-1)
dx,dy = runner:playerDirection(98,98)
_:expect(dx).is(1)
_:expect(dy).is(1)
end)
I’ll try passing the runner in … but we get a new error:
9: player direction -- GameRunner:142: attempt to perform arithmetic on a nil value (global 'tx')
That tx
is surely going to be a tile x:
function GameRunner:playerDirection(x,y)
tx,ty = self.player:position()
return sign(tx-x), sign(ty-y)
end
Now, you know what? This would be better if we were asking for player direction from a tile, not from an x,y. Then we’d defer the whole thing to Tile.
Let’s see where this is used:
function Monster:proposeMoveTowardAvatar()
local dx,dy = self.runner:playerDirection(self.tileX, self.tileY)
print(dx,dy)
if math.random() < 0.5 then
return 0,dy
else
return dx,0
end
end
Let’s see. Right now the monster doesn’t know its tile. Let’s provide a method, not an accessor, so that it does:
OK, I’m going to try something tricky here. I’ll make a save point.
Even so, what I have in mind is too big a risk. I’ll go in small steps. What I had in mind was to return a new kind of thing, a “direction” from this call, or even two proposed tiles. One small bite at a time though:
function Monster:proposeMoveTowardAvatar()
local dx,dy = self.runner:playerDirection(self:getTile())
print(dx,dy)
if math.random() < 0.5 then
return 0,dy
else
return dx,0
end
end
I’m positing the new method getTile
:
function Monster:getTile()
return self.tile or self.runner:getTile(self.tileX,self.tileY)
end
When we finally get around to creating monsters with tiles instead of x and y, this will just return the tile. Until then we fetch it. I chose not to cache it: that’s not our job.
Now to change playerDirection
:
function GameRunner:playerDirection(aTile)
return aTile:directionTo(self.player:getTile())
end
function Tile:directionTo(aTile)
tx,ty = aTile:position()
return sign(tx-self.x), sign(ty-self.y)
end
I wrote getTile()
for Player:
function Player:getTile()
return self.tile
end
We’re starting to define a standard: to get some object’s tile, send getTile
. However, we often don’t really want the tile, so we need to look at some of these for refinement. Right now, though, we’re trying to get wired up to the point where we can release the game.
I modify this test:
_:test("player direction", function()
local dx,dy
local runner = GameRunner()
local player = Player:fromXY(100,100, runner)
runner.player = player
local tile = Tile:edge(101,101)
dx,dy = runner:playerDirection(tile)
_:expect(dx).is(-1)
_:expect(dy).is(-1)
tile = Tile:edge(98,98)
dx,dy = runner:playerDirection(tile)
_:expect(dx).is(1)
_:expect(dy).is(1)
end)
9: player direction -- Tile:48: attempt to call a nil value (method 'position')
Apparently tiles don’t know position, hm.
function Tile:position()
return self.x,self.y
end
Tests are green! Run time error:
Main:41: attempt to perform arithmetic on a nil value (local 'x')
stack traceback:
Main:41: in function 'draw'
function draw()
pushMatrix()
if CodeaUnit then showCodeaUnitTests() end
if DisplayToggle then
local x,y = Runner.player:tileCoordinates()
local gx,gy = x*Runner.tileSize,y*Runner.tileSize
focus(gx,gy, 1)
else
scale(0.25)
end
Runner:draw()
popMatrix()
end
Ah, this is nice. We want to know the graphic coordinates of the player. Let’s ask her:
function draw()
pushMatrix()
if CodeaUnit then showCodeaUnitTests() end
if DisplayToggle then
local gx,gy = runner.player:graphicCoordinates()
focus(gx,gy, 1)
else
scale(0.25)
end
Runner:draw()
popMatrix()
end
function Player:graphicCoordinates()
return self:getTile():graphicCoordinates()
end
function Tile:graphicCoordinates()
return self.runner:graphicXY(self.x,self.y)
end
I’m not entirely sure that I want to forward this back to runner, but for now, it has to be. Next error:
Main:40: attempt to index a nil value (global 'runner')
stack traceback:
Main:40: in function 'draw'
Forgot to capitalize Runner.
function draw()
pushMatrix()
if CodeaUnit then showCodeaUnitTests() end
if DisplayToggle then
local gx,gy = Runner.player:graphicCoordinates()
focus(gx,gy, 1)
else
scale(0.25)
end
Runner:draw()
popMatrix()
end
Next error:
GameRunner:112: attempt to perform arithmetic on a nil value (local 'tileX')
stack traceback:
GameRunner:112: in method 'graphicXY'
Player:32: in method 'draw'
GameRunner:90: in method 'draw'
Main:45: in function 'draw'
function Player:draw()
local dx = -2
local dy = -3
pushMatrix()
pushStyle()
spriteMode(CORNER)
local gx,gy = self.runner:graphicXY(self.tileX,self.tileY)
sprite(asset.builtin.Planet_Cute.Character_Princess_Girl,gx+dx,gy+dy, 80,136)
popStyle()
popMatrix()
end
We have a better way to get our graphic coordinates now:
function Player:draw()
local dx = -2
local dy = -3
pushMatrix()
pushStyle()
spriteMode(CORNER)
local gx,gy = self:graphicCoordinates()
sprite(asset.builtin.Planet_Cute.Character_Princess_Girl,gx+dx,gy+dy, 80,136)
popStyle()
popMatrix()
end
Nearly good, and I’m not getting any more tracebacks. Let’s see if the ghosties are working. No. Princess can’t walk yet:
Player:50: attempt to perform arithmetic on a nil value (field 'tileX')
stack traceback:
Player:50: in method 'keyPress'
GameRunner:130: in method 'keyPress'
Main:50: in function 'keyboard'
We should go back to checking her for uses of tileX
and tileY
. I decide to give her two new methods:
function Player:keyPress(key)
local step = PlayerSteps[key]
if step then
sx,sy = step.x,step.y
psx,psy = sx + self:tileX(), sy + self:tileY()
self.runner:moveMeIfPossible(self, psx,psy)
end
end
No, wait, we have that nice tileCoordinates
method. And in fact I have two methods doing the same thing:
function Player:position()
return self.tileX,self.tileY
end
function Player:tileCoordinates()
return self.tileX, self.tileY
end
For now, one calls the other:
function Player:position()
return self:tileCoordinates()
end
function Player:tileCoordinates()
return self.tileX, self.tileY
end
We’ll dig out users of position
in a moment but we’re on a red bar.
function Player:keyPress(key)
local step = PlayerSteps[key]
if step then
sx,sy = step.x,step.y
tx,ty = self:tileCoordinates()
psx,psy = sx + tx, sy + ty
self.runner:moveMeIfPossible(self, psx,psy)
end
end
And fix the function:
function Player:tileCoordinates()
return self.tile:tileCoordinates()
end
function Tile:position()
return self:tileCoordinates()
end
function Tile:tileCoordinates()
return self.x,self.y
end
Here, too, I find the method position
. I’m moving toward using tileCoordinates
as it is more clear. Even more clear might be mapCoordinates
. We’ll see about that. We’re on a mission to let the Princess move.
She doesn’t crash, but she doesn’t move. I’ve done something wrong:
function Player:keyPress(key)
local step = PlayerSteps[key]
if step then
sx,sy = step.x,step.y
tx,ty = self:tileCoordinates()
psx,psy = sx + tx, sy + ty
self.runner:moveMeIfPossible(self, psx,psy)
end
end
Ah. The call back:
function Player:moveTo(x,y)
self.tileX = x
self.tileY = y
end
We need to reset our tile now. And I’m going to want to unwind this as well. Let me make this work and we’ll kick these ideas around a bit.
function Player:moveTo(x,y)
self.tile = self.runner:getTile(x,y)
end
That does it. The princess can move again. Game works.
Commit: Player converted to have only tile. Still can produce tileX and tileY.
Kicking Back and Around
OK, that took a little while, but wasn’t difficult. It’s a bit distressing that so much of the discovery of places needing to be changed came from failures at run time. I’m sure that some of those difficulties could have been covered by tests, and if they could be, and we encountered them at run time, they probably should be covered by tests.
A larger question is “how’s it going?” We’ve managed to remove two member variables from Player, replacing them with one, a Tile. We haven’t exactly made the code simpler. We are deferring more and more calls down to Tile, but the Tile defers four times back up to the GameRunner:
function Tile:draw()
pushMatrix()
pushStyle()
spriteMode(CORNER)
tint(self:getTint())
local sp = self:getSprite()
local gx,gy = self.runner:graphicXY(self.x,self.y)
sprite(sp, gx,gy, self.runner.tileSize)
popStyle()
popMatrix()
end
function Tile:getTint()
if not DisplayToggle then return color(255) end
local d = self.runner:playerDistanceXY(self.x,self.y)
local t = math.max(255-d*255/7,0)
return color(t,t,t,255)
end
function Tile:graphicCoordinates()
return self.runner:graphicXY(self.x,self.y)
end
function Tile:hasRoomNeighbor()
local ck = { {1,0},{1,1},{0,1},{-1,1}, {-1,0},{-1,-1},{0,-1},{1,-1}}
for i,p in ipairs(ck) do
local tx,ty = self.x +p[1], self.y + p[2]
local tile = self.runner:getTile(tx,ty)
if tile:isRoom() then return true end
end
return false
end
It’s weird to call all the way down to an elementary object like Tile and then call back up. This is telling us something, perhaps more than one thing. We rely on runner for these facts:
- Converting a tile coordinate to a graphical one. This requires knowing only tileSize, which we probably wish we knew anyway.
- Getting the distance to another tile (by its x and y). Clearly tiles should know how to do this.
- Determining whether a neighbor cell is of room type. Again it seems the tile should be able to do this.
I think that one “solution” is that the GameRunner shouldn’t process the tiles array after it’s created. (And quite possibly, creation should be done by another object entirely.) Once the map is created, all the tiles should know the map. The GameRunner may also need to know it, so as to draw things, but most of the attention to the map should be down in the tiles.
This is a guess, of course, but it’s consistent with my fundamental idea that pushing responsibility to Tile will be a good thing.
I have other dreams about this. A lot of the messing with tile coordinates is about moving from the tile something is on to another adjacent tile, or sometimes in the direction of a tile (when a monster chases the player). So I’m of a mind to move toward having things like the moveMeIfPossible
be couched in terms of tiles, not coordinates. That may or may not make me want to have code that thinks more in terms of north south west east than plus or minus one.
In fact, I’ve got some energy and momentum. Let’s take a look at that notion. First, let’s get the map pushed down into all the tiles:
function Tile:init(x,y,kind, runner)
self.x = x
self.y = y
self.kind = kind
self.runner = runner
self.map = runner:getMap()
self.sprites = {room=asset.builtin.Blocks.Brick_Grey, wall=asset.builtin.Blocks.Dirt, edge=asset.builtin.Blocks.Greystone}
end
function GameRunner:getMap()
return self.tiles
end
This is the live collection of tiles. By the time the game starts running, it will be complete. Let’s see how we can use it:
function Tile:hasRoomNeighbor()
local ck = { {1,0},{1,1},{0,1},{-1,1}, {-1,0},{-1,-1},{0,-1},{1,-1}}
for i,p in ipairs(ck) do
local tx,ty = self.x +p[1], self.y + p[2]
local tile = self:getTile(tx,ty)
if tile:isRoom() then return true end
end
return false
end
function Tile:getTile(x,y)
end
Ah. GameRunner has some special code for this function:
function GameRunner:getTile(x,y)
if x<=0 or x>self.tileCountX or y<=0 or y>self.tileCountY then
return Tile:edge(x,y, self)
end
return self.tiles[x][y]
end
We don’t want tiles knowing all those numbers. Putting the map down here doesn’t help enough, at least not right now. If it were a smarter object, it might help more.
Revert. Not every idea is great. This one may have its time in the sun but that time is not now.
I’d still prefer to be deferring these decisions to an object smaller than GameRunner, and further down in the hierarchy rather than at the top. I don’t like calling all the way down and then back up, though it works just fine.
What if we had the tiles ask for and cache their graphical address? Would that do anything for us? Not really, I guess. We’d reduce the number of calls, but we’d still have the bottom up to top connection.
There’s some kind of object trying to be born here. Maybe it’s a “graphic converter” or a “lens”. Maybe it’s even a Map, a lightweight thing that knows just enough to convert from map coordinates to screen.
I don’t know. Wait and see. Is there something else in Tile that we can improve?
There’s this:
function Tile:position()
return self:tileCoordinates()
end
function Tile:tileCoordinates()
return self.x,self.y
end
Let’s find the calls to ‘position’ and direct them to tileCoordinates
, then remove position.
function Tile:directionTo(aTile)
tx,ty = aTile:position()
return sign(tx-self.x), sign(ty-self.y)
end
LOL, that’s us. Easy:
function Tile:directionTo(aTile)
tx,ty = aTile:tileCoordinates()
return sign(tx-self.x), sign(ty-self.y)
end
The only other use of position
that I can find is in Player, which has nothing to do with us:
function Player:position()
return self:tileCoordinates()
end
We can delete that, no one calls it. Commit: remove use and definitions of position.
A bit more perhaps …
function Tile:draw()
pushMatrix()
pushStyle()
spriteMode(CORNER)
tint(self:getTint())
local sp = self:getSprite()
local gx,gy = self.runner:graphicXY(self.x,self.y)
sprite(sp, gx,gy, self.runner.tileSize)
popStyle()
popMatrix()
end
We have a method that does that call:
function Tile:graphicCoordinates()
return self.runner:graphicXY(self.x,self.y)
end
Use it.
function Tile:draw()
pushMatrix()
pushStyle()
spriteMode(CORNER)
tint(self:getTint())
local sp = self:getSprite()
local gx,gy = self:graphicCoordinates()
sprite(sp, gx,gy, self.runner.tileSize)
popStyle()
popMatrix()
end
So that’s OK. I noticed this:
function Monster:draw()
pushStyle()
spriteMode(CORNER)
local gx,gy = self.runner:graphicXY(self.tileX,self.tileY)
sprite(self.sprite1, gx,gy)
popStyle()
end
We want to defer these things downward, even though they may ring back up for now. So:
function Tile:draw()
pushMatrix()
pushStyle()
spriteMode(CORNER)
tint(self:getTint())
local sp = self:getSprite()
local gx,gy = self:graphicCoordinates()
sprite(sp, gx,gy, self.runner.tileSize)
popStyle()
popMatrix()
end
We wrote that getTile
earlier. Not sure why, seems a bit premature, but there it is, ready to use. What about this:
function Tile:getTint()
if not DisplayToggle then return color(255) end
local d = self.runner:playerDistanceXY(self.x,self.y)
local t = math.max(255-d*255/7,0)
return color(t,t,t,255)
end
Well, we don’t know the player here but we could and should ask the player. Let’s see what runner does:
function GameRunner:playerDistanceXY(x,y)
return self.player:distanceXY(x,y)
end
Ah, good. What else? Ah. I noticed that the ghost creature faces left, which is fine if he’s to the right of the princess, but if he’s to her left it would be nice if he faced the other way. So I wondered whether setting scale could flip a sprite, and tried this:
function Monster:draw()
pushMatrix()
pushStyle()
spriteMode(CORNER)
local gx,gy = self:getTile():graphicCoordinates()
translate(gx,gy)
scale(-1,1)
sprite(self.sprite1, 0,0)
translate(-gx,-gy)
popStyle()
popMatrix()
end
The trick here is to translate to where the ghost is, flip the x axis by scaling it to -1, draw him, then translate back. Works just fine: now the ghost faces right. I’ll deal with flipping him dynamically at some future time.
Commit: flip ghost to left.
OK, enough for today. A quick summary and I’m outa here.
Summary
The idea of pushing more functionality down into Tile is working, but so far I don’t feel that we’re reaping much in the way of benefit. We’ll know we’re getting benefit as we find ourselves with less and less code referring to two coordinates x and y.
Early days.
A fundamental point is that this refactoring isn’t by any means done, yet the program is running just fine, and we can ship it freely. There was brief period of less than an hour where it was broken, but it came quickly back.
That said, better tests would have made the refactoring easier. As it stands, had I not tested fairly extensively during game play, I might have missed some errors that occurred when moving the princess, or when monsters got close to her. Yes, before shipping, we should probably do extensive game play … but if the tests were good enough, we really shouldn’t need that.
Is it possible for tests to be that good? Yes, it is. I’ve seen it. This, however, is not that case. In real code, even in an app as small as this one, more than one layer of defense is probably needed.
We’ll press forward with this refactoring. See you next time!