Motivation
We have a problem! On the 347th floor, a highly toxic material leaked into the air! The safety system automatically locked the contaminated rooms, and workers who were not exposed were evacuated to safety. However, the exhaust system, which was supposed to ensure automatic extraction of the contaminated air, has failed. Since you have extensive experience in repairing things with a hammer, you have been selected to bring the broken fan back to operation with your Ripley.
The tactical team has prepared a mission plan for you, which must be strictly followed to ensure its successful completion.
From the operations center,
Manager
Goals
- {tiled} Understand game maps created in the Tiled editor.
- {factory} Learn to use the Abstract Factory design pattern.
- {observer} Utilize the Observer design pattern (publish/subscribe).
- {class} Familiarize yourself with the runtime representation of classes.
- {static} Use static class members.
Instructions
Step 1: Welcome to the Real World!
The primary goal of this mission is to teach Ripley how to navigate in a new environment. To achieve this, you need to load a map representing Ripley's environment, along with surrounding, more or less living, actors when creating the game scene.
To make this possible, you first need to create a factory class that your simulation will use to configure the appropriate situation and place the actors in their initial positions as defined in the map. And since the map may contain walls, you will need to teach Ripley to recognize them and avoid passing through them.
Task 1.1
Download the map package for today’s mission and extract it into the src/main/resources/maps/
directory in the project.
The resulting file structure in the src/main/resources/maps
directory should look like this:
src/main/resources/maps/
├── tilesets/
│ └── tileset01.png
└── mission-impossible.tmx
Task 1.2
In the project’s main()
method, modify the constructor call of the World
class to add a second argument with the path to the .tmx
map file to be loaded into the scene.
Write the map path as maps/mission-impossible.tmx
.
Task 1.3
In the scenario package, create a new class for the scenario named after this mission: MissionImpossible
.
The class must implement the SceneListener
interface, and the scenario will later be written in the sceneInitialized()
method.
Task 1.4
In the MissionImpossible
scenario class, create a nested public static class named Factory
that implements the ActorFactory interface.
This class will represent the Factory Method design pattern, and the class itself will contain only one method, create(String type, String name)
.
Task 1.5
Implement the create()
method in the MissionImpossible.Factory
class to create instances of the actors needed for today’s mission.
The game map for today’s mission contains objects named access card
, door
, ellen
, energy
, locker
, ventilator
. For now, you can provide instances for Ripley (ellen
) and a medkit (energy
). For other names, return null
from the create()
method.
Gamelib
The create()
method is called by the game library while processing the map. Maps are created using the Tiled editor. Essentially, these are XML files that define the game world's appearance across multiple layers. Each actor defined by the map has a type and name (hence the parameters of the create()
method in the ActorFactory
interface) as well as its initial position. The factory's task is to create instances to represent the given actor. The scene will automatically position the actors at their defined locations using the setPosition()
method.
Task 1.6
Set the MissionImpossible.Factory
class instance as the actor factory for the scene.
Link the MissionImpossible.Factory
instance with the scene instance in the main()
method by passing it as the third argument to the [World
constructor]<init>(java.lang.String name, java.lang.String mapResource, sk.tuke.kpi.gamelib.ActorFactory):
Scene missionImpossible = new World("mission-impossible", "maps/mission-impossible.tmx", new MissionImpossible.Factory());
Task 1.7
In the scenario, ensure Ripley's controls are set up using MovableController
and KeeperController
, and display her inventory and energy status.
Since Ripley is now created through the factory based on her placement in the scene map, do not create a new instance in the scenario. Instead, retrieve the existing instance from the scene.
Comment
To avoid duplicating the code needed to display Ripley's status (energy) in every scenario, create a showRipleyState()
method in the Ripley
class to handle the necessary text rendering. Call this method in the sceneUpdating()
method of the scenario.
Gamelib
If you want the scene to follow Ripley's movement, use the [scene.follow(Actor actor)
]follow(sk.tuke.kpi.gamelib.Actor actor) method.
Comment
Don’t forget to add the new scenario class as a listener to the scene in the main()
method.
Task 1.8
Verify your implementation.
If implemented correctly, you will see a scene with a map containing a medkit and Ripley. You should be able to control Ripley using the keyboard, and for now, she should be able to pass through walls.
Task 1.9
Ensure that Ripley cannot pass through walls.
Our tactical team considers the best approach to implement this functionality in the action class responsible for actor movement.
Gamelib
To check if an object of type Actor
is colliding with a wall, you can use the [intersectsWithWall(Actor actor)
]intersectsWithWall(sk.tuke.kpi.gamelib.Actor actor) method on the map object. You can get the map object from the scene using the [Scene#getMap()
]getMap() method.
In the map, walls are defined within a layer named
walls
. This layer is not rendered graphically in the scene, but the information from this layer (filled or empty tiles) is used to create walls in the positions of filled tiles.
Step 2: Alohomora!
When implementing the MissionImpossible.Factory
class, you likely noticed that not all the necessary actors defined in the map were available. In this step of the mission, you will create several missing objects. Specifically, you will create doors and an access card, which can be used to unlock locked doors.
During this step, you will also enhance the capabilities of usable items so that they can be used _interactively_—via the keyboard.
Task 2.1
In a new package sk.tuke.kpi.oop.game.openables
, create an interface Openable
, which will represent openable objects.
The interface will extend the Actor
interface and have the following methods:
void open()
- opens the item (actor)void close()
- closes the item (actor)boolean isOpen()
- returns true if the item (actor) is open, otherwise returns false
Task 2.2
In the package for openable actors, create a class Door
as shown in the diagram above. This class represents regular doors in the game and implements the methods from the Openable
and Usable
interfaces.
Doors will be usable with any actor, so use the Actor
type as the type argument for Usable
. For the current mission, we will need vertically oriented doors, so use the image vdoor as the animation.
The doors will be closed by default. When used by an actor, the doors should open if they are closed and close if they are already open.
Gamelib
When animating the opening and closing of doors, you can use the animation playback modes ONCE
and ONCE_REVERSED
, which can be set using the setPlayMode()
method on the animation, along with its play()
and stop()
methods.
Task 2.3
Complete the implementation of the doors so that they cannot be passed through when closed.
Our tactical and strategic team believes the best way to block closed doors is to create a wall in their place on the map. When the doors are opened, the wall should be removed.
Gamelib
All walls in the game map are defined in the walls layer, which you’ve already seen in the image above. When working with the Tiled editor, maps are created using tiles that, in our case, are 16x16 pixels in size. Each filled tile in the walls layer represents a wall.
In the object representation of the map, we also have tiles available. Each tile covering the map has its own x and y coordinate, representing the tile's position along the x and y axes, with the origin of the coordinate system in the bottom-left corner of the map. The tile at this location has coordinates [0, 0].
You can get a specific tile using the method getTile(int tileX, int tileY)
on the map object. This tile is of type MapTile and, in addition to its position and dimensions, you can check if the given position in the map is considered a wall using the isWall()
method. Finally, you can change the tile's type using the setType()
method to one of two values from the MapTile.Type
enumeration:
CLEAR
- the location of this tile is passable,WALL
- the location of this tile is considered a wall.
Comment
Keep in mind that the tile coordinate units do not match the coordinate units of, for example, actors. Therefore, you’ll need to perform a conversion from positions defined in pixels to positions defined by tile grid indices. Use the tile size for this calculation.
Comment
Remember that doors cover two tiles in size. You must update the state of both tiles when opening and closing the doors.
Task 2.4
Modify the create()
method in MissionImpossible.Factory
so that for the actor name "door"
, it returns an instance of the Door
class, and verify your implementation.
Doors should appear in the scene, and Ripley should not be able to pass through them into the smaller room.
Task 2.5
Add a getUsingActorClass()
method to the Usable
interface and to every specific actor implementing this interface.
In the next task, you will add support for planning the Use
action via the keyboard. The use case scenario is as follows:
- Ripley approaches doors that implement the
Usable
interface. - By pressing an assigned key, the player signals their intent to open the doors.
- The
Use
action is planned so that the doors are used with Ripley.
Another use case scenario, which you will implement later, is as follows:
- Ripley, with an access card in her backpack, approaches locked doors.
- By pressing an assigned key, the player signals their intent to use the item at the top of the backpack.
- The
Use
action is planned so that the access card (item from the backpack) is used with the locked doors (item in the scene colliding with Ripley).
Comment
As evident, especially in the second scenario, Ripley acts as an intermediary for using an item located in her vicinity.
Due to the limitations of the JVM platform, it is not possible to work directly with types represented by type parameters during program execution (these exist only during compilation). Therefore, the getUsingActorClass()
method being added will explicitly return the runtime representation of the actor class with which the usable item can interact. This class representation will be used in the next task to find a compatible actor (an actor that is an instance of the given class) with which the Use
action can be planned.
The new method in the Usable
interface should have the following signature:
Class<T> getUsingActorClass(); // T represents type parameter in Usable class
In every usable actor that specifies a concrete type of compatible actor for the type parameter of Usable
, return a reference to the runtime representation of the compatible actor’s class from this method. For example, in the case of the hammer (class Hammer
), which is usable with a reactor, the implementation of this method would look as follows:
public Class<Reactor> getUsingActorClass() {
return Reactor.class;
}
Task 2.6
In the Use
action, add the method scheduleForIntersectingWith(Actor mediatingActor)
, which will allow scheduling a Use
action on an actor that is compatible with the Usable
object passed to the constructor of the action and is in collision with the mediating actor represented by the mediatingActor
parameter.
This method is necessary in the Use
action class because we need to find the first compatible actor on the scene in collision with the mediating actor (primarily Ripley) in a context where the type system of the language has access to the type argument of the Usable
object. This will allow us to interactively schedule the Use
action in response to a key press in the next task.
Assuming you have a functional implementation of the Use
action, where the member variable usable
represents the usable actor passed to the constructor of the action and T
represents the type parameter of the action, this method can be implemented as follows:
public Disposable scheduleForIntersectingWith(Actor mediatingActor) {
Scene scene = mediatingActor.getScene();
if (scene == null) return null;
Class<T> usingActorClass = usable.getUsingActorClass(); // `usable` is the aforementioned member variable
for (Actor actor : scene) {
if (mediatingActor.intersects(actor) && usingActorClass.isInstance(actor)) {
return this.scheduleFor(usingActorClass.cast(actor)); // plan the action if we find a suitable actor
}
}
return null;
}
Comment
If you are already familiar with the stream API for working with collections and would like to use it in this case, an equivalent implementation of this method might be as follows:
public Disposable scheduleForIntersectingWith(Actor mediatingActor) {
Scene scene = mediatingActor.getScene();
if (scene == null) return null;
Class<T> usingActorClass = usable.getUsingActorClass(); // `usable` is the aforementioned member variable
return scene.getActors().stream() // get stream of actors at the scene
.filter(mediatingActor::intersects) // filter actors which are colliding with provider
.filter(usingActorClass::isInstance) // filter actors which are of comatible type
.map(usingActorClass::cast) // retype the stream of actors
.findFirst() // select first (if exists) actor from stream
.map(this::scheduleFor) // call method `scheduleFor` with found actor and receive `Disposable` object
.orElse(null); // if there are no suitable actors found return `null`
}
Task 2.7
In the KeeperController
controller, add scheduling of the Use
action for items on the scene.
When the U
key is pressed, schedule the use of the first found usable item in collision with the controlled actor. The controlled actor will then act as an intermediary to find the compatible item using the added method scheduleForIntersectingWith()
.
Comment
When solving this task, you will need to cast a general actor to a Usable
actor in the controller, but you won't have information about which specific subtype of the actor is the type argument of the found usable actor. So use the wildcard ?
instead of the specific type argument. The casting might look like this:
(Usable<?>) actor
Task 2.8
Verify the correctness of your implementation.
If done correctly, Ripley should be able to open the doors she approaches by pressing the U
key.
Task 2.9
According to the class diagram at the beginning of the step, create the LockedDoor
class in the package for openable actors, which will inherit from the Door
class and represent locked doors.
The security lock can only be removed by using the correct access card, which you will implement in the next task. After unlocking the door, it will be possible to open and close it just like a regular door.
In the class, implement the following methods:
void lock()
- lock (and also close) the door,void unlock()
- unlock (and also open) the door,boolean isLocked()
- returns the current state of the door – whether it is locked or not.
Task 2.10
According to the class diagram at the beginning of the step, create the AccessCard
class in the sk.tuke.kpi.oop.game.items
package.
This card will implement the Collectible
and Usable
interfaces and will be usable with the locked doors LockedDoor
. Using the card will unlock the locked door.
The animation representing the access card is stored in the file key.
Task 2.11
In the KeeperController
controller, add scheduling of the Use
action for items on top of the backpack.
When the B
key is pressed, schedule the Use
action on the item on top of the backpack if this item is usable. Again, use the controlled actor as an intermediary to find a compatible actor nearby on the scene.
Task 2.12
Modify the create()
method in MissionImpossible.Factory
so that for the name "door"
, it creates an instance of LockedDoor
instead of Door
, for the name "access card"
, it creates an instance of AccessCard
, and verify your implementation.
Verify that using the U
key, it is no longer possible to open locked doors, and that these doors will open when Ripley takes the access card into her backpack and uses the B
key after approaching the door.
Step 3: Missing Items
For completeness, the actors defined in the map still lack the locker and the ventilator. You will create these in this step. We will also need to expand the repair capabilities of the hammer (hidden in the locker) to work with any repairable item.
class diagram: A class diagram showing the position of the Locker
and Ventilator
classes.
[Locker{bg:cornsilk}]-^[<
Task 3.1
Modify the hammer so that it can repair any Repairable
item and not just the Reactor
.
Don't forget to also change the class returned by the method getUsingActorClass()
. Just like you return Reactor.class
, you can also return the runtime representation of the Repairable
interface as Repairable.class
.
Comment
Since the type argument for the Usable
interface can only be a subtype of Actor
, the change is also needed in the Repairable
interface: modify it so that it extends the Actor
interface.
Task 3.2
Based on the given class diagram, create the Locker
class in the sk.tuke.kpi.oop.game
package, which will represent a locker. Upon opening (using) it, the player will find the hammer.
The hammer will be found by having it fall out of the locker as soon as Ripley examines the locker. The locker should be usable only once, so Ripley will get only one hammer by examining a single locker.
The animation representing the locker is stored in the file locker.
Task 3.3
According to the given diagram, create the Ventilator
class in the sk.tuke.kpi.oop.game
package, which will represent a ventilator that Ripley will need to repair with the hammer.
The animation representing the ventilator is stored in the file ventilator. Since the ventilator starts as broken in the scene, initially stop the animation playback. In the repair()
method, represent the repair of the ventilator by starting the animation playback.
Task 3.4
Complete the implementation of MissionImpossible.Factory
for creating objects based on the names "ventilator"
and "locker"
.
Step 4: Entering Contaminated Zone
We are entering the final phase of today's mission. The goal is to repair the broken ventilator in the contaminated zone, thus eliminating the danger of contamination. In completing this step, we will demonstrate how to use messages to respond to emerging events, utilizing the Observer design pattern.
Task 4.1
Add message topics for opening and closing doors in the Door
class.
Various parts of the game (other actors, your code within the scenario, etc.) might want to react when certain doors are opened or closed. We will enable this by creating message topics with a defined message type (the type of object that will be sent in the specific topic) and publishing the message (the specific object of that type) to the message bus in the scene. For doors, the message type will naturally be Door
.
You will create message topics using the factory method Topic.create()
, where the first argument is the name of the topic and the second is the type of the object sent in the messages.
Since we want to access the topics without needing a reference to specific doors, implement them as static member variables in the Door
class. The topic for opening doors could look like this:
public static final Topic<Door> DOOR_OPENED = Topic.create("door opened", Door.class);
Similarly, create a topic for closing doors.
Task 4.2
Publish messages about opening and closing doors.
In the open()
and close()
methods of the door, publish a message indicating which door was just opened or closed. Use the publish()
method on the scene's message bus MessageBus, which you can retrieve via the getMessageBus()
method.
Task 4.3
In the scenario, schedule an action that gradually decreases Ripley’s energy as a result of the contamination spreading after the door is opened.
The message bus MessageBus
has the method subscribe(Topic<M> topic, Consumer<M> listener)
, which allows us to subscribe to receive messages published within a specific topic. The first argument is the topic object, and the second is a lambda expression (or a functional interface object of type Consumer
) with a single parameter—the object sent within the specific message.
Subscribe to the message topic Door.DOOR_OPENED
and react to the door opening by scheduling an action to reduce Ripley’s energy.
Note: Handle the energy reduction at a suitable interval so it does not happen too quickly, giving Ripley time to complete the mission.
Task 4.4
Verify your implementation.
After the door is opened, Ripley’s energy should start decreasing.
Task 4.5
Modify the Ripley
class so that when her energy reaches 0, her animation changes to player_die, and publish a death message.
Use the play mode Animation.PlayMode.ONCE
for the animation. Name the created message topic RIPLEY_DIED
.
Task 4.6
Ensure that Ripley is no longer controllable by the MovableController
and KeeperController
after her death.
Gamelib
Calling the registerListener()
method on an Input
object returns a reference to a Disposable object, which can be used to cancel the registered listener (similar to Disposable
objects returned by the scheduleFor
method for canceling actions).
Modify the scenario implementation to cancel the listeners for the mentioned controllers when the death message for Ripley is received, preventing further control of Ripley.
Task 4.7
Ensure that after the ventilator is repaired (which ventilates the contaminated area), the energy reduction for Ripley stops.
This task should also be addressed by creating a message topic for the ventilator repair (named VENTILATOR_REPAIRED
). In response to this message, stop the scheduled energy reduction for Ripley in the scenario.
Note: Keep in mind that variables defined within a lambda are not visible outside the lambda, and you cannot assign objects to local variables defined outside the lambda within the lambda itself. Local variables captured in a lambda are considered effectively final.
Task 4.8
Verify the implementation of the entire mission.
After the ventilator is repaired with the hammer, Ripley’s energy should stop decreasing. If she fails to repair the ventilator before her energy reaches 0, she should no longer be controllable.
Additional Resources
- Editor Tiled used for creating game maps.
- The Abstract Factory design pattern.