Dungeon 34
More pushing things to Tile. I’m required by law to publish this but I’m not required to like it.
I’ve decided that since tile size doesn’t change during a run of the program, and presently doesn’t change at all, and since the layout of tiles intentionally has its origin on the screen at 0,0, it is OK for tiles to know how to provide their graphic coordinates. All the scaling and translation will be done above that level in any case. This should make things nicer.
I’m also inclined to use either a tiny object, or a vec2, to represent coordinate, just to avoid passing around so many pairs of values. I’m not sure that’s a good idea, and want to look at the code to see whether a new object would really help.
Finally, I’d like the tiles to know the tile array. This will require moving the edge-protection into Tile, at least for now, but we may want another small object (Map?) to manage that access.
Anyway, I’m going in.
A quick glance at Tile with my eyes open tells me that Tile already has a convenient variable defined:
Tile = class()
local TileWall = "wall"
local TileRoom = "room"
local TileEdge = "edge"
local TileSize = 16
...
So at some point I must have had something like this in mind. Forwarding up into the GameRunner has some appeal, since changes to things like that would emanate from high levels, but that’s not going to happen and if it does we’ll deal with it then. If our objects are cohesive and loosely coupled, it should go fine if it happens, which it won’t.
That value should be 64, so I changed it.
In Tile:
function Tile:graphicCoordinates()
return self.runner:graphicXY(self.x,self.y)
end
We’ll replace this with the code from runner, and then find who all calls runner and have them ask their tile. The princess probably doesn’t have a tile to ask. We’ll see.
function Tile:graphicCoordinates()
return (self.x-1)*TileSize,(self.y-1)*TileSize
end
This should work, so let’s test. Game plays fine, tests are green. Commit: Tile returns graphicCoordinates.
Now let’s find senders of graphicXY on runner. There are none. Remove it, test. Green, game plays. Commit: remove runner:graphicXY
Interesting. How does princess do it?
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
function Player:graphicCoordinates()
return self:getTile():graphicCoordinates()
end
function Player:getTile()
return self.tile
end
Perfect. I guess she does know her tile. Let’s look at how she moves. I just want to refresh my memory.
This is sweet. She doesn’t know her coordinates at all, only her tile:
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
function Player:moveTo(x,y)
self.tile = self.runner:getTile(x,y)
end
Let’s review monsters and see if they are done the same way. If not, they probably should be. Do they? Not remotely:
function Monster:init(tileX,tileY, runner)
self.tileX = tileX
self.tileY = tileY
self.runner = runner
self.standing = asset.builtin.Platformer_Art.Monster_Standing
self.moving = asset.builtin.Platformer_Art.Monster_Moving
self.dead = asset.builtin.Platformer_Art.Monster_Squished
self.sprite1 = self.standing
self.sprite2 = self.dead
self.swap = 0
self.move = 0
self:setAnimationTimer()
self:setMotionTimer()
end
Easily remedied of course but let’s look back at creation of avatar and monsters.
function Monster:init(tileX,tileY, runner)
self.tileX = tileX
self.tileY = tileY
self.runner = runner
self.standing = asset.builtin.Platformer_Art.Monster_Standing
self.moving = asset.builtin.Platformer_Art.Monster_Moving
self.dead = asset.builtin.Platformer_Art.Monster_Squished
self.sprite1 = self.standing
self.sprite2 = self.dead
self.swap = 0
self.move = 0
self:setAnimationTimer()
self:setMotionTimer()
end
So she has a fromXY
method. It seems the GameRunner wants to deal in x y here. But for the monsters:
function GameRunner:createMonsters(n)
self.monsters = {}
for i = 1,n or 1 do
local t = self:randomRoomTile()
table.insert(self.monsters, Monster(t.x,t.y,self))
end
end
Here we have the tile and we decode it. Weird, who wrote this stuff? Let’s just change their protocol right here:
function GameRunner:createMonsters(n)
self.monsters = {}
for i = 1,n or 1 do
local tile = self:randomRoomTile()
table.insert(self.monsters, Monster(tile,self))
end
end
Now we go back to Monster and change it. We could, of course, just decode the tile back to x and y and it would work. But we want Monster to focus on tiles, as does Player.
function Monster:init(tile, runner)
self.tile = tile
self.runner = runner
self.standing = asset.builtin.Platformer_Art.Monster_Standing
self.moving = asset.builtin.Platformer_Art.Monster_Moving
self.dead = asset.builtin.Platformer_Art.Monster_Squished
self.sprite1 = self.standing
self.sprite2 = self.dead
self.swap = 0
self.move = 0
self:setAnimationTimer()
self:setMotionTimer()
end
Now I just go through finding the tileX tileY references and change them:
function Monster:chooseMove()
local nx,ny
if self.runner:playerDistanceXY(self.tileX,self.tileY) <= 10 then
nx,ny = self:proposeMoveTowardAvatar()
else
nx,ny = self:proposeRandomMove()
end
self.runner:moveMeIfPossible(self, self.tileX+nx, self.tileY+ny)
self:setMotionTimer()
end
That becomes:
function Monster:chooseMove()
local nx,ny
local tx,ty = self.tile:tileCoordinates()
if self.runner:playerDistanceXY(tx,ty) <= 10 then
nx,ny = self:proposeMoveTowardAvatar()
else
nx,ny = self:proposeRandomMove()
end
self.runner:moveMeIfPossible(self, tx+nx, ty+ny)
self:setMotionTimer()
end
Not as unwound as we’‘d like. I’d like to start doing movement in tile terms, rather than coordinates, but that’s for later.
This is odd:
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
function Monster:getTile()
return self.tile or self.runner:getTile(self.tileX,self.tileY)
end
We were going to move toward having a tile variable and then veered off. We replace that with:
function Monster:draw()
pushMatrix()
pushStyle()
spriteMode(CORNER)
local gx,gy = self.tile:graphicCoordinates()
translate(gx,gy)
scale(-1,1)
sprite(self.sprite1, 0,0)
translate(-gx,-gy)
popStyle()
popMatrix()
end
Next is moving.
function Monster:moveTo(newX,newY)
self.tileX = newX
self.tileY = newY
end
Player does this:
function Player:moveTo(x,y)
self.tile = self.runner:getTile(x,y)
end
We can copy that but of course we’re still using runner. One thing at a time, I guess. Change made.
Choosing a move:
function Monster:chooseMove()
local nx,ny
local tx,ty = self.tile:tileCoordinates()
if self.runner:playerDistanceXY(tx,ty) <= 10 then
nx,ny = self:proposeMoveTowardAvatar()
else
nx,ny = self:proposeRandomMove()
end
self.runner:moveMeIfPossible(self, tx+nx, ty+ny)
self:setMotionTimer()
end
function Monster:proposeMoveTowardAvatar()
local dx,dy = self.runner:playerDirection(self:getTile())
if math.random() < 0.5 then
return 0,dy
else
return dx,0
end
end
function Monster:proposeRandomMove()
local moves = { {x=-1,y=0}, {x=0,y=1}, {x=0,y=-1}, {x=1,y=0}}
local move = moves[math.random(1,4)]
return self.tileX + move.x, self.tileY + move.y
end
These last two become:
function Monster:proposeMoveTowardAvatar()
local dx,dy = self.runner:playerDirection(self.tile)
if math.random() < 0.5 then
return 0,dy
else
return dx,0
end
end
function Monster:proposeRandomMove()
local moves = { {x=-1,y=0}, {x=0,y=1}, {x=0,y=-1}, {x=1,y=0}}
local move = moves[math.random(1,4)]
local tx,ty = self.tile:tileCoordinates()
return tx + move.x, ty + move.y
end
And I think we’re good. Test. Green and the monsters still move as expected. Commit: Monster uses tile, not coordinates.
Things go bad starting here. We’re essentially reverted back to this point at the end. Skim down to Summing Up, that’s my advice.
Now it would be better, I think, if moving amounted to proposing a tile and if it’s a legal place to move, moving to it, rather than passing x y coordinates around. I think in the long term, we want this to be managed by a Map class rather than directly in GameRunner. And I think we’ll let our tiles know the map, and, I hope, not the GameRunner. But I think the protocol will be about the same versus the Map and the GameRunner, so we’ll work with the runner for now, and worry about providing this new thing later.
Instead of using coordinates to look up tiles and check them, let’s instead code in terms of north south west east. And let’s make those vectors, on the off chance that it will pay off. I’m not quite ready to create a new object just for this purpose, but the vec2 will at least keep things together.
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
PlayerSteps = {a={x=-1,y=0}, w={x=0,y=1}, s={x=0,y=-1}, d={x=1,y=0}}
I wonder whether we want to preserve the two-phase move, where we propose a move and then get called back. Let’s not. Let’s have a method that returns the tile to which we are to move, which may be the same tile we are presently on.
I’m losing confidence in the idea of north south etc, because the x and y coordinates make sense too, and they are hidden away, at least here. In Monster it’s a bit different, but still we use these pairs. Let’s at least convert them to vec2:
PlayerSteps = {a=vec2(-1,0), w=vec2(0,1), s=vec2(0,-1), d=vec2(1,0)}
And in monster:
function Monster:proposeRandomMove()
local moves = {vec2(-1,0), vec2(0,1), vec2(0,-1), vec2(1,0)}
local move = moves[math.random(1,4)]
local tx,ty = self.tile:tileCoordinates()
return tx + move.x, ty + move.y
end
This should have no effect, if I’m lucky. All seems good. I did see a ghost stuck in a wall, which isn’t supposed to be possible, but they do chase the princess.
Commit: convert move tables to vec2.
OK, it turns out that tiles know the runner, and if we convert to a tile map, I’m sure they’ll know their map, so we can make some decisions using tiles directly. Let’s see about changing the Player first, since it just does motion one way instead of two.
We’ve got this:
PlayerSteps = {a=vec2(-1,0), w=vec2(0,1), s=vec2(0,-1), d=vec2(1,0)}
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
Let’s make the steps just the direction names:
PlayerSteps = {a="west", w="north", s="south", d="east"}
Now let’s ask our tile for a legal neighbor in a direction:
function Player:keyPress(key)
local step = PlayerSteps[key]
if step then
self.tile = self.tile:legalNeighbor(step)
end
end
The protocol is that legalNeighbor
will return either the new tile, if it’s legal, or the input one if it isn’t. This gets a bit messy:
function Tile:legalNeighbor(aDirection)
local directions = {north=vec2(0,1), south=vec2(0,-1), east=vec2(1,0), west=vec2(-1,0)}
local newCoords = self:coordinateVector() + directions[aDirection]
local newTile = self.runner:getTileFromVector(newCoords)
if newTile:isRoom() then
return newTile
else
return self
end
end
I’m still confident that we’ll be glad when we get to vectors. I need a new method in Tile:
function Tile:coordinateVector()
return vec2(self.x,self.y)
end
In the fullness of time, I expect we’ll just store the vector, not the separate x and y.
New method in runner:
function GameRunner:getTileFromVector(aVector)
return self:getTile(aVector.x, aVector.y)
end
I expect this to work. I admit to being not certain.
Tests are green. Game seems to work, but the monsters aren’t stepping into the avatar’s square, they are staying one to the left. I think that was already going on. A key question is whether perhaps the avatar is standing one square to the right of her proper location.
I should be able to walk her around and see. She definitely starts in room center, and can go up to the walls on all sides. Makes me fairly sure that the Monster is making the mistake.
I see something that seems clearly wrong:
function Monster:chooseMove()
local nx,ny
local tx,ty = self.tile:tileCoordinates()
if self.runner:playerDistanceXY(tx,ty) <= 10 then
nx,ny = self:proposeMoveTowardAvatar()
else
nx,ny = self:proposeRandomMove()
end
self.runner:moveMeIfPossible(self, tx+nx, ty+ny)
self:setMotionTimer()
end
function Monster:proposeMoveTowardAvatar()
local dx,dy = self.runner:playerDirection(self.tile)
if math.random() < 0.5 then
return 0,dy
else
return dx,0
end
end
function Monster:proposeRandomMove()
local moves = {vec2(-1,0), vec2(0,1), vec2(0,-1), vec2(1,0)}
local move = moves[math.random(1,4)]
local tx,ty = self.tile:tileCoordinates()
return tx + move.x, ty + move.y
end
The random move is returning tile + move, while the move toward is returning just a step. The choose method expects the step. What’s odd is that the error I see seems to be in the intentional move, not the random one. Anyway we’d best change random:
function Monster:proposeRandomMove()
local moves = {vec2(-1,0), vec2(0,1), vec2(0,-1), vec2(1,0)}
local move = moves[math.random(1,4)]
return move.x, move.y
end
I don’t see how this can fix the problem. Even if it does I’ll be confused. We may need to dig further either way. Test.
They’re still doing it. And if I move the princess left on top of them, they actually back away.
I want to see some facts here. Putting in this print …
function Monster:chooseMove()
local nx,ny
local tx,ty = self.tile:tileCoordinates()
print(self.runner:playerDistanceXY(tx,ty))
if self.runner:playerDistanceXY(tx,ty) <= 10 then
nx,ny = self:proposeMoveTowardAvatar()
else
nx,ny = self:proposeRandomMove()
end
self.runner:moveMeIfPossible(self, tx+nx, ty+ny)
self:setMotionTimer()
end
I immediately see zeros coming out from the ghost beside the avatar. I want to know who’s where.
Aha! Remember that little experiment I did where I flipped the monster left to right, thinking that someday I’d do that to make them face the princess? Well, I left that in, and that code flips him around his corner, which throws his visible image one square to the left. So that’s interesting. I removed the scale(-1.1)
and it’s all good.
OK, back on track. Where was I?
Sudden problems like that really cause me to flush out my brain cache and when they’re resolved, I need to refresh.
OK, we were doing the legalNeighbor stuff in Player:
function Player:keyPress(key)
local step = PlayerSteps[key]
if step then
self.tile = self.tile:legalNeighbor(step)
end
end
We no longer propose a move, we just defer to the Tile’s judgment. I can remove the moveTo
from Player. Reviewing:
function Tile:legalNeighbor(aDirection)
local directions = {north=vec2(0,1), south=vec2(0,-1), east=vec2(1,0), west=vec2(-1,0)}
local newCoords = self:coordinateVector() + directions[aDirection]
local newTile = self.runner:getTileFromVector(newCoords)
if newTile:isRoom() then
return newTile
else
return self
end
end
This stuff with north south east west isn’t really helping at this point, is it?
But what if we do this:
function Tile:legalNeighbor(aDirection)
local newTile = self[aDirection](self)
if newTile:isRoom() then
return newTile
else
return self
end
end
We can look up aDirection, which is a string north south etc, in our table, and execute it. Then we provide:
function Tile:north()
return self.runner:getTileFromVector(self:coordinateVector() + vec2(0,1))
end
Now even before we do the other three, we see how we’ll refactor, so we do:
function Tile:getNeighbor(aVector)
return self.runner:getTileFromVector(self:coordinateVector() + aVector)
end
function Tile:north()
return self:getNeighbor(vec2(0,1))
end
function Tile:south()
return self:getNeighbor(vec2(0,-1))
end
function Tile:east()
return self:getNeighbor(vec2(1,0))
end
function Tile:west()
return self:getNeighbor(vec2(-1,0))
end
I think this should be good. Can you tell I’m getting a bit less confident? This process has been going on for more than 10 minutes and I lose confidence rapidly after that point. Let’s test.
This works. Now tiles understand methods north
, south
, east
, and west
. I’m thinking that ought to be useful.
Does this code confuse you?
function Tile:legalNeighbor(aDirection)
local newTile = self[aDirection](self)
if newTile:isRoom() then
return newTile
else
return self
end
end
This bit here:
local newTile = self[aDirection](self)
That means “execute aDirection as a method”. We could wrap it up or something but it seems not unreasonable. Let’s wait and see.
Can we now convert monsters to a similar scheme? Let’s review again how they navigate:
function Monster:chooseMove()
local nx,ny
local tx,ty = self.tile:tileCoordinates()
if self.runner:playerDistanceXY(tx,ty) <= 10 then
nx,ny = self:proposeMoveTowardAvatar()
else
nx,ny = self:proposeRandomMove()
end
self.runner:moveMeIfPossible(self, tx+nx, ty+ny)
self:setMotionTimer()
end
function Monster:proposeMoveTowardAvatar()
local dx,dy = self.runner:playerDirection(self.tile)
if math.random() < 0.5 then
return 0,dy
else
return dx,0
end
end
function Monster:proposeRandomMove()
local moves = {vec2(-1,0), vec2(0,1), vec2(0,-1), vec2(1,0)}
local move = moves[math.random(1,4)]
return move.x, move.y
end
We’re not going to propose moves any more, we’re going to just do them.
But wait, here’s an idea. What if we define Tile north, south, east, west to return only legal room tiles, and otherwise return the original? Then we can provide a raw tile fetch if someone wants any old tile, but users of the directions can just chose them safely.
Let’s go back to Player and make that happen:
function Player:keyPress(key)
local step = PlayerSteps[key]
if step then
self.tile = self.tile:legalNeighbor(step)
end
end
Ah. Now step
is a direction, but to send it, we’d have to dereference it here. It’s not helping much. We’ll leave the focus on legalNeighbor
for now. Back to Monster.
We’ll change this:
function Monster:chooseMove()
local nx,ny
local tx,ty = self.tile:tileCoordinates()
if self.runner:playerDistanceXY(tx,ty) <= 10 then
nx,ny = self:proposeMoveTowardAvatar()
else
nx,ny = self:proposeRandomMove()
end
self.runner:moveMeIfPossible(self, tx+nx, ty+ny)
self:setMotionTimer()
end
We’ll just move to a legal point, either randomly or toward the avatar:
function Monster:chooseMove()
if self.runner:playerDistanceXY(tx,ty) <= 10 then
self:moveTowardAvatar()
else
self:randomMove()
end
self:setMotionTimer()
end
We revise the old propose
methods:
function Monster:randomMove()
local moves = {"north", "south", "east", "west"}
local move = moves[math.random(1,4)]
self.tile = self.tile:legalNeighbor(move)
end
And looking at this:
function Monster:moveTowardAvatar()
local dx,dy = self.runner:playerDirection(self.tile)
if math.random() < 0.5 then
return 0,dy
else
return dx,0
end
end
We know that dx and dy can each be zero or one. If the random number rolls the wrong way, the ghost won’t move on that cycle, because he’ll move 0,dy=0 or vice versa. That makes the ghosts a bit erratic, in that sometimes they move toward you and sometimes they stand for a bit. I guess we’ll keep that feature, it’s kind of interesting.
Let’s see about playerDirection
. I think we’re the only user, other than a couple of tests. So …
function GameRunner:playerDirection(aTile)
return aTile:directionTo(self.player:getTile())
end
function Tile:directionTo(aTile)
tx,ty = aTile:tileCoordinates()
return sign(tx-self.x), sign(ty-self.y)
end
This -1,0,1 return value was so nice, but now I really want a direction. And we don’t support directions like northeast
, although we certainly could.
Let’s rig this so that it returns a pair, but instead of being dx,dy, it’s like “north”,”east” or “north”,””.
If we do this, we should be getting the same answer … so everything should still work …
Well, it doesn’t. That’s odd. And I don’t have a good save point.
I’ll command Z that change back out I guess.
Doesn’t help. I broke something. The monsters aren’t chasing properly. And my revert point is way back. Four files wanting to be committed.
GameRunner is OK, it just added the getTileFromVector
. I could commit that, but I’d better see what else is going on, I may have to revert a long way.
Tile looks OK, we just added methods.
Player we converted to directions and that worked. That leaves Monster, where the problem is, and it happened during the conversion to the new legal move scheme. I wonder if Working Copy can revert just one file. Yes, it can. Here goes.
Then I’ll commit: Player makes legal moves directly.
OK, back onto Monster, making them move directly and (trying) to convert them to directions.
function Monster:chooseMove()
local nx,ny
local tx,ty = self.tile:tileCoordinates()
if self.runner:playerDistanceXY(tx,ty) <= 10 then
nx,ny = self:proposeMoveTowardAvatar()
else
nx,ny = self:proposeRandomMove()
end
self.runner:moveMeIfPossible(self, tx+nx, ty+ny)
self:setMotionTimer()
end
function Monster:proposeMoveTowardAvatar()
local dx,dy = self.runner:playerDirection(self.tile)
if math.random() < 0.5 then
return 0,dy
else
return dx,0
end
end
function Monster:proposeRandomMove()
local moves = {vec2(-1,0), vec2(0,1), vec2(0,-1), vec2(1,0)}
local move = moves[math.random(1,4)]
local tx,ty = self.tile:tileCoordinates()
return tx + move.x, ty + move.y
end
Maybe I can convert the random move only, and then come back for the move toward.
function Monster:chooseMove()
local nx,ny
local tx,ty = self.tile:tileCoordinates()
if self.runner:playerDistanceXY(tx,ty) <= 10 then
nx,ny = self:moveTowardAvatar()
else
self:randomMove()
return
end
self.runner:moveMeIfPossible(self, tx+nx, ty+ny)
self:setMotionTimer()
end
A bit crossways but it’s just for now, I hope.
function Monster:randomMove()
local moves = {"north", "south", "east", "west"}
local move = moves[math.random(1,4)]
self.tile = self.tile:legalNeighbor(move)
end
This seems harmless. Let’s be sure the monster chases. It doesn’t move at all. Revert, see if things work.
I think the scale bug was in the revert. OK, yes. Now they are chasing properly. Commit: remove scale that flips monster.
I’m going to call this two strikes and out. The use of 0,1 deltas isn’t bad. Let’s put Player back the way she was, and ditch the named directions entirely.
Here’s the thing we call:
function Tile:legalNeighbor(aDirection)
local newTile = self[aDirection](self)
if newTile:isRoom() then
return newTile
else
return self
end
end
We’ll change that to use a vec2:
function Tile:legalNeighbor(aVector)
local newTile = self:getNeighbor(aVector)
if newTile:isRoom() then
return newTile
else
return self
end
end
function Tile:getNeighbor(aVector)
local newPos = self:coordinateVector() + aVector
return self.runner:getTileFromVector(newPos)
end
And back to using it in Player:
function Player:keyPress(key)
local step = PlayerSteps[key]
if step then
self.tile = self.tile:legalNeighbor(step)
end
end
We just need to change PlayerSteps
back:
PlayerSteps = {a=vec2(-1,0), w=vec2(0,1), s=vec2(0,-1), d=vec2(1,0)}
I found that line in Working Copy’s diff. Nice.
We should be back in operation. And we are. Commit: Player back to using room deltas.
It’s past time to stop. We’ve not accomplished much. I’m required by law to publish this but I’m not required to like it. Let’s sum up.
Summing Up
The first bits of change were righteous. We have four good commits and then essentially reverted back to them, with a few improvements. Three of our seven commits amount to undoing things that I’d tried.
The basic bad idea was that it would be nice to change from using difference vectors to specify moves, like (0,1), directions like north and south would be better. It turns out they were awkward to use in the Player, were implementing using tricky code, and I never made them work in Monster at all.
This is of course a bit frustrating, and I wonder if I could have seen earlier that it wasn’t an improvement. I don’t mind that particular bit of tricky code, and it was pretty easy in Player to convert to NEWS, because that’s all she can do. The monster wasn’t quite so easy, especially when moving toward the player, since it gets a vector back that can be (1,1) or (-1,-1) as well as a zero-one combination.
It might be that a third try would have made it work. My guess is that it would have. But the code wasn’t looking easier to understand and I wasn’t getting the collapse of methods that I had hoped for.
So we remove the bad idea. Yes, frustrating, but they can’t all be winners.
We’ll try for more winners next time!
See you then!