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 of Player or Monster 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 or Monster could contain an instance of Entity, 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.