Child pages
  • Eine Beispiellösung für das Text-Adventure

Eine Beispiellösung für das Text-Adventure

Nachfolgend stelle ich Ihnen dar, wie eine Beispiellösung für das Text-Adventure aussehen kann. Die Beispiellösung gibt's hier im Repo: https://git.st.archi-lab.io/students/st2/ss21/exercises/textadventure-lecture-example

Ich habe dabei mal bewusst versucht, meinen "Denkprozess" abzubilden. Das mache ich nicht, weil ich den so einzigartig finde (Lächeln). Da gibt es in jedem Fall auch andere sinnvolle Herangehensweisen. Aber vielleicht hilft es Ihnen, wenn ich versuche, meine Denkprozesse transparent zu machen. 

1. Erster Schritt: Die Entities

Als erstes habe ich mir überlegt, welche Entities ich gern in meinem Spiel hätte. Das fand ich ziemlich leicht und "straightforward". Hier die erste Version des Modells:  

Ein paar Prinzipien fand ich aus der Aufgabenstellung heraus recht klar:

  • Player und Monster haben Gemeinsamkeiten, brauchen also eine gemeinsame Abstraktion
    • belegen ein Feld exklusiv
    • haben eine "Strength", die durch Kämpfe u.a. verändert wird.
    • Letzteres war für mich das Argument, da eine abstrakte Klasse draus zu machen.
  • Game enthält den Game-Loop und die Initialisierung
  • Dungeon ist ein Entity, und Field auch, damit ein Field Dinge (Items) enthalten kann

Anderes habe ich erstmal offen gelassen:

  1. Wo und wie passiert die Kommandoverarbeitung?
  2. Wie genau ist die Datenstruktur im Dungeon für die Fields? Array?
  3. Wie wird ein Kampf abgebildet (und dass verschiedene Waffen verschiedene Effekte haben?)

2. Erster Code (und ein bisschen Gold-Plating bei der Darstellung)

Als erstes habe ich dann die Entities angelegt und angefangen, die Methoden und Abstraktionen umzusetzen. Dabei war "Printable" ein guter Startpunkt, weil man das sowieso braucht und es einen guten Rahmen für das Game schafft. Sprich: Ich hatte einen gut definierten "Meilenstein", der aus statisch ausgeprinteten Spielfeld bestand.

Etwas Gold-Plating konnte ich mir nicht verkneifen: Player und Monster werden in Farbe ausgegeben, und die Farbe ändert sich mit der relativen Strength. Damit musste ich dann auch direkt das Strength-Modell machen, was auch ganz hilfreich war. 

Die Frage nach der Datenstruktur Dungeon - Field musste dann wegen dem Printen schnell entschieden werden - fürs Erste als Array. Wenn das nicht trägt, wird es geändert. Dann noch die Init-Methode in Game mit dem Player auf 0,0 und Monstern in allen vier Ecken - ging glatt durch und ohne erkennbare "bad smells" im Code. 

3. Modell: Nur noch ein einziger Items-Typ

Bei den Items schien es so, dass man das Modell ein bisschen einfacher machen konnte - mit "impactOnSelf" und "impactOnOthers" sowie "limitOfUse" waren sowohl Potions wie auch Grenades abgedeckt. Sogar Sprengstoffgürtel, wenn man wollte (traurig)

4. Refactoring: Anwendung der Stepdown Rule

Beim Platzieren der Items dann die erste Anwendung der Stepdown Rule / Keep Methods Small. Game.initialize() war jetzt schon ziemlich lang:

public void initialize() {
dungeon = new Dungeon( 5, 5);
player = new Player( 50.0f );
dungeon.getField(0, 0 ).setCreature( player );

// monsters in all the other corners:
Monster monster = new Monster( 50.0f );
monsters.add( monster );
dungeon.getField(0, 4 ).setCreature( monster );
monster = new Monster( 100.0f );
monsters.add( monster );
dungeon.getField(4, 4 ).setCreature( monster );
monster = new Monster( 150.0f );
monsters.add( monster );
dungeon.getField(4, 0 ).setCreature( monster );

// some weapons and potions
dungeon.getField(2, 1 ).setItem(
new Item( "potion", +10.0f, 0f, 1 )
);
dungeon.getField(3, 0 ).setItem(
new Item( "knife", 0.0f, -5.0f )
);
dungeon.getField(2, 2 ).setItem(
new Item( "sword", 0.0f, -20.0f )
);
dungeon.getField(2, 4 ).setItem(
new Item( "lasergun", 0.0f, -60.0f )
);
}

Also Refactoring und das Platzieren von Monstern und Items rausgezogen: 

public void initialize() {
dungeon = new Dungeon( 5, 5);
player = new Player( 50.0f );
dungeon.getField(0, 0 ).setCreature( player );
placeMonsters();
placeItems();
}

private void placeMonsters() {
// monsters in all the other corners:
Monster monster = new Monster( 50.0f );
monsters.add( monster );
dungeon.getField(0, 4 ).setCreature( monster );
monster = new Monster( 100.0f );
monsters.add( monster );
dungeon.getField(4, 4 ).setCreature( monster );
monster = new Monster( 150.0f );
monsters.add( monster );
dungeon.getField(4, 0 ).setCreature( monster );
}

private void placeItems() {
dungeon.getField(2, 1 ).setItem(
new Item( "potion", +10.0f, 0f, 1 )
);
dungeon.getField(3, 0 ).setItem(
new Item( "knife", 0.0f, -5.0f )
);
dungeon.getField(2, 2 ).setItem(
new Item( "sword", 0.0f, -20.0f )
);
dungeon.getField(2, 4 ).setItem(
new Item( "lasergun", 0.0f, -60.0f )
);
}

5. "Printable" interface

Das Printable Interface bewährt sich jetzt schon als Abstraktion, weil man einfach das Drucken wunderbar hierarchisch durchreichen kann. Um während der Entwicklung zu sehen, wo meine Items liegen, drucke ich sie mit aus. Das ist das Ergebnis: 

 

Dafür brauche ich folgenden Code:

Printable

package thkoeln.archilab.exercises.textadventure;

public interface Printable {
public void print();
}

Game.main(...)

game.getDungeon().print();

Dungeon

@Override
public void print() {
for ( int x = 0; x < width; x++ ) {
for ( int y = 0; y < height; y++ ) {
fields[x][y].print();
System.out.print( " " );
}
System.out.println( "" );
}
}

Field

@Override
public void print() {
if( creature != null ) {
creature.print();
}
else if( item != null ) {
item.print();
}
else {
System.out.print( "_" );
}
}

LivingCreature

public abstract class LivingCreature implements Printable {
//...
public abstract String printSymbol();

@Override
public void print() {
ColorGradientPrinter.colorPrint( printSymbol(), getRelativeStrength() );
}
}


Unschön: Die Formatierung des Dungeons ist über mehrere Klassen verstreut; das könnte man nochmal angehen. Später. 

6. Nächster Entwicklungsschritt: Bewegen des Players

Als nächsten "Teilmeilenstein" möchte ich den Player im Dungeon bewegen können. Dafür muss ich mir überlegen, wo der Command Processor hinkommt. Naheliegendste Lösung: 

  • in Game
  • Dort wird einem Loop wird so lange ein Kommando eingelesen, bis die Abbruchbedingung erfüllt ist

Als erste Implementierung des Command Processors habe ich erstmal eine einfache Lösung gewählt:

public void play() {
while( !isFinished() ) {
String[] command = nextCommand();
String outMsg = "You wrote: '" + command[0] + "'";
if (command.length > 1) {
outMsg += "' and then the word '" + command[1] + "'";
}
System.out.println(outMsg);
}
}

public boolean isFinished() {
return false;
}


private String[] nextCommand() {
while( true ) {
String line = scanner.nextLine();
String[] parts = line.split("[ \t]+");
if ((parts.length < 1 || parts.length > 2) || (parts[0].length() != 1)) {
System.out.println("*** Invalid input! Please try again. ***");
}
return parts;
}
}

Allerdings hat mich das nicht wirklich zufrieden gemacht:

  • String[] ist keine gute Datenstruktur, besser wäre eine echte Command-Klasse
  • ... und statt der Outputs wären Exceptions besser. 

7. Refactoring: Verbesserung mit Command-Klasse und Exceptions

Wir führen einen Enum für die verschiedenen Kommando-Typen ein. 

CommandType

public enum CommandType {
s, e, w, n, u, t, d;

public boolean isMoveCommand() {
return ( this == s || this == n || this == e || this == w );
}
}

Game

So sieht das in der Game-Klasse schon besser aus: 

private Command nextCommand() {
Command command = null;
while( command == null ) {
String line = scanner.nextLine();
try {
command = Command.valueOf(line);
} catch (CommandException exception) {
System.out.println(exception.getMessage() + " Please try again.");
}
}
return command;
}

Die ganze Verarbeitung des String-Inputs mit angemessener Fehlerprüfung findet dann in einer Command-Factory-Methode statt (Command.valueOf( ... )): 

Command

public static Command valueOf(String inputFromCommandLine ) {
if( inputFromCommandLine == null ) throw new CommandException( "Invalid command line!" );
String[] parts = inputFromCommandLine.split("[ \t]+");
if ( parts.length < 1 ) throw new CommandException( "Empty input!" );
if ( parts.length > 2 ) throw new CommandException( "Max. 2 words allowed!" );
if ( parts[0].length() != 1 ) throw new CommandException( "First word must be 1-letter command!" );

CommandType commandType;
try {
commandType = CommandType.valueOf( parts[0] );
}
catch ( IllegalArgumentException exception ) {
throw new CommandException( "Unknown command!" );
}
if ( commandType.isMoveCommand() ) {
return new Command( commandType, null );
}
else {
if (parts.length < 2) throw new CommandException("Item name needs to be specified for a non-move command!");
return new Command(commandType, parts[1]);
}
}

Die Methode könnte etwas kürzer sein, aber mir fällt kein guter inhaltlicher Split ein - also bleibt das erstmal so. Dafür deckt sie auch tatsächlich alle Fehlerfälle ab. Das einzige, was noch nicht geprüft wird, ist ob der "itemName" auch existiert (d.h. gibt es auf dem Feld tatsächlich das Item "itemName", das man gerade aufheben will). Die Prüfung passt hier auch nicht so gut hin. Die machen wir da, wo wir ein Feld oder das Inventory kennen. 

8. Refactoring: Zyklische Abhängigkeit zwischen Field und LivingCreature

Jetzt ist alles zusammen, um Kommandos auch ausführen zu können. Dazu kann man in Player eine "execute( Command command )" Methode anlegen, die dann wiederum dedizierte Methoden aufruft. 

Player

public void execute( Command command ) {
if ( command.getCommandType().isMoveCommand() ) {
move( command.getCommandType() );
}
if ( command.getCommandType() == CommandType.t ) take( command.getItemName() );
if ( command.getCommandType() == CommandType.d ) drop( command.getItemName() );
if ( command.getCommandType() == CommandType.u ) use( command.getItemName() );
}

Allerdings stellt sich die Frage nach der zyklischen Abhängigkeit zwischen Field und LivingCreature. Zwar kann man LivingCreature schon als Abstraktion sehen, aber besser scheint es mir, hier noch ein Interface einzuziehen, das bei Field die Abfrage nach "blockiertem Feld" regeln kann. Das kann man mit einem "Blocking" Interface machen. 

Blocking Interface

public interface Blocking extends Printable {
public void place( Field field );
}

Field

public void setInhabitant( Blocking inhabitant ) {
if( this.inhabitant != null ) {
throw new AlreadyBlockedException();
}
this.inhabitant = inhabitant;
}

Platzieren von Player oder Monstern

Das Platzieren von Player oder Monstern sollte dann auch verschoben werden. Statt wie jetzt dem Field zu sagen, dass eine LivingCreature draufsteht ....

private void placeMonsters() {
// monsters in all the other corners:
Monster monster = new Monster( 50.0f );
monsters.add( monster );
dungeon.getField(0, 4 ).setLivingCreature( monster );
monster = new Monster( 100.0f );
monsters.add( monster );
dungeon.getField(4, 4 ).setLivingCreature( monster );
monster = new Monster( 150.0f );
monsters.add( monster );
dungeon.getField(4, 0 ).setLivingCreature( monster );
}

... sollte man besser die LivingCreature auf ein Field setzen: 

private void placeMonsters() {
// monsters in all the other corners:
Monster monster = new Monster( 50.0f );
monster.place( dungeon.getField(0, 4 ) );
monsters.add( monster );

monster = new Monster( 100.0f );
monster.place( dungeon.getField(4, 4 ) );
monsters.add( monster );

monster = new Monster( 150.0f );
monster.place( dungeon.getField(4, 0 ) );
monsters.add( monster );
}

9. Bewegen des Players - wie kommt man an das passende Field?

Die move-Methode ist im Player schon (leer) angelegt, aber es gibt noch ein Problem. Wie komme ich ausgehend vom "Field" an das entsprechende Nachbarfeld? Die Fields hängen einfach als Array im Dungeon. Idee: ich lege eine Hashmap in Field an, die die Nachbarn enthält (neighbours). 

Field

HashMap<CommandType, Field> neighbours = new HashMap<>();

Nicht 100% elegant, weil ich die Himmelsrichtungen und die CommandTypes (von denen es ja noch ein paar mehr gibt) hier zusammenwerfe. Leider kann man aber enums nicht von einander ableiten, sonst würde ich Directions (n,s,e,w) definieren und diese dann zu CommandType erweitern. Wie auch immer - das Problem löse ich später.

Die neighbours werden im Dungeon-Konstruktur initialisiert, wo die Fields instanziiert werden. Auch nicht 100% elegant, weil ich gern eine besser gekapselte Lösung hätte. Aber so geht es erstmal. 

Dungeon

Dungeon( int width, int height ) {
this.width = width;
this.height = height;
fields = new Field[width][height];
for ( int x = 0; x < width; x++ ) {
for ( int y = 0; y < height; y++ ) {
fields[x][y] = new Field();
// as we move from left to right and from top to bottom, this method of setting
// neighbours is sufficient - provided all neighbours are properly initialized to null.
if( x > 0 ) fields[x][y].setWestNeighbour( fields[x-1][y] );
if( y > 0 ) fields[x][y].setNorthNeighbour( fields[x][y-1] );
}
}
}

Die setWestNeighbour und setNorthNeighbour Methoden setzen die Nachbarschaftsbeziehungen jeweils gegenseitig. 

10. Refactoring: Blocker-Konzept bei Feldern konsequenter umsetzen

LivingCreature hat jetzt eine "protected" move-Methode (protected, damit die jeweilige Instanz selbst entscheiden kann, welche Arten von Moves "ok" sind. Bei Player z.B. nur aufgrund eines Kommandos. Man kann also move nicht von außen aufrufen.)

LivingCreature

// Only protected, not public, as it should not be set by the outside world.
// Instead, outside calls should use some dedicated move command. Only instances of
// LivingCreature may directly call this method.
protected void move(CommandType direction ) {
Field newField = currentField.getNeighbours().get( direction );
if ( newField == null ) {
throw new CommandException( "You can't go this way!" );
}
else {
newField.setInhabitant( this);
currentField.setInhabitant( null );
currentField = newField;
}
}

Hier fällt auf, dass das Konzept so nicht so schön umgesetzt ist. 

  1. Es ist unschön, setInhabitant( null ) aufzurufen - besser wäre eine dedizierte Methode, um ein Feld leer zu machen. 
  2. Das gleiche Konzept hat 2 Namen - Blocking und Inhabitant (Verstoß gegen konzeptuelle Integrität, verwandt mit Liskov Substitution Principle). 

Der Deutlichkeit halber mache ich ein Interface daraus und benenne die Methoden entsprechend um: 

Blockable

public interface Blockable {
/**
* Blocker will be removed.
*/
void unblock();

/**
* New blocker added
* @param blocker
*/
void block(Blocking blocker);
}

Field

public class Field implements Printable, Blockable {
//...
@Override
public void unblock() {
this.blocker = null;
}

@Override
public void block(Blocking blocker) throws AlreadyBlockedException {
if( this.blocker != null ) {
throw new AlreadyBlockedException( "This field is already blocked!" );
}
this.blocker = blocker;
}
//...
}

11. Refactoring: Exceptions

Bis jetzt waren alle Exceptions RuntimeExceptions, die nicht deklariert werden müssen. Das ist einerseits einfacher, andererseits auch weniger explizit. Ich mache mal den Versuch, alles auf "checked exceptions" umzustellen - falls mein Code durch viele try/catch-Blöcke verschandelt wird, dann lasse ich das wieder. Die Hoffnung ist, dass ich das nur einmal - oben im Game-Loop - fangen muss. Das funktioniert auch ganz gut: 

Command

public class Command {
public static Command valueOf(String inputFromCommandLine ) throws CommandException {
//...
}
}

Game

nextCommand reicht die Exception noch weiter nach oben ...

private Command nextCommand() throws CommandException {
Command command = null;
while( command == null ) {
String line = scanner.nextLine();
command = Command.valueOf(line);
}
return command;
}

... und im Game-Loop (Methode "play()") wird sie dann gefangen und behandelt (indem ausgegeben wird: "<Error Message>. Please try again."). 

public void play() {
while( !isFinished() ) {
dungeon.print();
System.out.println();
try {
Command command = nextCommand();
player.execute(command);
System.out.println();
}
catch ( CommandException commandException ) {
System.out.println( commandException.getMessage() );
System.out.println( "Please try again.\n");
}
}
}

12. Nächstes Feature: Items vom Boden aufheben

Es sollte eigentlich alles vorbereitet sein, um auch Items zu erkennen und aufzuheben. Das ist auch so - es reichen 6 Zeilen in der Player.take(...) Methode.

Player

Es gibt eine "execute" Methode, die beliebige Kommandos auswerten und an entsprechende move(...), take(...), drop(...) oder use(...) Methoden delegiert. 

public void execute( Command command ) throws CommandException {
if ( command.getCommandType().isMoveCommand() ) {
move( command.getCommandType() );
}
if ( command.getCommandType() == CommandType.t ) take( command.getItemName() );
if ( command.getCommandType() == CommandType.d ) drop( command.getItemName() );
if ( command.getCommandType() == CommandType.u ) use( command.getItemName() );
}

Die take(...)-Methode funktioniert wie man es sich vorstellt: Eine CommandException, wenn es das genannte Item hier nicht gibt. Ansonsten einfach Item vom Field nehmen und dem Inventory zufügen. 

private void take( String itemName ) throws CommandException {
if ( !getCurrentField().containsItem() ||
!getCurrentField().getItem().getName().equals( itemName) )
throw new CommandException( "Here is no " + itemName + "." );
inventory.put( itemName, getCurrentField().getItem() );
getCurrentField().setItem( null );
System.out.println( "You have picked up a " + itemName + "." );
}

Das einzige, was etwas stört: Wenn man versucht, etwas aufzuheben, was nicht  da ist, wird die Meldung (über die Exception) im Game-Loop ausgegeben. Die Erfolgsmeldung erfolgt lokal. Das sollte man auch nochmal ändern, damit man die Kommunikation über eine Methode bündeln kann. Für den Moment lasse ich das so. 

13. Nächstes Feature: Items fallen lassen

Beim Fallenlassen muss man bedenken, dass Felder momentan nur ein Item können. Geht aber mit der bisherigen Struktur prima: 

Player

private void drop( String itemName ) throws CommandException {
if ( !inventory.containsKey( itemName ) )
throw new CommandException( "You don't have a " + itemName + "." );
if ( getCurrentField().containsItem() )
throw new CommandException( "There is no room to drop it." );
getCurrentField().setItem( inventory.get( itemName ) );
inventory.remove( itemName );
}

14. Nächstes Feature: Monster greifen an

Auch das geht jetzt recht leicht. Als zusätzliche Abstraktion definiere ich ein Interface Impactable, das ausdrückt, das man von außen angegriffen oder geheilt werden kann. 

Impactable

public interface Impactable {

/**
* Receive an external impact (positive by healing, or negative by attack)
* @param impact if positive, it is a healing, otherwise it is an attack
*/
public void receiveImpact( float impact );

/**
* @return Maximum strength when fully healthy.
*/
public float getMaxStrength();

/**
* @return Current actual strength.
*/
public float getStrength();

/**
* @return Current relative strength as number between 0 and 1.
*/
public float getRelativeStrength();

/**
* @return true if strength = 0
*/
public boolean isDead();
}

LivingCreature

LivingCreature implementiert dieses Interface. Damit können sich später die Monster auch gegenseitig angreifen. 

Ein Monster bekommt auch ein Item (attackCapability), quasi als "eingebaute Waffe". Dann muss ich nur noch eine attack() Methode bei Monster implementieren und diese aus dem Gameloop aufrufen: 

Monster

public void attack() {
for ( Field neighbourField : getCurrentField().getNeighbours().values() ) {
if ( neighbourField != null && neighbourField.getBlocker() instanceof Impactable ) {
System.out.println( "The monster " + getName() + " attacks!" );
((Impactable) neighbourField.getBlocker()).receiveImpact(
attackCapability.getImpactOnOthers()
);
}
}
}

Game.play()

//...
for
( Monster monster : monsters ) {
monster.attack();
}
//...

15. Refactoring: Stepdown Rule / Keep Methods Small in Game.play()

Die play() Methode ist jetzt viel zu lang. 

public void play() {
System.out.println( "Hello! What is your name?" );
while ( player.getName() == null ) {
String name = scanner.nextLine();
if ( name != null && name.length() > 0 ) {
player.setName( name );
System.out.println( "Hello, " + name +
". Your task is to navigate the dungeon and kill the monsters. Good luck!" );
}
else {
System.out.println( "Please tell me your name ..." );
}
}

while( !isFinished() ) {
dungeon.print();
System.out.println();
try {
Command command = nextCommand();
player.execute(command);
if ( player.getCurrentField().containsItem() ) {
System.out.println( "On the ground, you see a " + player.getCurrentField().getItem() + "." );
}
System.out.println();
}
catch ( CommandException commandException ) {
System.out.println( commandException.getMessage() );
System.out.println( "Please try again.");
}

for ( Monster monster : monsters ) {
monster.attack();
}
}
}

Ich teile sie in drei Teilmethoden auf. 

public void play() {
readPlayerName();

while( !isFinished() ) {
dungeon.print();
System.out.println();

executePlayerCommand();
operateMonsters();
}
}

16. Nächstes Feature: Player greift Monster an

Das geht einfach mit den vorhandenen Methoden - nur Player.use(...) muss implementiert werden. 

Player

private void use( String itemName ) throws CommandException {
if ( !inventory.containsKey( itemName ) )
throw new CommandException( this + " doesn't have a " + itemName + "." );
Item item = inventory.get( itemName );

// impact on self
receiveImpact( item.getImpactOnSelf() );
// impact on others
for ( Field neighbourField : getCurrentField().getNeighbours().values() ) {
if ( neighbourField != null && neighbourField.getBlocker() instanceof Impactable) {
Impactable impactable = (Impactable) neighbourField.getBlocker();
System.out.println( this + " attacks " + impactable + " with a " + itemName + "!" );
((Impactable) neighbourField.getBlocker()).receiveImpact( item.getImpactOnOthers() );
}
}
}

Es gibt noch zwei Kleinigkeiten zu fixen: Tote Monster sollten nicht mehr angreifen (Lächeln), und das heilende Potion wirkt noch nicht. 

17. Letztes Feature: Monster bewegen sich

Die Monster sollen sich mit einer gewissen Wahrscheinlichkeit auch bewegen können. Auch das geht mit unserer Struktur jetzt sehr schnell: 

Eine neue Zeile in Game.operateMonsters()

private void operateMonsters() {
for ( Monster monster : monsters ) {
monster.randomWalk();
monster.attack();
}
}

Eine Methode Monster.randomWalk()

public void randomWalk() {
Random random = new Random();
// move in 7 out of 10 cases
if ( random.nextFloat() > 0.3f ) {
// if this random move is illegal, just don't do it :-), no damage done.
try { move(CommandType.randomDirection()); }
catch ( CommandException commandException ) {}
}
}

Danach noch ein bisschen Bugfixing - z.B. greifen die Monster momentan auch tote Co-Monster weiter an. That's it!

18. Refactoring: Package-Struktur

Mittlerweile sind es eine ganze Menge Klassen, Interfaces und Enums (16 Stück) geworden. Daher mache ich als letzten Schritt noch eine (aus meiner Sicht sinnvolle Package-Struktur und verschiebe die Klassen etc. entsprechend.

19. Revisiting the Model

Das Modell habe ich während der Entwicklung nicht mehr angefasst, fand es aber in der Kommunikation anschließend hilfreich. Daher habe ich es noch einmal an den wesentlichen Stellen aktualisiert.