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.
Kieli vaihtuu A+:n sivujen yläreunan painikkeesta. Tai tästä: Vaihda suomeksi.
Chapter 9.3: Interactive Fiction and Code Quality
Introduction: A Forest Adventure
The AdventureDraft module is 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:
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 see that the game works but is low in entertainment. That’s something you’ll get to fix in an 11.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 program is to run it line by line in the debugger
This diagram may also help.
Keep the code on hand as you read on.
Don’t read on before you’ve explored the code.
Let’s Develop AdventureDraft
Suppose we want to:
add a new area to the game world;
add a couple more directions of movement (e.g., go up might send the player up a tree); and
offer a GUI that presents the game in a separate window as shown.
Let’s look at each goal in turn. We’ll find that the code, in its given state, 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. We can just write
the new area into the Adventure
class:
val northPole = 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.
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.Class
Area
needs new instance variables:upNeighbor
anddownNeighbor
.So that the player character can move in these new directions, we need a couple more
if
s in thePlayer
class’sgo
method:if direction == "up"
etc.In order for the new directions to show up in area descriptions, we need to edit
printAreaInfo
in classAdventure
to add a couple of additionalif
s:if area.upNeighbor.isDefined then 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 problem is that 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 treatment of neighbors in class Player
,
whose go
method now looks like this:
def go(direction: String) =
var destination: Option[Area] = None
if direction == "north" then
destination = this.location.northNeighbor
else if direction == "east" then
destination = this.location.eastNeighbor
else if direction == "south" then
destination = this.location.southNeighbor
else if direction == "west" then
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" then
this.northNeighbor
else if direction == "east" then
this.eastNeighbor
else if direction == "south" then
this.southNeighbor
else if direction == "west" then
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:
The method
neighbor
(above) still contains a repetitive list of directions. Fair enough, it’s located within classArea
, so at least any additions to the list will be made in that class.We need to add a new instance variable in
Area
for each additional direction (e.g.,upNeighbor
).The
Adventure
class is no simpler. ItsprintAreaInfo
method still needs to attend to any added direction separately.
A familiar tool rids us of the first two complaints:
Refactoring #2: Using a Map
to eliminate redundancy
The neighbor
method has one responsibility: 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 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.
A 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 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.
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 whitespace makes our code nice and tidy. Code conventions may be flouted tastefully.
And here is setNeighbors
for class Area
:
def setNeighbors(exits: Vector[(String, Area)]) =
for exit <- exits do
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 Map
s
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 tools in the Scala API make use of this
“asterisk trick”, which is better known as variable arguments
or “varargs”. One example is how you can pass in any number of
parameters to a new map in Map(...)
..
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 then
print(" north")
if area.eastNeighbor.isDefined then
print(" east")
if area.southNeighbor.isDefined then
print(" south")
if area.westNeighbor.isDefined then
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 a
number of specific directions. What’s more, the method doesn’t even work anymore, 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 itsrun
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 Program
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
print
ing and keyboard-reading takes place in classAdventureTextUI
.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 module contains a tiny
Item
class. In the Scaladocs, there are several methods that involveItem
s, although they haven’t been implemented as code yet. You’ll deal with that in the programming assignment below.
Assignment: Develop the Game Further
Develop the game in module Adventure by adding items to it. Use Map
s to
accomplish that.
That is:
Each
Area
should be able to hold items. TheItem
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.
Maps are well suited for storing items. Use them for storing items. Do not use a vector or buffer instead! The type
Map[String, Item]
will serve you well.
See below for an example game session.
Instructions and hints
The given classes need additional methods that implement the desired functionality. See the Scaladocs.
Some of the changes will be to the
Action
class, which has not been detailed in this chapter.
There is a comment in
Adventure.scala
that specifies the initial locations of the remote control and the batteries.The items in this game have unique names; that is, no two items can have the same name.
First add the items to the
Area
s 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!
A+ presents the exercise submission form here.
A few ideas for reflection
Above, we gave
Area
the methodsneighbor
,setNeighbor
, andsetNeighbors
as well as a private variableneighbors
that points to aMap
. We could have madeneighbors
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.)How would you refactor
Action
so that there are noif
(ormatch
) commands and no repetitive creation ofSome
objects? Hint: use aMap
and function objects. Is that solution substantially better or worse than the given one?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 createAction
objects under such a scheme? 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 11.1. That chapter consists of a single programming assignment, which is big enough that Week 11 is actually two weeks long. Here’s the summary: “Create your own text-adventure game by editing the Adventure program as you please.” You can start brainstorming already. Consider working with a pair, even if you didn’t before. 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 and so contributed to this ebook’s design. Thank you!
The ebook’s chapters, programming assignments, and weekly bulletins have been written in Finnish and translated into English by Juha Sorva.
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, Kaisa Ek, Joonatan Honkamaa, Antti Immonen, Jaakko Kantojärvi, Onni Komulainen, Niklas Kröger, Kalle Laitinen, Teemu Lehtinen, Mikael Lenander, Ilona Ma, Jaakko Nakaza, Strasdosky Otewa, Timi Seppälä, Teemu Sirkiä, Joel Toppinen, Anna Valldeoriola Cardó, 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 did 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, Juha Sorva, and Jaakko Nakaza. Several of its key components are built upon Aleksi’s SMCL library.
The pedagogy of using O1Library 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+ was originally created at Aalto’s LeTech research group as a student project. The open-source project is now shepherded by the Computer Science department’s edu-tech team and hosted by the department’s IT services; dozens of Aalto students and others have also contributed.
The A+ Courses plugin, which supports A+ and O1 in IntelliJ IDEA, is another open-source project. It has been designed and implemented by various students in collaboration with O1’s teachers.
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.
Each new
Area
gets instance variable calledneighbors
. It refers to aMap
whose keys are strings (names of directions) and whose values areArea
objects.