Luet oppimateriaalin englanninkielistä versiota. Mainitsit kuitenkin taustakyselyssä osaavasi suomea. Siksi suosittelemme, että käytät suomenkielistä versiota, joka on testatumpi ja hieman laajempi ja muutenkin mukava.

Suomenkielinen materiaali kyllä esittelee englanninkielisetkin termit. Myös suomenkielisessä materiaalissa käytetään ohjelmaprojektien koodissa englanninkielisiä nimiä kurssin alkupään johdantoesimerkkejä lukuunottamatta.

Voit vaihtaa kieltä A+:n valikon yläreunassa olevasta painikkeesta. Tai tästä: Vaihda suomeksi.


Chapter 9.1: Interactive Fiction and Code Quality

About This Page

Questions Answered: Some more practice on an application with multiple classes, please? How can I write code that’s easy to develop further?

Topics: A few principles of program design. A few words about modifiability and refactoring. Additional practice on Maps.

What Will I Do? Read first, program next. Prepare for the time-consuming, open-ended programming assignment that makes up Chapter 10.1.

Rough Estimate of Workload:? Three or four hours.

Points Available: B80.

Related Projects: AdventureDraft (new)Adventure (new)
We’ll examine the former; you’ll edit the latter.
../_images/person02.png

Introduction: A Forest Adventure

The AdventureDraft project a text-based adventure game. In such games, the player controls a character in the game world by issuing textual commands; the game also uses text to describe the character’s current location.

Launch o1.adventure.draft.ui.AdventureTextUI. The game will run in the text console:

../_images/adventure_console.png

Spend a few moments with the game. You can instruct the player to go north, go west, and rest, for example. Type quit to end the game.

If you wish, you can take a look at a map of the tiny game word in forest_map.gif.

You’ll notice that the game works but is low in entertainment. That’s something you’ll get to fix in a 10.1 assignment. As for the present chapter, we’ll explore the given implementation and improve its quality through refactoring.

About AdventureDraft

Right now: take some time to examine the given program.

The rest of this chapter assumes that you are more or less familiar with AdventureDraft! Read through the entire code. You don’t have to dig into every implementation detail but you should try to get a general sense of each class and each method.

A good way to explore the project is to run it line by line in the debugger

This diagram may also help.

../_images/project_adventuredraft.png

Keep the code on hand as you read on.

Don’t read on before you’ve explored the code.

Let’s Extend AdventureDraft

Suppose we want to:

  1. add a new area to the game world;
  2. add a couple more directions of movement (e.g., go up might send the player up a tree); and
  3. offer a GUI that presents the game in a separate window as shown.
../_images/adventure_gui.png

Let’s look at each goal in turn. We’ll find that, as given, the code doesn’t readily bend to our will.

Adding a New Area

Our first goal, adding an area to the game, doesn’t pose any problems yet. We can just write the new area into the Adventure class:

val northPole = new Area("The North Pole", "You find yourself at the North Pole. BRRR!")
northForest.northNeighbor = Some(northPole)
northPole.southNeighbor = Some(northForest)

Adding Directions

Adding directions of movement — up and down, for example — is harder. It turns out that we’ll have to modify the program in several places.

  1. Of course, we have to edit Adventure where it defines the game map. That’s where we list all the areas and all the directions of movement that lead from one area to another.
  2. Class Area needs new instance variables: upNeighbor and downNeighbor.
  3. So that the player character can move in these new directions, we need a couple more ifs in the go method of class Player: if (direction == "up") etc.
  4. In order for the new directions to show up in area descriptions, we need to edit printAreaInfo in class Adventure to add a couple of additional ifs: if (area.upNeighbor.isDefined) print(" up") etc.

This isn’t great. We’d like our program to be easy to modify. Such a simple extension of existing functionality should not require changes to several parts of the program.

What’s more, our already repetitive code just keeps getting more and more redundant as we add directions.

The source of the problems is the way the logic that deals with neighboring areas is scattered all over the program.

Let’s refactor the code in stages.

Refactoring #1: introducing a method parameter

Let’s try to put all the code that deals with neighboring areas into class Area.

As a first step, let’s eliminate the repetitive list of neighbors in class Player, whose go method initially looks like this:

def go(direction: String) = {
  var destination: Option[Area] = None
  if (direction == "north") {
    destination = this.location.northNeighbor
  } else if (direction == "east") {
    destination = this.location.eastNeighbor
  } else if (direction == "south") {
    destination = this.location.southNeighbor
  } else if (direction == "west") {
    destination = this.location.westNeighbor
  }
  // ...

We wouldn’t like to modify Player at all just to introduce a new direction. The class therefore shouldn’t contain any code that lists all the specific directions available in the game.

It’s not too hard to fix this. Instead of asking an Area object for its “northern neighbor”, “eastern neighbor”, and so on, we’ll ask the area to tell us its “neighbor in direction X”:

def go(direction: String) = {
  val destination = this.location.neighbor(direction)
  // ...

Our code is now shorter. The redundancy is gone and there are no more mentions of specific directions. We no longer need to edit go if new directions are added to the game. This is great!

Of course, now we need to provide a neighbor method in class Area. Here’s an uneducated implementation:

class Area(var name: String, var description: String) {

  private var northNeighbor: Option[Area] = None
  private var eastNeighbor:  Option[Area] = None
  private var southNeighbor: Option[Area] = None
  private var westNeighbor:  Option[Area] = None

  def neighbor(direction: String) =
    if (direction == "north")
      this.northNeighbor
    else if (direction == "east")
      this.eastNeighbor
    else if (direction == "south")
      this.southNeighbor
    else if (direction == "west")
      this.westNeighbor
    else
      None

Bonus info

Programmers sometimes refer to different refactorings by name. What we did here, for instance, may be called “parameterizing a method”. If you’re interested, take a look at the long Catalog of Refactorings.

Our program is already better than it was, but there’s still plenty of room for complaint. In particular:

  1. The method neighbor (above) still contains a repetitive list of directions. Fair enough, it’s located within class Area, so at least any additions to the list will be made in that class.
  2. We need to add a new instance variable in Area for each additional direction (e.g., upNeighbor).
  3. The Adventure class is no simpler. Its printAreaInfo method still needs to attend to any added direction separately.

An already-familiar tool rids us of the first two complaints:

Refactoring #2: using a Map to eliminate redundancy

The neighbor method has one job: to fetch the Area object that corresponds to a given direction (string). Sounds like a job for a Map.

import scala.collection.mutable.Map

class Area(var name: String, var description: String) {

  private val neighbors = Map[String, Area]()

  def neighbor(direction: String) = this.neighbors.get(direction)
We give each new Area a new instance variable neighbors. It refers to a Map whose keys are strings (names of directions) and whose values are Area objects.
We can now get an Option[Area] so that neighbor works exactly as planned.

This implementation is both simpler and easier to modify. We no longer need a list of directions in the source code. neighbor works on any string that we use as a direction.

Remark on abstraction

Those refactorings rely on abstraction: our refactored code deals with the general case rather than the various individual cases. This abstraction is very intuitive: it is natural for us to think of the player character as “moving to the neighboring area in direction X” rather than as “checking if the direction is north and moving north if it is, otherwise checking if the direction is east, etc.”

Refactoring #3: the game map

We didn’t yet deal with how each area’s neighbors are set up as we construct the game world.

Our original Adventure class says this:

middle.northNeighbor = Some(northForest)
middle.eastNeighbor  = Some(tangle)
middle.southNeighbor = Some(southForest)
middle.westNeighbor  = Some(clearing)

northForest.eastNeighbor  = Some(tangle)
northForest.southNeighbor = Some(middle)
northForest.westNeighbor  = Some(clearing)

// etc.

This code isn’t compatible with our revised Area class, which doesn’t have northNeighbor and its specific siblings. Let’s instead give Area a method that adds a key–value pair in the Map:

def setNeighbor(direction: String, neighbor: Area) = {
  this.neighbors += direction -> neighbor
}

In Adventure, we can now discard the Option wrappers and just call setNeighbor:

middle.setNeighbor("north", northForest)
middle.setNeighbor("east",  tangle)
middle.setNeighbor("south", southForest)
middle.setNeighbor("west",  clearing)

northForest.setNeighbor("east", tangle)
northForest.setNeighbor("south", middle)
northForest.setNeighbor("west",  clearing)

// etc.

Notice that we can now add new directions without changing any of the other classes, even Area. It suffices to pass a new string to setNeighbor.

How would you add a treehouse that the player can climb up to? (Do it if you want to practice.)

Our program is already significantly better, but let’s indulge in a bit of showboating. Since Adventure constructs the entire game world at once, it would be nice if we could add multiple neighbors with a single method call. Perhaps we could define the entire world map like this:

     middle.setNeighbors(Vector("north" -> northForest, "east" -> tangle, "south" -> southForest, "west" -> clearing   ))
northForest.setNeighbors(Vector(                        "east" -> tangle, "south" -> middle,      "west" -> clearing   ))
southForest.setNeighbors(Vector("north" -> middle,      "east" -> tangle, "south" -> southForest, "west" -> clearing   ))
   clearing.setNeighbors(Vector("north" -> northForest, "east" -> middle, "south" -> southForest, "west" -> northForest))
     tangle.setNeighbors(Vector("north" -> northForest, "east" -> home,   "south" -> southForest, "west" -> northForest))
       home.setNeighbors(Vector(                                                                  "west" -> tangle     ))
For this to work, we need Area objects to have a setneighbors method that...
... takes in a vector of key–value pairs that represents the exits from the location.
A bit of unconventional indenting makes our code nice and tidy. Code conventions exist to be tastefully flouted.

And here is setNeighbors for class Area:

def setNeighbors(exits: Vector[(String, Area)]) = {
  for (exit <- exits) {
    this.neighbors += exit
  }
}
Note the different kinds of brackets in the parameter type: the method takes in a vector that contains pairs, each of which is constructed from a String and an Area.
The method simply loops through the pairs and adds them in the Map that stores the area’s neighbors.

More implementations

As an additional trick, you may wish to know that mutable Maps have a ++= method that adds a collectionful of key–value pairs at once. We can use it to implement setNeighbors as shown below.

def setNeighbors(exits: Vector[(String, Area)]) = {
  this.neighbors ++= exits
}

As yet another trick, you may wish to know that you can use the asterisk * to define a method that takes an arbitrary number of parameters. So this works, too:

def setNeighbors(exits: (String, Area)*) = {
  this.neighbors ++= exits
}

Above, exits is a container for all the parameter values of type (String, Area) that we pass in. This solution also simplifies the code in class Adventure:

    middle.setNeighbors("north" -> nForest, "east" -> tangle, "south" -> sForest, "west" -> clearing)
   nForest.setNeighbors(                    "east" -> tangle, "south" -> middle,  "west" -> clearing)
   sForest.setNeighbors("north" -> middle,  "east" -> tangle, "south" -> sForest, "west" -> clearing)
  clearing.setNeighbors("north" -> nForest, "east" -> middle, "south" -> sForest, "west" -> nForest )
    tangle.setNeighbors("north" -> nForest, "east" -> home,   "south" -> sForest, "west" -> nForest )
      home.setNeighbors(                                                          "west" -> tangle  )

Incidentally, many of the methods in the Scala API make use of this “asterisk trick”, which is better known as variable arguments or “varargs”. For instance, the fact that you can pass in any number of parameters to a new map in Map(...) is based on this technique.

Refactoring #4: bringing methods and data together

As far as adding directions is concerned, we have one more point to cover: printAreaInfo in class Adventure, which is now in this shape:

def printAreaInfo() = {
  val area = this.player.location
  println("\n\n" + area.name)
  println("-" * area.name.length)
  println(area.description)
  print("\nExits available:")
  if (area.northNeighbor.isDefined) {
    print(" north")
  }
  if (area.eastNeighbor.isDefined) {
    print(" east")
  }
  if (area.southNeighbor.isDefined) {
    print(" south")
  }
  if (area.westNeighbor.isDefined) {
    print(" west")
  }
  println()
  println()
}

(Side note: The code uses print, a function that is like println except that it doesn’t change lines after printing out the designated characters. So we get the exits listed on a single line of output.)

We’ve already established the original problem with printAreaInfo: the code lists numerous specific directions. What’s more, the method doesn’t even work any more, since our refactored program no longer has separate variables for each direction.

Another grumble is that this method of class Adventure operates almost exclusively on data that is stored elsewhere (in an Area object). In object-oriented programming, we’d usually like to define operations in the same place with the data being operated on. By doing so, we boost the program’s cohesion (koheesio): the “togetherness” of the pieces that make up each program component. A cohesive program is easier to read and maintain.

We can solve these problems by moving printAreaInfo from Adventure to Area, where the pertinent data is. As part of that move, let’s edit the method to use the Map that we’ve stored in neighbors:

def printAreaInfo() = {
  println("\n\n" + this.name)
  println(this.name.replaceAll(".", "-"))
  println(this.description)
  println("\nExits available: " + this.neighbors.keys.mkString(" ") + "\n\n")
}

How much did we accomplish?

The text adventure is now easier to develop further than it was. You’ve hopefully learned something about refactoring programs already. But we didn’t do everything we intended yet.

At the beginning of the chapter, we planned to furnish the game with a graphical interface. As things stand, this aim is hard to realize, because AdventureDraft’s implementation breaks a key principle of application design:

On User Interfaces and Domain Models

Chapters 1.2 and 2.7 identified the model and the user interface as the two primary components of any application program. Many of the programs you’ve seen since are split in two thus.

Even though AdventureDraft also has packages named o1.adventure.draft.ui (the user interface) and o1.adventure.draft (the model), that division hasn’t been adequately implemented:

  • The user interface comprises a single app object that merely creates an Adventure object and calls its run method. The UI doesn’t actually take care of interacting with the user by printing out text and reading input.
  • On the other hand, the classes of the model, which should only represent the game world and its internal logic, don’t stick to their task: they print out various things in any which method. One of the methods in class Adventure even stops to read input, despite that class belonging to the domain model.

So what to do about our goal of replacing the console UI with a GUI as illustrated above? We would need to change the code throughout, replacing input and output instructions with GUI code. And what if we wanted to offer the user their pick of two alternative interfaces — the console UI and the GUI? Do we need two entirely separate versions of the program, with lots of duplicate code?

Let’s phrase our question in more general terms: what if we wish to use our program’s domain model in a different user interface or even a different application?

Separating model from interface

In a well-designed application, the model will be as independent as possible from any user interface (and from any other code that uses the model, such as a test program). The model’s classes don’t communicate with end users; they don’t, for instance, print out anything, read any input, open any GUI windows, or react to button clicks. It should be possible to change the user interface, or replace it entirely, without touching the model.

A user interface portrays the model for the end user to observe (e.g., as printouts about the game world); it also receives user inputs that it uses to manipulate the model somehow (e.g., commands to move the player character). The user interface calls the methods of the objects that make up the model and, therefore, must know in detail which sort of model it is an interface to; it can’t be independent of the model.

Adventure, the Improved Project

Adventure is a better version of AdventureDraft. It is better in that:

  • It has been refactored as discussed above.
  • Its user interface has been separated from the domain model:
    • All printing and keyboard-reading takes place in class AdventureTextUI.
    • The classes of the domain model don’t print anything. They merely return strings that describe areas, the results of player actions, events in the game world, etc. It’s up to the user interface to determine how those strings are presented to the human player. (We stick with the assumption that any interface for this game will be text-based in one way or another.)
  • There is also a graphical user interface. It launches from the app object AdventureGUI.
  • The project contains a tiny Item class. In the Scaladocs, there are several methods that involve Items, although they haven’t been implemented as code yet. You’ll deal with that in the programming assignment below.

Now study the improved Adventure

Fetch Adventure. Study it on your own. Note how it differs from AdventureDraft.

Again, you can run the code line by line in the debugger.

Don’t forget to study class Action, too, even though our discussion in this chapter has barely touched on it.

../_images/project_adventure.png

Assignment: Extend the Game

Develop the game in project Adventure by adding items to it. That is:

  • Each Area should be able to hold items. The Item class has been written already, but the other classes don’t use it yet; you’ll be fixing that.
  • The items in an area should appear as part of the area description of the player character’s current location.
  • The player should be able to pick up items with get itemname and to drop them with drop itemname.
  • The player should be able to look at the items in their possession with examine itemname.
  • The command inventory should produce a listing of the items that the player is currently carrying.
  • The win condition should be more elaborate. It’s no longer enough that the player finds their way home; they should first get their hands on a remote control and some batteries and only then head home with those two items in their possession.

See below for an example game session.

Instructions and hints

  • The given classes need additional methods that implement the desired functionality. See the Scaladocs.
  • There is a comment in Adventure.scala that specifies the initial locations of the remote control and the batteries.
  • Maps are well suited for storing Items. Use them for storing items. The items in this game have unique names; that is, no two items can have the same name.
  • First add the items to the Areas of the game world. Then work on the in-game commands that deal with items.
  • Make sure your program produces an output that exactly matches the example run below and the documentation, or A+ will be upset.

Example output

You are lost in the woods. Find your way back home.

Better hurry, 'cause Scalatut elämät is on real soon now. And you can't miss Scalkkarit, right?


Forest
------
You are somewhere in the forest. There are a lot of trees here.
Birds are singing.

Exits available: west north south east


Command: go east
You go east.


Tangle of Bushes
----------------
You are in a dense tangle of bushes. It's hard to see exactly where you're going.

Exits available: west north south east


Command: go east
You go east.


Home
----
Home sweet home! Now the only thing you need is a working remote control.

Exits available: west


Command: inventory
You are empty-handed.


Home
----
Home sweet home! Now the only thing you need is a working remote control.

Exits available: west


Command: go east
You can't go east.


Home
----
Home sweet home! Now the only thing you need is a working remote control.

Exits available: west


Command: go west
You go west.


Tangle of Bushes
----------------
You are in a dense tangle of bushes. It's hard to see exactly where you're going.

Exits available: west north south east


Command: get remote
There is no remote here to pick up.


Tangle of Bushes
----------------
You are in a dense tangle of bushes. It's hard to see exactly where you're going.

Exits available: west north south east


Command: go west
You go west.


Forest
------
You are somewhere in the forest. A tangle of bushes blocks further passage north.
Birds are singing.

Exits available: west south east


Command: go west
You go west.


Forest Clearing
---------------
You are at a small clearing in the middle of forest.
Nearly invisible, twisted paths lead in many directions.
You see here: battery

Exits available: west north south east


Command: examine battery
If you want to examine something, you need to pick it up first.


Forest Clearing
---------------
You are at a small clearing in the middle of forest.
Nearly invisible, twisted paths lead in many directions.
You see here: battery

Exits available: west north south east


Command: get battery
You pick up the battery.


Forest Clearing
---------------
You are at a small clearing in the middle of forest.
Nearly invisible, twisted paths lead in many directions.

Exits available: west north south east


Command: inventory
You are carrying:
battery


Forest Clearing
---------------
You are at a small clearing in the middle of forest.
Nearly invisible, twisted paths lead in many directions.

Exits available: west north south east


Command: examine battery
You look closely at the battery.
It's a small battery cell. Looks new.


Forest Clearing
---------------
You are at a small clearing in the middle of forest.
Nearly invisible, twisted paths lead in many directions.

Exits available: west north south east


Command: go south
You go south.


Forest
------
The forest just goes on and on.
You see here: remote

Exits available: west north south east


Command: get remote
You pick up the remote.


Forest
------
The forest just goes on and on.

Exits available: west north south east


Command: examine remote
You look closely at the remote.
It's the remote control for your TV.
What it was doing in the forest, you have no idea.
Problem is, there's no battery.


Forest
------
The forest just goes on and on.

Exits available: west north south east


Command: inventory
You are carrying:
remote
battery


Forest
------
The forest just goes on and on.

Exits available: west north south east


Command: go east
You go east.


Tangle of Bushes
----------------
You are in a dense tangle of bushes. It's hard to see exactly where you're going.

Exits available: west north south east


Command: drop remot
You don't have that!


Tangle of Bushes
----------------
You are in a dense tangle of bushes. It's hard to see exactly where you're going.

Exits available: west north south east


Command: drop remote
You drop the remote.


Tangle of Bushes
----------------
You are in a dense tangle of bushes. It's hard to see exactly where you're going.
You see here: remote

Exits available: west north south east


Command: drop remote
You don't have that!


Tangle of Bushes
----------------
You are in a dense tangle of bushes. It's hard to see exactly where you're going.
You see here: remote

Exits available: west north south east


Command: go east
You go east.


Home
----
Home sweet home! Now the only thing you need is a working remote control.

Exits available: west


Command: go west
You go west.


Tangle of Bushes
----------------
You are in a dense tangle of bushes. It's hard to see exactly where you're going.
You see here: remote

Exits available: west north south east


Command: get remot
There is no remot here to pick up.


Tangle of Bushes
----------------
You are in a dense tangle of bushes. It's hard to see exactly where you're going.
You see here: remote

Exits available: west north south east


Command: get remote
You pick up the remote.


Tangle of Bushes
----------------
You are in a dense tangle of bushes. It's hard to see exactly where you're going.

Exits available: west north south east


Command: go east
You go east.

Home at last... and phew, just in time! Well done!

Submission form

A+ presents the exercise submission form here.

A few ideas for reflection

  1. Above, we gave Area the methods neighbor, setNeighbor, and setNeighbors as well as a private variable neighbors that points to a Map. We could have made neighbors public, and if we had, we wouldn’t have needed the three methods. (Why not?) Can you spot anything questionable about this proposal? (Here’s one perspective: the Law of Demeter.)
  2. How would you refactor Action to eliminate the ifs and the repetitive creating of Some objects? Hint: use a Map and function objects. Is your solution substantially better or worse than the given one?
  3. How about refactoring the game so that Action is an abstract class or trait, whose various subtypes correspond to the various in-game commands? How would you create Action objects under such a scheme? Hint: try a factory method (Chapter 5.3). Is your solution substantially better or worse than the given one?

Like so many other programming problems, the questions don’t have an unambiguous best answer.

Summary of Key Points

  • The way that a program is designed and implemented affects how easy it is to extend and otherwise modify it.
  • Refactoring a program can substantially improve various aspects of program quality, such as extensibility and readability.
  • Eliminating dependencies between program components tends to make the program easier to modify. Implicit dependencies are particularly toxic to modifiability.
  • Links to the glossary: refactoring; implicit coupling, cohesion, DRY; model vs. user interface; abstraction.

Start thinking about your own game!

We’ll return to Adventure in Chapter 10.1. That chapter consists of a single programming assignment, which is big enough that Week 10 is actually two weeks long. Here’s the summary: “Create your own text-adventure game by editing the Adventure project as you please.” You can start brainstorming already. Make sure to start implementing your game as soon as you can!

Feedback

Please note that this section must be completed individually. Even if you worked on this chapter with a pair, each of you should submit the form separately.

Credits

Thousands of students have given feedback that has contributed to this ebook’s design. Thank you!

Weeks 1 to 13 of the ebook, including the assignments and weekly bulletins, have been written in Finnish and translated into English by Juha Sorva.

Weeks 14 to 20 are by Otto Seppälä. That part of the ebook isn’t available during the fall term, but we’ll publish it when it’s time.

The appendices (glossary, Scala reference, FAQ, etc.) are by Juha Sorva unless otherwise specified on the page.

The automatic assessment of the assignments has been developed by: (in alphabetical order) Riku Autio, Nikolas Drosdek, Joonatan Honkamaa, Jaakko Kantojärvi, Niklas Kröger, Teemu Lehtinen, Strasdosky Otewa, Timi Seppälä, Teemu Sirkiä, and Aleksi Vartiainen.

The illustrations at the top of each chapter, and the similar drawings elsewhere in the ebook, are the work of Christina Lassheikki.

The animations that detail the execution Scala programs have been designed by Juha Sorva and Teemu Sirkiä. Teemu Sirkiä and Riku Autio have done the technical implementation, relying on Teemu’s Jsvee and Kelmu toolkits.

The other diagrams and interactive presentations in the ebook are by Juha Sorva.

The O1Library software has been developed by Aleksi Lukkarinen and Juha Sorva. Several of its key components are built upon Aleksi’s SMCL library.

The pedagogy behind O1Library’s tools for simple graphical programming (such as Pic) is inspired by the textbooks How to Design Programs by Flatt, Felleisen, Findler, and Krishnamurthi and Picturing Programs by Stephen Bloch.

The course platform A+ has been created by Aalto’s LeTech research group and is largely developed by students. The current lead developer is Jaakko Kantojärvi; many other students of computer science and information networks are also active on the project.

For O1’s current teaching staff, please see Chapter 1.1.

Additional credits for this page

This chapter draws inspiration from classic adventure games by Infocom and the work of David Barnes and Michael Kölling.

a drop of ink
Posting submission...

Submission received.