ASCII Valley is a simplified text-based adaptation of the indie simulation role-playing video game Stardew Valley with some influences of Dwarf Fortress when it comes to the visual details; the player takes on the role of a character who takes charge of his deceased grandfather's abandoned farm located in a site named Stardew Valley. Planting and harvesting crops and fighting off mummies are some of the things that will make you play ASCII Valley for hours on end!
This project was developed by Joao Sousa ([email protected])
and Rafael Ribeiro ([email protected])
for LPOO 2019⁄20
Storing of map state in a save file. The map is divided in chunks and each chunk's data is divided in 4 parts: an ID, the IDs of it's neighbor chunks, a matrix that references the ground "type" and another that references the entity on said tile, if it exists. The interpretation of these values is intentionally hardcoded, since these save files are only edited and accessed by the game.
Displaying of the chunk where the player is located, terrain and map entities included.
As of now, the player can move around the current map chunk, colliding with the map entities that are supposed to be solid.
When the player goes beyond an "edge" of a chunk it moves to the respective neighbour chunk.
The enemy entities (Mummies) have different behaviour depending on how close they are to the player, determining their next action based on it.
Melee combat based on the hero project of the practical classes is the way for the player to combat the enemies.
The starting point of the game is a menu that presents the player with options to load the game, see the game controls and quit.
In the form of a player toolbar that holds the players' tools, that allow the player to interact with specific parts of the game, and also the items collected through said interactions.
This section lists functionalities that were thought up in the start of the project and were not be achieved in the time frame we had.
At the start of the project, even before writing any lines of code, while drafting a general idea of the project it became clear that file organization would have to be one of the foundations in order to find quickly what we are looking for and, in general, to have a good development process.
Therefore we chose the MVC pattern: Model-View-Controller. It divides the application in three parts, allowing for separation in groups of functions according to their role and by extension allowing also better function separation/organization.
We started off by creating a GameController class in charge of holding a MapView and MapModel classes. From here it was easy to implement new features on the Player and Entity end, but also to easily grow the input actions.
Benefits:
- ease of working simultaneously in the same project without interfering with each other's work.
- allows for a higher degree of cohesion. Methods that perform actions of the same "domain" are grouped together. For example, the
files ChunkView.java, MapView.java and EntityView.java
that deal which tasks are related to data displaying all belong to the package
com.g64.view
. - faster development speed in the long run due to consistent organization.
Liabilities:
- requires a higher number of files that can build up over time with the increase of the project's complexity.
After initially writing in the reading of keyboard inputs, it was clear the "switch" approach was messy and
did not scale properly. Also the need for enemies with behaviour similar to the players' made this pattern fit very well.
Later on, a game state related situation came up, more precisely related with the menuGameState
.
The menu had three possible options: "Play", "Controls" and "Quit" and by selecting a certain option the player would get different outcomes and
implementing every outcome within the menuGameState
didn't look like the best option.
The Command pattern consists of parameterizing clients with different requests, in our case, the player, enemy and menu actions.
This was done in the form of an Action interface and several commands that are executed when appropriate. Namely, the Move family of actions, responsible for movement and collision checking of the "invoker" entity.
In the MenuGameState class, this pattern is present within the list of menuOptions
.
Each menuOption
has a String associated with it as well as a MenuCommand
implementation, which is the element that effectively knows how to execute the menuOption
's action.
The code in the com.g64.controller is much easier to read and also proved to make the scaling of the inputs easier, especially alongside the State pattern next described.
Having started by implementing the game itself, we came across difficulties when trying to incorporate other parts of the game, highlighting the menu that appears at the start. The different game "states" and the transitions (finite-state machine) between them were mostly dealt with by using a switch statement in the start function of the game controller.
enum gameStates {
MAIN_MENU, CONTROLS, IN_GAME;
}
public void start() {
while (running){
switch (gameState) {
case MAIN_MENU:
// (...)
break;
case CONTROLS:
// (...)
break;
case IN_GAME:
// (...)
break;
}
}
}
This first solution was obviously not optimal: the function was becoming unnecessarily long and in case of a new possible game state the enum gameStates and the function start would need to be changed (adding another case to the switch statement), for example.
Another part of our code that soon started to become problematic was the behaviour of the enemies, which heavily depended on the distance the enemy was to the player.
The pattern State was chosen for both cases. It lets an object change its behavior depending on its internal state and is a way of implementing a finite-state machine making it highly appropriate for the situation.
The game controller keeps track of the current state of the game which can be changed according to the player input.
Each possible GameState
implements an execute
(which calls the necessary draw functions) and processKey
functions (which processes the player's key presses), allowing different functionalities for a same key press depending on the context
and transitions between states.
The enemies hold the active "humour", which can be either an AggroedHumour
or a NormalHumour,
and change between these two according to the distance to the player,
which is checked on every game cycle by the function updateState()
here.
This pattern helped reducing the clutter and size of start
function of GameController (it is now about 5 times smaller than previously)
and made it possible to easily add afterwards a DeadPlayerState
(for when the player dies), improving consequentially the maintainability and readability of the code.
It also simplified the creation of new and varied enemies, as was the case of the Ghost, implemented much later than the Mummy.
Near the start of the project, while implementing the way the game translated the save file into the corresponding objects, it became clear that the lazy approach of just a switch was messy. Later, with the addition of enemies and the need for these to be created on demand by the specifi chunks, this method also fit well.
The Factory pattern, capable of creating objects of unspecified classes, saved us the use of messy constructors and unmaintainable switch cases.
On the case of the MapTerrain objects, that hold the information of each ground type, the implementation of the MapTerrainFactory was straightforward, since the translation of save file to object is hard coded.
Similarly, the EnemyFactory simply constructs an enemy at a random position of the map, being called by the ChunkModel whenever there is a need to add an enemy to a chunk.
This pattern helped by decoupling the creation of these entities from the classes that hold them, improving readability as well. We didn't implement other types of enemies besides Mummies and Ghosts but this could easily be expanded on with this pattern.
When implementing the usage of different tools and items by the player, we soon realized that these varied a lot depending on the parts of the map they interacted with (Axe interacts with both Enemies and MapEntities ; Hoe interacts with MapTerrain). In an initial phase, this led to the use of instanceof operations to quickly determine if the action would be accepted on said "Target", something that clearly goes against polymorphism.
public class Axe extends Item {
@Override
public void use(GameController controller, Position position){
MapEntity target = controller.getMapModel().thisChunk().getEntityAt(position);
if(target instanceof TreeEntity) target.remove();
}
}
Thus the use of the visitor pattern was a solution we found, since it consists of separating the actions done by the items from their classes, following the open/closed principle.
By creating a class ItemVisitor with functions allowUsage(Item item) overridden to accept all possible "interactable" items. Alongside these, all items also implement an accept(ItemVisitor itemVisitor) method, which consists of calling the visitor's method corresponding to itself. The process of getting the map objects from the position that was interacted with, deciding which objects to evaluate and effectively cause changes to these are all handled by the visitor.
This makes the code on the the Interact actions more readable, since the handling of the item is done entirely by this new class. However, this also has its' downsides: the creation of new items is less intuitive, and in a game like ours would, in the long run, be hindering. Despite helping us remove a lot of the smelly instanceof calls, we were not able to completely rid this part of our code from them.
Evidently, the function that sticks out the most as being too long is the readMap method, in charge of porting the map information from the .csv file to the Map object.
Even after multiple uses of the Extract Method, it can still be considered too long, mostly because of the mess of code in charge of opening and reading the file itself.
There are various places in our project where switch statements are still present. Namely, in the MapEntityFactory and MapTerrainFactory, in charge of creating MapEntity and MapTerrain objects from the characters saved in the save file
public static MapEntity get(Position position, String string){
switch (string){
case "^":
return new RockEntity(position);
case "~":
return new WaterEntity(position);
case "y":
return new TallGrassEntity(position);
case "O":
return new TreeEntity(position);
case "#":
return new UnpassableWallEntity(position);
case "i":
return new GrownCornEntity(position);
case "j":
return new GrownCarrotEntity(position);
default:
return null;
}
}
Other places where the switch smell is present are in the handleBoundaryCrossing()
functions on movable entities (Player and Enemies)
which move the player between chunks and keep the enemies from leaving their current chunk.
@Override
public void handleBoundaryCrossing(MapModel map){
MapModel.Crossing crossing = map.checkBoundaries(position);
switch (crossing) {
case NO_CROSS:
(...)
case CROSS_DOWN:
(...)
case CROSS_UP:
(...)
case CROSS_LEFT:
(...)
case CROSS_RIGHT:
(...)
}
}
When it comes to the presence of the switch on the Factory classes, since the cases are simple and easy to update with the creation of new
Map objects, we think the smell can be ignored.
On the other hand, the switch in the handleBoundaryCrossing()
functions could be extracted with the Replace type code with subclasses
method by making the Crossing
enum an abstract class with subclasses CrossRight
, CrossUp
, ... with an overridden method handle()
.
The MainMenuView and PauseMenuView classes act very similarly and thus
their draw()
methods have a lot of duplicate code.
By using the Extract superclass method the duplicate code can be stored in a
superclass MenuView
with a single draw()
method that maintains functionality.
Despite using the Visitor pattern in ItemVisitor to remove most of the instanceof checks in our code,
this only saved us from the checking the type of the Tool
or Drop
in question. There is still the need to check for the type of the targeted part of the map
(MapEntity
and MapTerrain
) and act accordingly.
@Override
public Item.usageValue allowUsage(Axe item) {
if (entity instanceof TreeEntity || entity instanceof Enemy){
(..)
}
}
To solve this smelly code, the Visitor pattern would be the best choice. An implementation of a MapTerrainVisitor
and MapEntityVisitor
, with overrides for every item would
work.
@Override
public Item.usageValue allowUsage(Axe item) {
if (entity instanceof TreeEntity || entity instanceof Enemy){
(..)
}
}
50% Rafael Ribeiro 50% Joao Sousa