11
\$\begingroup\$

I am creating a simple dungeon crawler game. My program runs and does what I want it to do so far, but I am starting to run into difficulty with adding new functionality, and I think one of the main issues is my class design.

I want to refactor my classes according to proper OOP principles, of which I am attempting to understand. I would like to have a better understanding of this now so that I will have an easier time going forward.

I am thinking about restructuring my classes according to the following:

Character:

  • id
  • name
  • currenthp
  • maximumhp
  • level
  • experience
  • Room (Character should know its location, correct?)
  • Monster (Character should also know its target, correct?)

Monster:

  • id
  • name
  • currenthp
  • maximumhp
  • level
  • Room (like Character, Monster should know its location?)
  • Character (like Character, Monster should know its target?)

Room:

  • id
  • location
  • The location of its doors
  • Dungeon? (should Room know what Dungeon it is in?)
  • Monster(s)? (should Room know what monsters are in it?)
  • Character? (should Roomknow what character is in it?)

Dungeon:

  • id
  • name
  • Rooms

Does the above seem like a reasonable design for my classes? My main question is: to what extent should each class know about the other classes?

Character class:

private class Character
{
    public string name { get; set; }
    public int level { get; set; }
    public Tuple<int, int> currentLocation { get; set; }
    public int currentHP { get; set; }
    public int maximumHP { get; set; }
    public Dungeon dungeon { get; set; }
    public Monster target { get; set; }

    public Character(string name, Dungeon dungeon)
    {
        this.name = name;
        level = 1;
        maximumHP = 100;
        currentHP = 100;
        this.dungeon = dungeon;

        switch (dungeon.Value)
        {
            case 1: //Dungeon 1
                currentLocation = new Tuple<int, int>(5, 0);
                break;
            case 2: //Dungeon 2
                currentLocation = new Tuple<int, int>(0, 0);
                break;
            case 3: //Dungeon 3
                currentLocation = new Tuple<int, int>(0, 0);
                break;
        }
    }

    public List<string> lookAroundRoom()
    {
        Room room = dungeon.DungeonLayout[currentLocation];
        List<string> targets = new List<string>();
        foreach (Monster monster in room.monster)
        {
            targets.Add(monster.Name);
        }
        return targets;
    }

    public Monster targetMonster(ListBox lst)
    {
        //I think this should be split into two methods
        //currently it does two things - sets the monster as the target
        //and then returns monster data
        Room room = dungeon.DungeonLayout[currentLocation];
        target = room.monster[lst.SelectedIndex];
        return target;
    }
}

Monster class:

public class Monster
{
    private static int autoIncrementID = 1;
    public int ID
    {
        get { return autoIncrementID; }
    }
    public string Name { get; set; }
    public int level { get; set; }
    public int currentHP { get; set; }
    public int maximumHP { get; set; }

    public Monster()
    {
        Name = $"Monster {autoIncrementID++}";
        currentHP = 20;
        maximumHP = 20;
        level = 1;
    }
}

public void MonsterDie(Room room, Monster monster)
{
    room.monster = null;
}

Room class:

public class Room
{
    public bool MoveNorth { get; set; }
    public bool MoveEast { get; set; }
    public bool MoveSouth { get; set; }
    public bool MoveWest { get; set; }
    public List<Monster> monster { get; set; }

    public Room(bool moveNorth = false, bool moveEast = false, bool moveSouth = false, bool moveWest = false)
    {
        MoveNorth = moveNorth;
        MoveEast = moveEast;
        MoveSouth = moveSouth;
        MoveWest = moveWest;

        monster = new List<Monster>();
        monster.Add(new Monster());
    }
}

Dungeon class:

public class Dungeon
{
    public string Name { get; set; }
    public int Value { get; set; }
    public Dictionary<Tuple<int, int>, Room> DungeonLayout { get; set; }

    public Dungeon(int value)
    {
        DungeonLayout = new Dictionary<Tuple<int, int>, Room>();

        switch (value)
        {
            case 1:
                Name = "Dungeon 1";
                Value = value;
                DungeonLayout.Add(new Tuple<int, int>(5, 0), new Room(false, false, false, true));
                DungeonLayout.Add(new Tuple<int, int>(4, 0), new Room(false, true, true, false));
                //Code truncated. The rest of this code contains 
                //more lines like the above two 
                //to generate the layout of the dungeon. 
        }
    }
}
\$\endgroup\$
2
  • \$\begingroup\$ Character and monster should probably have a common parent class (or implement a common sub-interface, or both), something like LivingBeing or Actor or Combatant which reflects the fact that they both have HPs, do damage, have locations within rooms and move around. \$\endgroup\$ Sep 10, 2016 at 16:28
  • \$\begingroup\$ HP should be their own class too, so you can write code for taking damage/healing/special stuff once. This also makes it easier to have regeneration (e.g. a special class that implements the IHitPoints interface that does registers itself and something each tick (regen)), etc. Most of the time when you see 'copied' members this is a hint that the code structure is not optimal. \$\endgroup\$
    – Mala
    Sep 10, 2016 at 19:25

2 Answers 2

22
\$\begingroup\$

First off, I wrote a series of articles about some of the problems of class design in this domain, though it really is about class design in general. Wizards and warriors is just the "fun" domain that makes it interesting:

https://ericlippert.com/2015/04/27/wizards-and-warriors-part-one/

You should read the whole series and understand all of it; the takeaway is: it is surprisingly hard to come up with good classes in the wizards and warriors domain, so don't feel bad if you are struggling with this. There are many pitfalls.

The other answer has some good advice, and some that is questionable. (There is a small dungeon class and a dark dungeon class, so what class do you use if you want a small, dark dungeon? Don't use the "inheritance pivot" on properties that are orthogonal. Just make a property "size" and a property "light" in the base class.)

Here's more good advice.

Several of your parenthetical questions are about location awareness:

Character should know its location, correct?

The classic way to solve this problem is to make every object that exists in the dungeon inherit from a common base class: GameObject, say. Every GameObject has:

  • A parent, which is another GameObject; all game objects have a parent except the root, which is typically the dungeon. The parent of the key is the bag, the parent of the bag is the player, the parent of the player is the jail cell, the parent of the jail cell is the dungeon, which has no parent.
  • A list, possibly empty, of child GameObjects.

And, most important:

  • We require that the parent-child relationship be consistent! If the parent of the key is the bag, then the key must be a child of the bag.

Moving around the dungeon is now just a special case of the general problem of moving objects. When the dragon moves from the drawbridge to the moat, the dragon stops being a child of the drawbridge and starts being a child of the moat.

Getting these relationships coded correctly is of vital importance; you don't want "put the bag inside the bag" to produce an infinite regress, and you certainly don't want "put the library in the bag" to work. Think of all the ways things can go terribly wrong, and figure out a way to prevent them.

Looking at your class design: I notice that Character and Monster are almost exactly identical. This should tell you something. Characters and Monsters are the same thing. The difference between a character and a monster is that the characters actions are determined by the player, and the monsters actions are determined by an AI. But from the point of view of the game engine, they're the same. So make them the same.

Notice that in your proposed design you have silently baked limitations of the game into the type system. A monster can have only one location; OK, that seems reasonable. That location is a room -- too restrictive. You should be able to put the dragon in a cage, and the cage in a room. A monster can only target characters? Too restrictive; a dragon should be able to attack a werewolf. (Again, we see that characters and monsters are really the same thing.) A monster can only target one opponent? Too restrictive; what if the dragon wants to claw one character and fire breathe on another?

Think about what limitations you are baking in when you design your class hierarchies, and make sure you are only baking in things that really have to be invariants, like having a single location.

Finally, as I note in my series: use good OO design for more than just the game objects. There are lots of coding concepts in a game other than the objects manipulated by the player in the game; there are players and rules and commands and actions and many other concepts. Those are actually the core of your game engine; design them carefully too.

\$\endgroup\$
6
  • 1
    \$\begingroup\$ "The accepted answer has some good advice, and some that is questionable." You might want to update that. \$\endgroup\$
    – Laurel
    Sep 10, 2016 at 20:13
  • \$\begingroup\$ I have read through your blog and it gave me a lot to think about and process. I really appreciate your help! Do I understand correctly that by having a root "GameObject" such as dungeon that everything inherits from it? For example (simple classes for brevity), Dungeon -> Room -> Being (player, monster, etc.) -> Item (key, potion, etc.). And does having a list in each class that holds child GameObjects violate the dependency inversion principle? \$\endgroup\$
    – Iceape
    Sep 11, 2016 at 15:50
  • \$\begingroup\$ @Iceape: You're welcome. Yes, the idea is that everything that lives "in the dungeon" inherits from the same base class. A class like "GameRule" or "PlayerStatistics" would not; they are not things you can find in a dungeon. As for "dependency inversion", don't stress about it. There is no reason to think "the functioning of this bag depends on its contents". Dependency injection is a technique for solving large scale business software problems; for example, you want to "inject" dependencies like error logging or database access. Don't worry about it for little game engines! \$\endgroup\$ Sep 11, 2016 at 19:37
  • \$\begingroup\$ @EricLippert Sorry, I wasn't referring to dependency injection (unless I am misunderstanding and dependency injection and dependency inversion are related). I was reading about the SOLID principles of object-oriented design and one of the principles is dependency inversion as can be seen here: code.tutsplus.com/tutorials/… \$\endgroup\$
    – Iceape
    Sep 11, 2016 at 19:59
  • 1
    \$\begingroup\$ @Iceape: Dependency injection is a technique used to achieve dependency inversion. Stress out about neither of them; these sorts of highfalutin architectural abstractions are for software in the large. Write clear, well-factored classes that are each responsible for some clear concept, and you'll do fine. \$\endgroup\$ Sep 12, 2016 at 1:21
11
\$\begingroup\$

My main question is to what extent should each class know about the other classes?

At best nothing - they should know only about abstractions. (Further reading: The Dependency Inversion Principle)

I am starting to run into difficulty with adding new functionality,

Because your models are tigthly coupled. You need to loosen them by introducing some abstraction - either an interface or an abstract class.

All your model names sound like base classes. What is a Character? What is a Room? What is a Dungeon? Those are very general concepts and you alredy try to differenciate between them by setting a Value of a Dungeon - whatever the value means. You should find a better name for it - or a different Name.

(Further reading: The Open/Closed Principle)

So instead of creating a switch for each dungeon inside of a Dungeon constructor consider this design:

make the dungeon abstract:

abstract class Dungeon 
{
    protected Dungeon(string name, IReadonlyDictionary<...> layout)
    {
        Name = name;
        Layout = layout;
    }
    public string Name { get; }
    public IReadOnlyDictionary<Tuple<int, int>, Room> Layout { get; }
}

and derive from it other dungeons:

class SmallDungeon : Dungeon
{
    public Dungeon() : base("Small Dungeon", CreateLayout())
    {
    }

    private static IReadOnlyDictionary<...> CreateLayout()
    {
        return new Dictionary<...>
        {
             [new Tuple<int, int>(5, 0)] = new Room(false, false, false, true),
             [new Tuple<int, int>(4, 0)] = new Room(false, true, true, false)
        }
    }
}

and others:

class LargeDungeon : Dungeon { ... }
class DarkDungeon : Dungeon { ... }

You can/should apply the same technique to the characters, monsters and rooms.

abstract class Character { ... }
class Magician : Character { ... }
class Elf : Character { ... }

This way you'll be able to construct any combination of anything and I will be easy to add a new character or dungeon because you won't have to modify any of the existing classes but create a new one. Then you just register is somewhere and you're done.


This is only a good start. There are many more possibilities. You could for example create a dungeon factory or a layout factory but you can decide on this later when you need it.


You should also follow the PascalCase naming convention for public members.


Room

Why does the room have such properties like MoveX? Can a room move? They are not easy to understand. Consider a different name.


Tracking character location:

class CharacterLocationMap : IEnumerable<KeyValuePair<Character, Location>>
{
    // Could also be the ObservableCallection to get location updates
    private readonly IDictionary<Character, Location> _locations = new...

    public Location this[Character character]
    {
        // I assume a character must be somewhere - otherwise TryGetValue
        get { return _locations[character]; }
        set{ _locations[character] = value; }
    }    

    // IEnumerable<...> implementation return _locations.GetEnumerator();
}

now you need to derive the Room and Dungeon classes from an abstract class Location that will provide both locations some general properties like Name or maybe coordinates etc.

\$\endgroup\$
10
  • \$\begingroup\$ My first guess would be that some of Dungeon is actually some Player functionality. Nice answer. What's a publibli member? ;-) \$\endgroup\$ Sep 9, 2016 at 11:53
  • \$\begingroup\$ @Mat'sMug lol I don't know what my fingers were typing - fixed ;-] - it's possbile but from a bunch of general models you can make any number of assumptions and up to a point they will all be true. \$\endgroup\$
    – t3chb0t
    Sep 9, 2016 at 11:59
  • \$\begingroup\$ Thank you. I really appreciate your help. You've given me a lot of good material and resources. The "MoveX" bools in Room are what I used to set the boundaries of each room. If MoveNorth is false, then the player will not be able to move north while in that room. But I see what you are saying about the naming and will probably rename it to something like NorthernBoundary. \$\endgroup\$
    – Iceape
    Sep 9, 2016 at 12:30
  • 1
    \$\begingroup\$ @Iceape yes NorthernBoundary would be much better, even better however would be HasNorthernBoundary because it's a property of the room so you read it like room.HasXBoundary = false; - without Has I would think that the room itself is a XBoundary - somehow the is naturally comes inbetween if there is no other verb but if you add has there are nore more misunderstandings. \$\endgroup\$
    – t3chb0t
    Sep 9, 2016 at 12:48
  • 1
    \$\begingroup\$ Generally, you would want all terrain (the 'world') to be it's own class. You most likely want it to be some kind of tree, so you can efficiently determine spatial effects (tree traversal for proximity)....a dungeon is then just a special class that has the tree, and can add additional knowledge. All exit nodes (doors, gates, ...) to other locations can be tagged, so you can subdivide the world easily without losing the generality of traversal (encountered exit nodes start a search on the neighboring locations, or whatever special logic you need.) \$\endgroup\$
    – Mala
    Sep 10, 2016 at 19:29

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge that you have read and understand our privacy policy and code of conduct.

Not the answer you're looking for? Browse other questions tagged or ask your own question.