In my last article I introduced this series and here’s the first pattern: the Abstract Factory
At first I didn’t know exactly what to build but, thankfully, the authors of Design Patterns furnished me with a good idea from their own example: a text based game where we traverse randomly generated dungeons. The player will input their commands from a list of options and will traverse different rooms that might have traps, treasures, enchantments, and whatever other nicities our hearts might desire.
For starters, I just want to be able to create a ruby module where I can say
DungeonMaker.new_dungeon and it will give me a pristine new dungueon for my player to just enter and begin his adventure.
Not all rooms are created equal
So, clearly I have the following situation: I want to generate, in a random manner, different kinds of rooms that will have, on each side (for now, we deal with four) either a wall or a door, also these of different kinds.
We need to be more explicit, though. What are all possible kinds of rooms, doors and walls? For now I’ll go with three:
A trapped entity, when activated, will inflict a certain negative effect (ideally a random effect picked from a list of possible effects). An enchanted one will cast a spell, which can either be good or bad. Finally, ordinary entities have a chance at hidden items or treasure.
I’ll first try to do it in a straightforward fashion, so that the problem will manifest itself. It is when the dragon rears it’s ugly head that we must strike. Once we see what the problem is, then I’ll present the solution.
Finding the dragon’s lair
So, first, I want my API to be simple. I want whoever uses my code (which will be a gem) to be able to just say
DungeonMaker.new_dungeon and they’ll get a new dungeon, fully set up with random rooms and the whole deal. Optionally, they can pass the number of rooms they want in their dungeon (default will be 10).
So I start by creating my
$ bundler gem dungeon_maker
After this, in order to be able to build the gem with bundler, you’ll need to configure some required options in the
.gemspec file. I won’t go through it here since this information is readily accessible on the internet. Either way, once you
cd into the project, this is what the file tree will look like:
Now, what I’ll I want to do is loop through the number of rooms and, for each room, instantiate a room of a random type and give for each of it’s sides either a wall or a door of a random type as well. Seems quite convoluted, and it is. I bet there are maybe better algorithms than this, but I just want to illustrate the problem of instantiating different types of objects, not the best algorithm for assembling randomly generated dungeons. Not for now, at least.
So, first order of business is to add our
new_dungeon method to our
DungeonMaker module in
# frozen_string_literal: true require_relative "dungeon_maker/version" module DungeonMaker def self.new_dungeon(number_of_rooms: 10) end end
Now we need a way to store our types of objects so that they can be retrieved in a random fashion when we want. Ruby’s
Array class has a
#sample method that is perfect for this, so I’ll just make a simple array of the object types I want and create the
dungeon object while I’m at it:
# frozen_string_literal: true require_relative "dungeon_maker/version" module DungeonMaker def self.new_dungeon(number_of_rooms: 10) types = [:trapped, :enchanted, :ordinary] dungeon = Dungeon.new(number_of_rooms) end end
You might be asking yourself, like I did, “Hey! Where’d you get that
Dungeon class, mister!? That’s cheating!”. Not quite, Timmy. Whenever I can I write my code this way, I do, because it helps me ignore matters of how to implement things and focus on what I want my program to do. I’ll worry about the how tos and the why fors later.
I might need to change these constructors, but I don’t want implementation detail to guide me on how my algorithm works. Rather, I want my algorithm to inform (in the original sense of the word ) my implementation.
So now I want to loop through the dungeon’s rooms and make them be of any random type:
NOTE: I use #map! because I think it’s more readable. It’s not usually the preferred way of doing things but, since this is the only place I modify this dungeon, it should be fine
# frozen_string_literal: true require_relative "dungeon_maker/version" module DungeonMaker def self.new_dungeon(number_of_rooms: 10) types = [:trapped, :enchanted, :ordinary] dungeon = Dungeon.new(number_of_rooms) dungeon.rooms.map! do |room| type = types.sample room = case type when :trapped TrappedRoom.new when :enchanted EnchantedRoom.new when :ordinary OrdinaryRoom.new else OrdinaryRoom.new end end end end
Pretty straightforward code, right? I sample one of the types, match against it and instantiate the kind of room that I want. The else clause is there for completeness, but it should never be run. If it is, however, I just make an
OrdinaryRoom. One possibility is to make some
BuggyRoom type just so you know this weirdness is going on. Wouldn’t know if players would like it, but hey, there are no bugs, just happy little features.
Finally, we loop through the rooms’ sides and do the same deal as before, except we want to randomly assign a wall or a door. Thankfully, Ruby’s got our back again with
Kernel#rand, so we get:
# frozen_string_literal: true require_relative "dungeon_maker/version" module DungeonMaker def self.new_dungeon(number_of_rooms: 10) types = [:trapped, :enchanted, :ordinary] dungeon = Dungeon.new(number_of_rooms) dungeon.rooms.map! do |room| type = types.sample room = case type when :trapped TrappedRoom.new when :enchanted EnchantedRoom.new when :ordinary OrdinaryRoom.new else OrdinaryRoom.new end room.sides.map! do |side| make_wall = rand() >= 0.5 if make_wall type = types.sample side = case type when :trapped TrappedWall.new when :enchanted EnchantedWall.new when :ordinary OrdinaryWall.new else OrdinaryWall.new end else type = types.sample side = case type when :trapped TrappedDoor.new when :enchanted EnchantedDoor.new when :ordinary OrdinaryDoor.new else OrdinaryDoor.new end end end room end dungeon end end
Done. Well, of course, we still need to actually write all those classes, but we don’t need to write them to spot the dragon that has emerged from the depths called complexity.
First problem is this code isn’t as readable as it could be due to all of the
case statements. Second, and this is the actual problem, suppose one day we decide to add 3 new types of doors, 7 new types of walls, 2 new types of rooms and, God forbid, we decide we want our rooms to have any number of sides between 3 and 8 and that sides can have both doors and walls. You’d have cases within if else statements nested under more cases. The dragon is real and is rearing it’s head. Time to pull out our sword engraved with the powerful
Abstract Factory enchantment.
NOTE: The next code snippets will already have the changes needed due to the addition of the necessary
Roomclasses. If you want to see how these were added and required, checkout the commits for the project
How our sword is forged
So the problem is laid before us: we have different kinds of objects that need to be instantiated and we want our program to be able to, at runtime, randomly select a type of object to be created without polluting our algorithm’s logic with how these objects are instantiated.
The pattern that solves this pickle is the Abstract Factory. It will define a common interface (methods that our program can call) for our
DungeonMaker to interact with and we’ll move the logic concerning which kind of objects to instantiate elsewhere.
On the other side of the Abstract Factory we have our concrete implementations. So, in our case, Abstract Factory can be a class that defines methods for making rooms, doors and walls and we create three different concrete factories, one for each type of object we want, that will implement each of these methods:
Classically, and if you read the Design Patterns book they illustrate it this way, we’d inject the concrete factory we want at runtime and our program would use that implementation only. In our case, we have an extra twist where we want to mix and match the different possible kinds (Hmm… wonder if there’s a pattern for that…) so that we don’t just get either Ordinary Dungeons or Enchanted Dungeons, but a dungeon that can have rooms, doors and walls of any kind.
Smiting our foe
So we’d like our abstract factory to have methods that allow us to create rooms, doors and walls of each kind. Technically, I could just make a
RandomDungeonFactory that’ll randomly pick one of the types and just return the new object, but I want some flexibility. Maybe, in the future, I want to give my gem’s user the ability to generate a purely enchanted maze, who knows? It’s best to have the randomization logic seperated from my factories.
Returning to the methods our abstract factory should have. I can identify 3 at the very least:
#make_room. Here we hit a design decision particular to Ruby. If this were in languages like Java, I’d declare an
abstract class, or maybe a simple
interface, that my concrete factories would have to extend or implement. Ruby being Ruby though, I have 2 options:
- I can create an
AbstractFactoryclass which my concrete implementations will inherit and override
- I can use the so called “duck typing” available to me due to how Ruby does it’s typing: if it quacks like a factory, it is a factory. With this option, there’s no need to create a seperate
AbstractFactoryclass. I just need to create the Factories I want and make sure they all have the 3 methods I want
I’ll go with the second option because I feel that adding an extra class just to be overriden seems unnecessary.
Also, I need to change my
DungeonMaker#new_dungeon method to use these factories. I’ll also keep the randomization logic in it since I don’t see a reason to move it elsewhere yet. Therefore, our classes should look like:
require "dungeon_maker/doors" require "dungeon_maker/walls" require "dungeon_maker/rooms" module DungeonMaker module Factories class TrappedDungeonFactory def make_door Doors::TrappedDoor.new end def make_wall Walls::TrappedWall.new end def make_room Rooms::TrappedRoom.new end end end end
require "dungeon_maker/doors" require "dungeon_maker/walls" require "dungeon_maker/rooms" module DungeonMaker module Factories class EnchantedDungeonFactory def make_door Doors::EnchantedDoor.new end def make_wall Walls::EnchantedWall.new end def make_room Rooms::EnchantedRoom.new end end end end
require "dungeon_maker/doors" require "dungeon_maker/walls" require "dungeon_maker/rooms" module DungeonMaker module Factories class OrdinaryDungeonFactory def make_door Doors::OrdinaryDoor.new end def make_wall Walls::OrdinaryWall.new end def make_room Rooms::OrdinaryRoom.new end end end end
new_dungeon method now looks like this:
require "dungeon_maker/dungeon" require "dungeon_maker/factories" module DungeonMaker def self.new_dungeon(number_of_rooms: 10) factories = [Factories::TrappedDungeonFactory.new, Factories::EnchantedDungeonFactory.new, Factories::OrdinaryDungeonFactory.new] dungeon = Dungeon.new(number_of_rooms) dungeon.rooms.map! do factory = factories.sample room = factory.make_room room.sides.map! do factory = factories.sample make_wall = rand >= 0.5 make_wall ? factory.make_wall : factory.make_door end room end dungeon end end
So much better. And just to prove it works, I also have console output:
There’s much more that needs to be done to get our game working, but I believe we’re off to a good start. Maybe not all the patterns in the book I’ll implement into this game, but it will definitely come up in future articles.