Dungeon 37 - Gameplay
It’s time to put some game-playing features into this thing. That could lead anywhere, but I’m hoping today at least will be easy.
My Mac and watch are updating. There’s not much to do here at 0700, other than work with the iPad, write an article, eat a banana. You know, early morning stuff.
It’s time, I think, to make this program look a bit more like a game. I’m not promising to do everything it takes to make it into a good game, or even a complete one, but we should at least explore the issues that come up as we add in game features, and see how we respond to them as they arise. We’ve done no up front design about game play, we’ve just tried to keep our objects fairly neat and sensible.
What might we do?
- Controls
- Our game has only keyboard keys to operate it. It really can’t operate at all well without an attached keyboard: the on-screen one covers up the view. We may need to put controls on the screen, and change the screen layout accordingly. That’ll be, um, fun.
- Player Advancement
- The player should advance, gaining in treasure, experience, power, and so on. This means she’ll need some kind of inventory of those things, and the game needs some way of displaying progress.
- Monster Types
- There should be different kinds of monsters in the dungeon. Some may be dangerous: perhaps some are actually helpful. Perhaps there are friendly monsters who will lead you to treasure unless you attack them.
- Battles
- There will have to be battles with the monsters. The battle factors may include monster aggressiveness and strength, initiative (who attacks first), player and monster strength and health, and so on.
- Levels
- There should be multiple dungeon levels, getting more difficult as one progresses downward. If it’s possible to return to a level, the player should find that level as they left it.
- Map
- Any sensible adventurer would keep a map, building it up as they go. I think the game should display a small map of the dungeon, with the parts that the player has seen visible and the rest dark.
This is surely more than enough for this morning. And for the rest of the month, most likely. Today, I think I’d like to start with battle.
Before I Begin …
I have to fix some bugs from yesterday. Somehow, at the last minute, I wound up with several occurrences of .pos
in Tile, that should have been :pos()
. There were broken tests and the game didn’t run. I must have committed without testing. Very not good.
I’ve fixed them and committed, and am building yesterday’s page. Now we can get to work.
Initial Battle
Our game has no controller other than the keyboard. I’m not inclined to work on on-screen controls, because I think it’ll mostly be a pain and it won’t make my use of the game any more fun.
For the first battle, which will clearly be between a pink ghost and the Princess, I propose this simple rule:
Whoever enters the other creature’s square first wins. This will be signified by the ghost going into a flat dead mode, if Princess wins, and by Princess going dark if she loses. This isn’t much for gameplay, but it should drive out some issues and begin to drive out code.
I’m thinking right now that there will probably be a Battle object, but that’s purely speculative.
When either the ghosts or the Princess move, they go through this method:
function Tile:legalNeighbor(aStepVector)
local newTile = self:getNeighbor(aStepVector)
if newTile:isRoom() then
return newTile
else
return self
end
end
Along the way of looking for that I found moveMeIfPossible
, which is no longer used. Remove and commit.
As I was saying, the legalNeighbor
method is always called when someone or something tries to move to a new cell. It can return the new cell, allowing the entity to move. It can return the old cell, requiring the entity to stay where it was. It could, in principle, return any cell, teleporting the entity to that location.
The name is probably not ideal for what we’re about to do with it, but it’s the name in use at present.
I think we need to know who’s moving. That way we can access their properties and such to run the battle. Our current idea for battle is that it will be brief, but it’ll get more interesting. Probably.
Let’s change the method to require that the moving entity pass itself in:
function Tile:legalNeighbor(anEntity, aStepVector)
local newTile = self:getNeighbor(aStepVector)
if newTile:isRoom() then
return newTile
else
return self
end
end
function Monster:moveTowardAvatar()
local dxdy = self.runner:playerDirection(self.tile)
if math.random() < 0.5 then
self.tile = self.tile:legalNeighbor(self,vec2(0,dxdy.y))
else
self.tile = self.tile:legalNeighbor(self,vec2(dxdy.x,0))
end
end
function Monster:makeRandomMove()
local moves = {vec2(-1,0), vec2(0,1), vec2(0,-1), vec2(1,0)}
local move = moves[math.random(1,4)]
self.tile = self.tile:legalNeighbor(self,move)
end
function Player:keyPress(key)
local step = PlayerSteps[key]
if step then
self.tile = self.tile:legalNeighbor(self,step)
end
end
That should make no difference to anyone. Test and commit: legalNeighbor requires entity being moved.
Now what? We need to know whether the tile we’re looking at contains another entity and attack it. Right now, tiles don’t contain anything. Entities have a tile, but the tile does not know what’s in it.
That seems like a thing tiles should know. But it’s going to be tricky, because as an entity moves, it would have to repeatedly be removed from one tile and added to another. It will be easy to get that wrong. Hm, unless we do it here.
What if this method, which knows where you are and where you’re going to go, did the adding and removing? That should be safe. There’s a bit of doing in the initialization, adding them for the first time, but I think we can change that to call this function.
Let’s do it.
I’ve been talking about entities, intending a generic notion of player or monster, perhaps even other items like chests or gems. We have no such centralized notion in the game at present, and we may need one. I hesitate to move to something like that, however: it’s a big design decision and we have very little information at present. Let’s defer that decision and instead implement a common protocol in Monster and Player as needed.
As for the Tile, we can readily give it a new member variable, contents, into which we’ll just toss things that need to be in there.
Let’s do it. Do we need tests for this? I think we should. I’d rather not, but I think we should.
This test is kind of a pain, as we need to have a room or something to navigate around in. This might do the job:
_:test("tile contents", function()
local runner = GameRunner()
local room = Room(10,10,10,10,runner)
local tile = runner:getTile(vec2(15,15))
local player = Player(tile,runner)
_:expect(tile.contents).has(player)
end)
This test requires that the player shows up in the tile upon which it is created. This test will fail a few times:
10: tile contents -- CodeaUnit:94: bad argument #1 to 'for iterator' (table expected, got nil)
Pretty sure that means CodeaUnit didn’t find a table. Let’s add one to Tile:
function Tile:init(x,y,kind, runner)
self.position = vec2(x,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}
self.contents = {}
end
10: tile contents -- Player:10: bad argument #2 to 'format' (number expected, got nil)
OK, that’s weird. Oh, CodeaUnit was trying to print the player, since it wasn’t found in the contents. Player may not be correctly set up. Let’s check its tostring
.
function Player:__tostring()
return string.format("Player (%d,%d)", self.tileX,self.tileY)
end
Looks like I didn’t fix that when I removed tileX
and Y from Player.
function Player:__tostring()
return string.format("Player (%d,%d)", self.tile:pos().x,self.tile:pos().y)
end
Now to fix the actual error:
function Player:init(tile, runner)
self.tile = tile
self.tile:addContents(self)
self.runner = runner
end
And …
function Tile:addContents(anEntity)
self.contents[anEntity] = anEntity
end
Test runs, yay!
I want to update the test this way:
_:test("tile contents", function()
local runner = GameRunner()
local room = Room(10,10,10,10,runner)
local tile = runner:getTile(vec2(15,15))
local player = Player(tile,runner)
_:expect(tile.contents).has(player)
player:moveBy(1,0)
end)
Right now, the player just jams a new tile into herself, thusly:
function Player:keyPress(key)
local step = PlayerSteps[key]
if step then
self.tile = self.tile:legalNeighbor(self,step)
end
end
That troubles me, but in any case I can’t use it. So:
function Player:keyPress(key)
local step = PlayerSteps[key]
if step then
self:moveBy(step)
end
end
function Player:moveBy(aStep)
self:moveTo(self.tile:legalNeighbor(aStep))
end
function Player:moveTo(aTile)
self.tile:removeContents(self)
self.tile = aTile
self.tile:addContents(self)
end
This seems like a lot of feature envy going on here, but it’ll probably do for now. Let’s add the two needed asserts:
_:test("tile contents", function()
local runner = GameRunner()
local room = Room(10,10,10,10,runner)
local tile = runner:getTile(vec2(15,15))
local player = Player(tile,runner)
_:expect(tile.contents).has(player)
player:moveBy(1,0)
_:expect(tile.contents).hasnt(player)
local t2 = runner:getTile(vec2(16,15))
_:expect(t2.contents).has(player)
end)
I’m not confident here, but the test will tell.
10: tile contents -- Tile:77: bad argument #-1 to '__add' (vec2)
function Player:moveBy(aStep)
self:moveTo(self.tile:legalNeighbor(self,aStep))
end
_:test("tile contents", function()
local runner = GameRunner()
local room = Room(10,10,10,10,runner)
local tile = runner:getTile(vec2(15,15))
local player = Player(tile,runner)
_:expect(tile.contents).has(player)
player:moveBy(vec2(1,0))
_:expect(tile.contents).hasnt(player)
local t2 = runner:getTile(vec2(16,15))
_:expect(t2.contents).has(player)
end)
This is more like I had in mind:
10: tile contents -- Player:60: attempt to call a nil value (method 'removeContents')
function Tile:removeContents(anEntity)
self.contents[anEntity] = nil
end
Test runs. Commit: Player adds and removes self from current tile.
Now it seems to me that we have to add similar code to Monster, so that they’ll be in the contents as well. That will, of course constitute duplication, and since it’ll be hidden deep in the depths of two different classes, and since it’ll probably have to change, this means trouble. We need to find a way to remove that duplication after we create it, or not to create it at all.
Let’s modify the Player to do better. We have this:
function Player:init(tile, runner)
self.tile = tile
self.tile:addContents(self)
self.runner = runner
end
function Player:keyPress(key)
local step = PlayerSteps[key]
if step then
self:moveBy(step)
end
end
function Player:moveBy(aStep)
self:moveTo(self.tile:legalNeighbor(self,aStep))
end
function Player:moveTo(aTile)
self.tile:removeContents(self)
self.tile = aTile
self.tile:addContents(self)
end
That last method, moveTo
consists almost entirely of feature envy, manipulating the tile.
Considering …
I’m considering at least two options here.
The simpler is to add a method moveFromTo
to Tile, and call it with, e.g. self.tile:moveFromTo(self, t1, t2)
. That will give tile all the info that it needs to adjust contents. We could either return the tile or call back later with a setTile
kind of operation.
The more complex option would be to have entities not know their tiles at all, just to have the tiles know where the entities are and draw them accordingly. That might actually be a better design.
Another option comes to mind, which is “why didn’t we do all the contents stuff during legalNeighbor
?”
I think that makes more sense for now, and it’s more consistent with the vague design I started with.
function Tile:legalNeighbor(anEntity, aStepVector)
local newTile = self:getNeighbor(aStepVector)
if newTile:isRoom() then
self:removeContents(anEntity)
newTile:addContents(anEntity)
return newTile
else
return self
end
end
function Player:keyPress(key)
local step = PlayerSteps[key]
if step then
self:moveBy(step)
end
end
function Player:moveBy(aStep)
self.tile = self.tile:legalNeighbor(self,aStep)
end
This should still run the tests, and it does. Now I’m concerned about that addContents
in the Player init. I don’t see what to do about it. I’ll have to add it to Monster. Can I do that without a test? Yes, I can, and I will. Should I? Perhaps not.
function Monster:init(tile, runner)
self.tile = tile
self.tile:addContents(self)
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 believe that our tiles know whether they contain a player, a monster, or both. Let’s try something random.
When a tile gets a legal move into a cell with contents, the moving entity will send interact
to all the other entities in the room’s contents. And we’ll implement monster interact to make the monster die. (I promise that no monsters will actually be harmed during this game. They’ll just play dead.)
function Tile:legalNeighbor(anEntity, aStepVector)
local newTile = self:getNeighbor(aStepVector)
if newTile:isRoom() then
self:removeContents(anEntity)
newTile:interactionFrom(anEntity)
newTile:addContents(anEntity)
return newTile
else
return self
end
end
function Tile:interactionFrom(anEntity)
for k,entity in pairs(self.contents) do
entity:interact(anEntity)
end
end
function Monster:interact(anEntity)
self.alive = false
end
function Monster:chooseAnimation()
if self.alive then
self.sprite1,self.sprite2 = self.sprite2,self.sprite1
self:setAnimationTimer()
else
self.sprite1 = self.dead
end
end
function Monster:chooseMove()
if not self.alive then return end
if self.runner:playerDistance(self.tile) <= 10 then
self:moveTowardAvatar()
else
self:makeRandomMove()
end
self:setMotionTimer()
end
And it works. Here’s the princess, standing between two vanquished pink things:
We need to give her an interaction too. Let’s have her die if the ghosts step onto her:
function Player:draw()
local dx = -2
local dy = -3
pushMatrix()
pushStyle()
spriteMode(CORNER)
local center = self:graphicCorner()
if not self.alive then tint(0) end
sprite(asset.builtin.Planet_Cute.Character_Princess_Girl,center.x+dx,center.y+dy, 80,136)
popStyle()
popMatrix()
end
Now to go find a ghost and just stand there:
Tragic loss of princess.
Time to commit: elementary battle logic.
I think I’ll tint the dead ghost a bit while I’m at it:
Well, dark green probably isn’t the answer, but it’ll do for now. Commit: dead ghosts are green.
Let’s sum up.
Sum(up)
It’s 0917, and I took out time to feed the cat. So we have a couple of hours in, and elementary battle logic in place.
I think it’s actually rather good as a start. The idea of an entity entering a tile just automatically interacting with whatever’s on that tile makes a lot of sense to me. If it’s a chest, they can open it, if it’s a monster they can battle it, and so on.
We will need a way for the interacting entity to know what kind of thing it’s interacting with. In the picture above, there are two dead ghosts. The lower one, I killed with the princess. The upper one was killed when one ghost collided with another. Since Monster:interact
just causes the collided victim to die, they can kill each other.
Hm … if they were just a bit inclined not to run into each other if they could avoid it, it might make an interesting player strategy to try to get monsters to fight each other. We’ll keep that in mind.
Overall, the implementation went smoothly, and the test was helpful although not deeply so. It probably paid its way when I changed where the contents updates were done and the test still worked. Thanks, test.
I’m a bit concerned that entities have to remember to add themselves to their first tile. If they forget, that could be bad, although a moving entity would quickly correct the problem. But there are two things wrong with adding to contents in an entity’s init:
First, it’s duplication: there’s another place where adding to contents happens, in the legal move logic, and we don’t like duplication.
Second, it will be forgotten, and something won’t work until I remember. We don’t like shipping defects.
I think it would be interesting if entities didn’t know their tile directly. But I’m not exactly sure how to implement that, by which I mean I have almost no idea at all. Maybe there is a master list of all entities inside the Tile realm, and tiles can work from that. I’ll have to think about it: it might be possible, and if it is, it would remove a coupling between entities and tiles, and that would quite likely be good.
But that is certainly for another day. For today, we have simple battles and either side can win.
See you next time!