Homework 4 Notes
1. Instructions
This assignment is about adding a user interface that accepts user inputs and calls into the game to make moves. Our task is to implement the UserNotification
class, which is actually just a class to store callback functions that can be later invoked by the game's entities to notify (i.e., print messages to) the user.
By the end of the assignment, we have a fully playable game with the following class diagram:
2. Implementing main()
Let's take a look at how main()
is implemented to get a sense of how UserNotification
fits into the overall picture:
#include "Context.h"
#include "Dungeon.h"
#include "RandomProvider.h"
#include "GameStateObservation.h"
#include "UserNotification.h"
#include <iostream>
#include <random>
#include <string>
class RandomCave final : public HuntTheWumpus::IRandomProvider
{
// Various random number generation methods. Implementation not shown.
};
HuntTheWumpus::UserNotification MakeUserNotifications()
{
HuntTheWumpus::UserNotification notify;
notify.AddCallback(HuntTheWumpus::UserNotification::Notification::ObserveWumpus, []
{
std::cout << "You smell a horrid stench..." << std::endl;
});
// ... other notifications ...
return notify;
}
class GameChange final : public HuntTheWumpus::IGameStateChange
{
// State change implementation not shown.
};
std::vector<std::string> SplitString(const std::string& text, const std::string& delims)
{
// Split the string into tokens. Implementation not shown.
}
int main()
{
// Set up the game's context.
RandomCave sourceOfRandom;
GameChange change;
auto observe = MakeUserNotifications();
HuntTheWumpus::Context gameContext{observe, sourceOfRandom, change};
// Create the dungeon.
HuntTheWumpus::Dungeon dungeon(gameContext);
while (change.IsPlaying())
{
// Get user input.
std::string input;
std::cout << "Command? ";
std::cout.flush();
std::getline(std::cin, input);
// Split into strings.
const auto stringTokens = SplitString(input, " \t\n");
const auto &command = stringTokens[0];
// Process "Move <cave>" command,
if (command == "m" || command == "M" || command == "move" || command == "MOVE")
{
if (stringTokens.size() < 2)
{
std::cout << "A Move command must be followed by the destination cave id." << std::endl;
continue;
}
// Second token is a destination.
const auto destCave = std::stoi(stringTokens[1]);
dungeon.MakeMove(HuntTheWumpus::DungeonMove::Move, {destCave});
}
// Process "Shoot <cave> [cave...]" command.
if (command == "s" || command == "S" || command == "shoot" || command == "SHOOT")
{
if (stringTokens.size() < 2)
{
std::cout << "A Shoot command must be followed by a list of caves for the arrow to go through." << std::endl;
continue;
}
// Remaining tokens is the desired arrow path.
std::vector<int> path;
auto firstToken = false;
for (auto &&token : stringTokens)
{
if (!firstToken)
{
firstToken = true;
continue;
}
path.push_back(std::stoi(token));
}
path.resize(std::min(path.size(), static_cast<size_t>(5)));
dungeon.MakeMove(HuntTheWumpus::DungeonMove::Shoot, path);
}
// Process "Quit" command.
if (command == "q" || command == "quit" || command == "e" || command == "exit" || command == "x")
{
std::cout << "Exiting." << std::endl;
change.GameOver(false);
}
}
return 0;
}
The main()
uses the traditional "setup and loop" pattern. The setup phase involves creating the game's context, which is the simple struct that we saw previously in Assignment 2, but now with more members:
#pragma once
namespace HuntTheWumpus
{
class UserNotification;
class IRandomProvider;
class IGameStateChange;
struct Context
{
UserNotification &m_notification;
IRandomProvider &m_random;
IGameStateChange &m_change;
};
}
IRandomProvider
is implemented byRandomCave
, which is a simple class that inherits fromIRandomProvider
and overrides its methods to generate random numbers. The implementation is not shown here.IGameStateChange
is implemented byGameChange
, which is a simple class that inherits fromIGameStateChange
and overrides its methods to keep track of the game's state, which can be either "IsPlaying" or "GameOver". The implementation is not shown here.UserNotification
is a class that we'll implement in the next section.
3. Implementing UserNotification
Before look at how UserNotification
is implemented, let's take a look at how the notification system works:
1. UserNotification
is instantiated in main()
HuntTheWumpus::UserNotification notify;
2. Callbacks are added to UserNotification
in main()
notify.AddCallback(HuntTheWumpus::UserNotification::Notification::BatTriggered, []
{ std::cout << "Zap -- Super Bat Snatch! Elsewhereville for you!" << std::endl; });
where BatTriggered
is an enum value of type Notification
:
// Inform the user about particular operations happening.
class UserNotification final
{
public:
enum class Notification
{
BatTriggered,
// other notifications...
};
// rest of class definition...
};
3. UserNotification
is passed to Context
in main()
HuntTheWumpus::Context gameContext{notify, sourceOfRandom, change};
4. UserNotification
is passed to Dungeon
in main()
HuntTheWumpus::Dungeon dungeon(gameContext);
5. When a denizen is added to a cave, the other denizens in the cave are notified
void Cave::AddDenizen(const std::shared_ptr<Denizen>& newDenizen, const bool observeEntrance)
{
m_denizens.emplace(newDenizen);
if (observeEntrance)
{
auto action = false;
for (auto&& denizen : m_denizens)
{
action = denizen->ObserveCaveEntrance(newDenizen);
if (action)
{
// Stop if that denizen affected things.
break;
}
}
if(!action && newDenizen->Properties().m_reportMovement)
{
for(auto &&[caveId, cave] : m_tunnels)
{
cave.lock()->ReportDenizens();
}
}
}
}
Notice the use of lock()
here. This is because m_tunnels
is a map of std::weak_ptr
, and in order to access the Cave
object, we need to call lock()
to convert the std::weak_ptr
to a std::shared_ptr
.
6. UserNotification
is notified in Dungeon::ReportDenizens()
bool Bat::ObserveCaveEntrance(const std::shared_ptr<Denizen>& trigger)
{
if (trigger->Properties().m_carryableByBats)
{
const auto cave = m_cave.lock();
// Emit flapping sounds.
m_providers.m_notification.Notify(UserNotification::Notification::BatTriggered);
// Carry to another spot.
const auto caveId = cave->GetCaveId();
auto newCaveFound = false;
auto newCaveId = 0;
while (!newCaveFound)
{
newCaveId = m_providers.m_random.MakeRandomCave();
newCaveFound = newCaveId != caveId;
}
cave->GetDungeon().Move(trigger->GetIdentifier(), newCaveId);
return true;
}
return false;
}
In summary, UserNotification
is available as the context of the game, and the game's entities can call into it to notify the user of various events.
Implementation of UserNotification
Now that we have a sense of how the notification system works, let's take a look at how UserNotification
is implemented:
#pragma once
#include <functional>
#include <variant>
#include <vector>
#include <unordered_map>
namespace HuntTheWumpus
{
// Inform the user about particular operations happening.
class UserNotification final
{
public:
enum class Notification
{
ObserveWumpus,
ObservePit,
ObserveBat,
ObserveMiss,
ObserveOutOfArrows,
BatTriggered,
PitTriggered,
WumpusTriggered,
WumpusAwoken,
WumpusShot,
HunterEaten,
HunterShot,
CaveEntered,
NeighboringCaves,
ReportIllegalMove
};
UserNotification() = default;
~UserNotification() = default;
void AddCallback(Notification category, std::function<void()>&& callback);
void AddCallback(Notification, std::function<void(int)>&& callback);
void AddCallback(Notification, std::function<void(const std::vector<int>&)>&& callback);
void Notify(Notification category) const;
void Notify(Notification category, int arg) const;
void Notify(Notification category, const std::vector<int>& arg) const;
UserNotification(const UserNotification&) = default;
UserNotification(UserNotification&&) = default;
UserNotification& operator=(const UserNotification&) = default;
UserNotification& operator=(UserNotification&&) = default;
using CallbackData = std::variant<std::function<void()>,
std::function<void(int)>,
std::function<void(const std::vector<int>&)> >;
private:
std::unordered_map<Notification, CallbackData> m_callbacks;
};
}
The UserNotification
class is a simple class that contains a map of callbacks, which are functions that are called when a particular event occurs.
Notice there are some repeated code in the AddCallback()
and Notify()
methods. Although we can potentially use the CallbackData
variant (which is a type-safe union) to reduce the code duplication, the current implementation provides a clearer API and better type-safety. Inside the AddCallback()
method, we use std::function
to store the callback functions, and use insert_or_assign()
to add the callback functions to the map:
void UserNotification::AddCallback(const Notification category, std::function<void(int)> &&callback)
{
m_callbacks.insert_or_assign(category, std::move(callback));
}
Here is the difference between insert()
and insert_or_assign()
:
-
insert
:- If the key doesn't exist in the map, it will insert the key-value pair.
- If the key already exists, it won't do anything; the existing value remains unchanged.
-
insert_or_assign
:- If the key doesn't exist in the map, it will insert the key-value pair.
- If the key already exists, it will update (overwrite) the value with the new one.
The Notify()
method is responsible for triggering the callback functions. It first looks up the callback function in the map, and then invokes the callback function if it exists:
- UserNotification::Notify (no arguments version)
void UserNotification::Notify(const Notification category) const
{
const auto callbackIter = m_callbacks.find(category);
if (callbackIter != m_callbacks.end()) { // Check if category was found
const auto *callbackFunc = std::get_if<std::function<void()>>(&callbackIter->second);
if (callbackFunc) { // Ensure the retrieved pointer isn't null
(*callbackFunc)();
}
}
}
This function is responsible for triggering a notification that doesn't require any arguments.
- First, it looks for the callback associated with the given
category
inm_callbacks
. - If the callback is found, it then attempts to extract a function (of type
std::function<void()>
) from the associatedstd::variant
usingstd::get_if
. This function represents a callback that doesn't take any arguments. If the variant is not found,std::get_if
returns a null pointer. -
If the extraction is successful, the callback function is invoked with
(*callbackFunc)()
. -
DoCallback (template function)
template <typename Callback, typename CallbackArg>
void DoCallback(const std::unordered_map<UserNotification::Notification, UserNotification::CallbackData> &callbacks,
const UserNotification::Notification callbackId,
const CallbackArg &arg)
{
const auto callbackIter = callbacks.find(callbackId);
if (callbackIter != callbacks.end())
{
const auto *callbackFunc = std::get_if<Callback>(&callbackIter->second);
if (callbackFunc)
{
(*callbackFunc)(arg);
}
}
}
This is a generic function designed to retrieve and execute a callback, given a specific category and argument.
- The function takes in a map of callbacks (
callbacks
), a notification category (callbackId
), and an argument (arg
) to pass to the callback. - It then looks up the callback function in the map.
- Using
std::get_if
, it attempts to extract a specific type of callback function from thestd::variant
. This type is determined by theCallback
template parameter. -
If successful, the extracted callback function is invoked, passing the provided
arg
. -
UserNotification::Notify (int argument version)
void UserNotification::Notify(const Notification category, const int arg) const
{
DoCallback<std::function<void(int)>, int>(m_callbacks, category, arg);
}
This function triggers a notification that requires an integer argument.
-
It uses the
DoCallback
function, specifying that the callback function it's looking for should take an integer argument (std::function<void(int)>
). -
UserNotification::Notify (std::vector
argument version)
void UserNotification::Notify(const Notification category, const std::vector<int> &arg) const
{
DoCallback<std::function<void(const std::vector<int> &)>, std::vector<int>>(m_callbacks, category, arg);
}
This function triggers a notification that requires a std::vector<int>
argument.
- Like the previous function, it uses the
DoCallback
function. In this case, it specifies that the callback function should take astd::vector<int>
as its argument. - This is actually used to report the neighboring caves, which is a vector of integers.
notify.AddCallback(HuntTheWumpus::UserNotification::Notification::NeighboringCaves,
[](const std::vector<int> &neighbors)
{
std::cout << "Tunnels lead to: ";
for (const auto caveId : neighbors)
{
std::cout << " " << caveId;
}
std::cout << std::endl;
});