Dungeon 110
Implementation inheritance? Why not? A challenge to my betters.
We have two kinds of “living” creatures in our Dungeon, the player (princess) and the monsters. The monsters come in various flavors, but they’re all monsters. There are two classes that contain the behavior for them, Player, and Monster. These two classes are different in some regards, and the same in others.
For example, the player moves as driven by keystrokes or screen touches, while the monsters are moved by a coupld of very simple algorithms that cause them to move randomly, or to move toward the player if she is close by.
However, the player and monsters have quite a few methods that are exactly the same, including the method to get their attack verbs (the princess slaps or whacks, a pink slime smears, squishes, or sloshes), and they accumulate damage the same way, die the same way, provide their current attribute values the same way, and so on.
I have made the design decision, quite some time ago, to have a superclass Entity
, from which Monster
and Player
both inherit. And I’ve made the questionable decision to have the common behavior in that superclass.
Putting concrete behavior in a superclass is considered to be a dubious, even bad, thing to do. See, for a random example, this article. There are plenty of others.
Now it’s important to mention that the Entity
class is abstract. There are no instances of Entity
in the system. So it is, in essence, an “abstract” class, not “concrete”. That said, it does include concrete behavior, and that behavior is inherited by both Monster
and Player
. You would have no problem finding people who’d tell you that this structure is “wrong”, or, as many of the articles are wont to say, “evil”.
I’ll leave it to you to read as many of those articles as you care to, and I’d very much welcome a serious review of the code with an eye to what would be better. It’s a pity that we can’t readily pair program on this thing. Meanwhile, we’ll review the code here, and we’ll keep an eye on what trouble I get into because of this decisions. My guess is that there won’t be much, but I could be wrong: I frequently am.
What are alternatives?
There are, no doubt, many alternatives to this structure. I’ll mention these:
- Just duplicate the code
- There’s no law against duplicating the code, although I have learned that duplicated code is frequently a sign that there’s a better design available that would avoid that duplication. But I generally tolerate some duplication, and many programmers tolerate far more than I do.
- Entity owns Player or Monster
- The
Entity
could be a concrete wrapper class that contains an instance ofPlayer
orMonster
to do the individual actions. This would typically require the Entity to forward all the individuals’ messages down to the contained instance. - Player/Monster has an Entity
- The
Player
orMonster
could contain an instance ofEntity
, to which it would forward the messages calling for common behaviors handled there.
We could imagine variations on this, such as a class that has both an Entity
and a Player
or Monster
, and that dispatched message as suitable. This object would essentially forward everything, except that common elements might drift upward.
Another approach we might find lying in the road, much the same as we find road apples, would be for clients to deal with the separation, violating the Law of Demeter along the way, to send messages to entity:getCreature()
or something like that. This, by my standards, is never a good idea. It’s “feature envy”, and it’s resolved by adding a method to the entity in this case.
Now, to me, it’s clear on its face that the inheritance approach involves less code than the alternatives, because there’s no forwarding going on. Messages go to the Player
or Monster
always, and they are either dealt with there or by the superclass.
Most of the arguments against implementation inheritance seem more focused on inheritance from concrete classes, such as having a HairyMonster inheriting from Monster, and focused on bad things that are going to happen to you in the future should you dare to go to the dark side of inheritance.
I don’t mean to minimize the importance of this question. It is certainly possible to build an unintelligible mess with inheritance, and we don’t want to do that, But … let’s look at this code and see what’s not to like.
Entity
-- Entity
-- RJ 20210201 - superclass for Monster and Player
Entity = class()
function Entity:accumulateDamage(amount)
self.accumulatedDamage = self.accumulatedDamage + amount
end
function Entity:attackerSpeedAdvantage()
return 2
end
function Entity:attackVerb()
return self.attackVerbs[math.random(1,#self.attackVerbs)]
end
function Entity:die()
self.healthPoints = 0
self.accumulatedDamage = 0
self.alive = false
end
function Entity:distance(aTile)
return aTile:distance(self.tile)
end
function Entity:distanceFromPlayer()
return self.runner:playerDistance(self.tile)
end
function Entity:damageFrom(aTile,amount)
if not self:isAlive() then return end
self.healthPoints = self.healthPoints - amount
self.accumulatedDamage = self.accumulatedDamage - amount
if self.accumulatedDamage < 0 then self.accumulatedDamage = 0 end
if self.healthPoints <= 0 then
self.runner:addTextToCrawl(self:name().." is dead!")
sound(self.deathSound, 1, self.pitch)
self:die()
else
sound(self.hurtSound, 1, self.pitch)
end
end
function Entity:getTile()
return self.tile
end
function Entity:health()
return self.healthPoints
end
function Entity:isAlive()
return self.alive
end
function Entity:isDead()
return not self:isAlive()
end
function Entity:speed()
return self.speedPoints
end
function Entity:speedRoll()
return math.random(1,self:speed())
end
function Entity:strength()
return self.strengthPoints
end
function Entity:willBeAlive()
return not self:willBeDead()
end
function Entity:willBeDead()
return self:isDead() or self.accumulatedDamage >= self.healthPoints
end
Most of these are not much more than accessors. We could imagine having those initialized in an Entity:init
, but that happens to be fraught in Lua. There is some way to call a method on your superclass, but I don’t even know what it is. That ensures that I won’t do it, at least not until I learn how.
The method damageFrom
is interesting, and is moderately complicated. It’s there because of the complexity of the relationship between real-time battle and the after-the-fact nature of the crawl. But it’s the same for all entities, so here it resides. It’s also interesting because it’s called, not by saying something like defender:damageFrom
but instead “damageFrom” is passed as a string, as part of a Combat script. This means that if we look for senders of the message, we can miss them if we’re not careful. This isn’t a problem in the inheritance, it’s a consequence of the Combat vs Crawl structure.
Now let’s look at the subclasses. If we’ve done this right, there will be no duplicated names between each subclass and Entity
, though there will be duplicated names between the classes, since they have to implement equivalent behavior.
As we do this, we may notice other candidates to move up to Entity. I’ve not been religious about searching those out.
Monster
-- Monster
-- RJ 202012??
local MT = nil
Monster = class(Entity)
function Monster:getMonstersAtLevel(level)
if not MT then self:initMonsterTable() end
local t = {}
for i,m in ipairs(MT) do
if m.level == level then
table.insert(t,m)
end
end
return t
end
function Monster:getRandomMonsters(runner, number, level)
local t = self:getMonstersAtLevel(level)
local result = {}
for i = 1,number do
local mtEntry = t[math.random(#t)]
local tile = runner:randomRoomTile(1)
local monster = Monster(tile, runner, mtEntry)
table.insert(result, monster)
end
return result
end
function Monster:init(tile, runner, mtEntry)
if not MT then self:initMonsterTable() end
self.alive = true
self.tile = tile
self.tile:addDeferredContents(self)
self.runner = runner
self.mtEntry = mtEntry or self:randomMtEntry()
self.dead = self.mtEntry.dead
self.hit = self.mtEntry.hit
self.moving = self.mtEntry.moving
self.level = self.mtEntry.level or 1
self.attackVerbs = self.mtEntry.attackVerbs or {"strikes at"}
self.healthPoints = self:roll(self.mtEntry.health)
self.strengthPoints = self:roll(self.mtEntry.strength)
self.speedPoints = self:roll(self.mtEntry.speed)
self.movingIndex = 1
self.swap = 0
self.move = 0
self.attributeSheet = AttributeSheet(self)
self.deathSound = asset.downloaded.A_Hero_s_Quest.Monster_Die_1
self.hurtSound = asset.downloaded.A_Hero_s_Quest.Monster_Hit_1
self.pitch = 1
self.accumulatedDamage = 0
end
function Monster:randomMtEntry()
if not MT then self:initMonsterTable() end
local index = math.random(1,#MT)
return self:getMtEntry(index)
end
function Monster:getMtEntry(index)
if not MT then self:initMonsterTable() end
return MT[index]
end
function Monster:initMonsterTable()
local m
MT = {}
m = {name="Pink Slime", level = 1, health={1,2}, speed = {4,10}, strength=1,
attackVerbs={"smears", "squishes", "sloshes at"},
dead=asset.slime_squashed, hit=asset.slime_hit,
moving={asset.slime, asset.slime_walk, asset.slime_squashed}}
table.insert(MT,m)
m = {name="Death Fly", level = 1, health={2,3}, speed = {8,12}, strength=1,
attackVerbs={"bites", "poisons"},
dead=asset.fly_dead, hit=asset.fly_hit,
moving={asset.fly, asset.fly_fly}}
table.insert(MT,m)
m = {name="Ghost", level=1, health={1,5}, speed={5,9},strength={1,1},
attackVerbs={"licks", "terrifies", "slams"},
dead=asset.ghost_dead, hit=asset.ghost_hit,
moving={asset.ghost, asset.ghost_normal}}
table.insert(MT,m)
m = {name="Toothhead", level = 2, health={4,6}, speed = {8,15}, strength={1,2},
attackVerbs={"gnaws at", "bites", "sinks teeth into"},
dead=asset.barnacle_dead, hit=asset.barnacle_hit,
moving={asset.barnacle, asset.barnacle_bite}}
table.insert(MT,m)
m = {name="Vampire Bat", level=2, health={3,8}, speed={5,10}, strength={8,10},
attackVerbs={"drains", "bites", "fangs"},
dead=asset.bat_dead, hit=asset.bat_hit,
moving={asset.bat, asset.bat_fly}}
table.insert(MT,m)
m = {name="Murder Hornet", level = 2, health={2,3}, speed = {8,12}, strength={2,4},
attackVerbs={"stings", "poisons", "jabs"},
dead=asset.bee_dead, hit=asset.bee_hit,
moving={asset.bee, asset.bee_fly}}
table.insert(MT,m)
m = {name="Serpent", level=3, health={8,14}, speed={8,15}, strength={8,12},
attackVerbs={"bites", "poisons", "strikes at"},
dead=asset.snake_dead, hit=asset.snake_hit,
moving={asset.snake, asset.snake_walk}}
table.insert(MT,m)
m = {name="Yellow Widow", level=3, health={1,4}, speed = {2,5}, strength={9,15},
attackVerbs={"bites", "poisons", "tangles"},
dead=asset.spider_dead, hit=asset.spider_hit,
moving={asset.spider, asset.spider_walk1,asset.spider_walk2}}
table.insert(MT,m)
m = {name="Poison Frog", level=3, health={4,8}, speed = {2,6}, strength={8,11},
attackVerbs={"leaps at", "smears poison on", "poisons", "bites"},
dead=asset.frog_dead, hit=asset.frog_hit,
moving={asset.frog, asset.frog_leap}}
table.insert(MT,m)
m = {name="Ankle Biter", level=4, health={9,18}, speed={3,7}, strength={10,15},
attackVerbs={"grinds ankles of", "cuts ankle of", "saws at feet of"},
dead=asset.spinnerHalf_dead, hit=asset.spinnerHalf_hit,
moving={asset.spinnerHalf, asset.spinnerHalf_spin}}
table.insert(MT,m)
end
function Monster:__tostring()
return string.format("Monster (%d,%d)", self.tile:pos().x,self.tile:pos().y)
end
function Monster:chooseAnimation()
if self.alive then
self.movingIndex = self.movingIndex + 1
if self.movingIndex > #self.moving then self.movingIndex = 1 end
self:setAnimationTimer()
else
self.sprite1 = self.dead
end
end
function Monster:chooseMove()
-- return true if in range
if not self.alive then return false end
if self:distanceFromPlayer() <= 10 then
self:moveTowardAvatar()
return true
else
self:makeRandomMove()
return false
end
end
function Monster:displayDamage(boolean)
self.showDamage = boolean
end
function Monster:displaySheet()
return self:isAlive()
end
function Monster:draw(tiny)
if tiny then return end
local r,g,b,a = tint()
if r==0 and g==0 and b==0 then return end
self:drawMonster()
self:drawSheet()
end
function Monster:drawMonster()
if not self.tile.currentlyVisible then return end
pushMatrix()
pushStyle()
spriteMode(CENTER)
noTint()
local center = self.tile:graphicCenter()
translate(center.x,center.y)
self:flipTowardPlayer()
if self:isDead() then
tint(0,128,0,175)
sprite(self.dead)
elseif self.showDamage then
self:selectDamageTint()
sprite(self.hit,0,0)
else
sprite(self.moving[self.movingIndex], 0,0)
end
popStyle()
popMatrix()
end
function Monster:drawSheet()
if self:distanceFromPlayer() <= 5 then
self.attributeSheet:draw()
end
end
function Monster:flipTowardPlayer()
local dir = self.runner:playerDirection(self.tile)
if dir.x > 0 then
scale(-1,1)
end
end
function Monster:getTable(mtTable)
if type(mtTable) == "table" then return mtTable end
return {mtTable or 1, mtTable or 1}
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 Monster:moveTo(aTile)
self.tile = aTile
end
function Monster:moveTowardAvatar()
local dxdy = self.runner:playerDirection(self.tile)
if dxdy.x == 0 or dxdy.y == 0 then
self.tile = self.tile:legalNeighbor(self, dxdy)
elseif 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:name()
return self.mtEntry.name
end
function Monster:photo()
if self.alive then
return self.moving[1]
else
return self.dead
end
end
function Monster:rest()
end
function Monster:roll(entry)
local range = self:getTable(entry)
return math.random(range[1], range[2])
end
function Monster:selectDamageTint()
if self.healthPoints <= 2 then
tint(255,0,0)
else
tint(255,255,0)
end
end
function Monster:setAnimationTimer()
if self.animationTimer then tween.stop(self.animationTimer) end
self.animationTimer = self:setTimer(self.chooseAnimation, 0.5, 0.05)
end
function Monster:setTimer(action, time, deltaTime)
if not self.runner then return end
local t = time + math.random()*deltaTime
return tween.delay(t, action, self)
end
function Monster:startActionWithMonster(aMonster)
if aMonster:isDead() then return end
aMonster:rest()
end
function Monster:startActionWithPlayer(aPlayer)
self.runner:initiateCombatBetween(self,aPlayer)
end
function Monster:startAllTimers()
self:setAnimationTimer()
end
Player
-- Player
-- RJ 20201205
Player = class(Entity)
PlayerSteps = {a=vec2(-1,0), w=vec2(0,1), s=vec2(0,-1), d=vec2(1,0)}
function Player:__tostring()
return string.format("Player (%d,%d)", self.tile:pos().x,self.tile:pos().y)
end
function Player:privateFromXY(tileX,tileY,runner)
local tile = runner:getTile(vec2(tileX,tileY))
return Player(tile,runner)
end
function Player:cloneFrom(oldPlayer, tile, runner)
local player = Player(tile,runner)
if oldPlayer then player:initAttributes(oldPlayer) end
return player
end
function Player:init(tile, runner)
self.runner = runner
self:initTile(tile)
self:initAttributes(self:retainedAttributes())
self:initSounds()
self:initVerbs()
end
function Player:retainedAttributes()
return { keys=0, healthPoints = 12, speedPoints = 8, strengthPoints = 10 }
end
function Player:initAttributes(attrs)
self.alive = true
self.attributeSheet = AttributeSheet(self,750)
self.accumulatedDamage = 0
self:initRetainedAttributes(attrs)
end
function Player:initRetainedAttributes(attrs)
for k,v in pairs(self:retainedAttributes()) do
self[k] = attrs[k]
end
end
function Player:initSounds()
self.deathSound = asset.downloaded.A_Hero_s_Quest.Hurt_3
self.hurtSound = asset.downloaded.A_Hero_s_Quest.Hurt_1
self.pitch = 1.5
end
function Player:initTile(tile)
self.tile = tile
self.tile:illuminate()
self.tile:addDeferredContents(self)
end
function Player:initVerbs()
self.attackVerbs = {"whacks", "bashes", "stabs", "slaps", "punches", "slams", "strikes"}
end
-- Instance Methods
function Player:addPoints(kind, amount)
local attr = self:pointsTable(kind)
if attr then
local current = self[attr]
self[attr] = math.min(20,current + amount)
self:doCrawl(kind, amount)
end
end
function Player:displayDamage(boolean)
self.showDamage = boolean
end
function Player:displaySheet()
return true
end
function Player:doCrawl(kind, amount)
local msg = string.format("+%d "..kind.."!!", amount)
self.runner:addTextToCrawl(msg)
end
function Player:down()
self:executeKey("s")
end
function Player:draw()
-- ignored
end
function Player:drawExplicit(tiny)
local sx,sy
local dx = 0
local dy = 30
pushMatrix()
pushStyle()
spriteMode(CENTER)
local center = self:graphicCenter() + vec2(dx,dy)
translate(center.x,center.y)
if not self.alive then tint(0) end
sx,sy = self:setForMap(tiny)
self:drawSprite(sx,sy)
popStyle()
popMatrix()
if not tiny then
self.attributeSheet:draw()
end
end
function Player:drawSprite(sx,sy)
if self.showDamage then
self:selectDamageTint()
sprite(asset.Character_Princess_Girl_Hit,0,0,sx,sy)
else
sprite(asset.Character_Princess_Girl,0,0,sx,sy)
end
end
function Player:executeKey(key)
local step = PlayerSteps[key]
if step then
self:moveBy(step)
end
end
function Player:fight()
end
function Player:flee()
end
function Player:graphicCenter()
return self:getTile():graphicCenter()
end
function Player:graphicCorner()
return self:getTile():graphicCorner()
end
function Player:itsOurTurn()
return self.runner:itsPlayerTurn()
end
function Player:left()
self:executeKey("a")
end
function Player:right()
self:executeKey("d")
end
function Player:up()
self:executeKey("w")
end
function Player:keyPress(key)
if not self:itsOurTurn() then return false end
self:executeKey(key)
self:turnComplete()
end
function Player:moveBy(aStep)
self.runner:clearTiles()
self.tile = self.tile:legalNeighbor(self,aStep)
self.tile:illuminate()
end
function Player:name()
return "Princess"
end
function Player:photo()
return asset.builtin.Planet_Cute.Character_Princess_Girl
end
function Player:pointsTable(kind)
local t = {Strength="strengthPoints", Health="healthPoints", Speed="speedPoints"}
return t[kind]
end
function Player:selectDamageTint()
if self.healthPoints <= 2 then
tint(255,0,0)
else
tint(255,255,0)
end
end
function Player:setForMap(tiny)
if tiny then
tint(255,0,0)
return 180,272
else
return 66,112
end
end
function Player:startActionWithChest(aChest)
aChest:open()
end
function Player:startActionWithKey(aKey)
self.keys = self.keys + 1
sound(asset.downloaded.A_Hero_s_Quest.Defensive_Cast_1)
aKey:take()
end
function Player:startActionWithLoot(aLoot)
aLoot:actionWith(self)
end
function Player:startActionWithMonster(aMonster)
self.runner:initiateCombatBetween(self,aMonster)
end
function Player:startActionWithSpikes(spikes)
spikes:actionWith(self)
end
function Player:startActionWithWayDown(aWayDown)
aWayDown:actionWith(self)
end
function Player:turnComplete()
self.runner:playerTurnComplete()
end
Thoughts
As I glance over these, I don’t see much to hate. I do see more than one responsibility. Certainly one responsibility that each has is to move, and another is to draw itself. We could imagine some kind of pluggable behavior for one or both of these, with a player having a keyboard-touch mover and a monster having a simple AI mover, or a drawing support object that all sprite-based figures could use. And for that we might find other candidates. Here are some other draw functions:
function WayDown:draw()
pushMatrix()
pushStyle()
spriteMode(CENTER)
local c = self.tile:graphicCenter()
sprite(asset.steps_down,c.x,c.y,64,64)
popStyle()
popMatrix()
end
function Tile:draw(tiny)
pushMatrix()
pushStyle()
spriteMode(CENTER)
self:drawSprites(tiny)
if not tiny and self.currentlyVisible then self:drawContents(tiny) end
popStyle()
popMatrix()
end
function Button:draw()
pushMatrix()
pushStyle()
spriteMode(CENTER)
translate(self.x,self.y)
tint(255,255,255,128)
sprite(self.img, 0,0, self.w, self.h)
popStyle()
popMatrix()
end
function Loot:draw()
pushStyle()
spriteMode(CENTER)
local g = self.tile:graphicCenter()
sprite(self.icon,g.x,g.y+10,35,60)
popStyle()
end
And so on … there’s certainly some commonality there. I could imagine making the duplication more obvious, and then pulling it off to some kind of object.
That’s irrelevant, however, to the concern about the Entity:Monster-Player relationship.
Summary
My point in this article was just to raise a concern that has come up in my thinking about the program. I first built the Entity
class back in article 81. I mentioned some of the drawbacks there, and I’ve been living with it for about 30 articles now with no notable issues.
But there are those I respect who would argue, in general, against implementation inheritance, so it’s fair to mention it, and a challenge to those who hold that opinion to seriously consider what would actually be better in this real case.
We’ll wrap this here, and see you next time! Thanks for reading, and if you have views or questions, tweet me up. Or email me, that works too.