How can I make my C++ quest system more lightweight and contain less references outside of itself?

I’m assuming the problem you’re trying to get a grip on, is that you have to #include a bunch of headers in a bunch of places, and you’d like to reduce that, keep it to a minimum. This is known as “reducing physical coupling.”

To include fewer headers everywhere, use the more basic classes in most places, and pass arguments to functions rather than keep state around.
AActor is available everywhere. APlayerController is also pretty common. ASceneComponent, too (or AActorComponent.)

You could build the interface between player and quest system using an AQuestPlayerComponent, which would be added to the player controller, probably in a blueprint. If it’s in blueprint, you actually don’t need to do anything at all in the C++ bit. The behavior of the quest component would still be in C++, it would just be wired up in blueprint. This cuts down on #includes a lot.

However, you still have the problem that “putting gold coins in inventory should be a listenable event for quest progression.”
It turns out, this is also a problem for tutorials, and for things like “add sound effects to these events,” and for a few other reactive interactions. It’s just a known place where things get coupled.

There are three main ways to go about resolving that.
The first is to build a “INotifier” interface that everything needs to implement, and that has a method per “thing” that could happen. Ideally, the interface itself just uses strings, vectors, integers, floats, and actor references, so the INotifier interface doesn’t in turn need to include any other classes. The player, and any interactive object, would then have to look for a INotifier interface on whatever is being interacted with, and call it.
It’s ugly to have an interface with “OnReceivedGold()” right next to “OnReceivedDamage” right next to “OnLeveledUp()” right next to “OnEquippedItem(),” and so on for 50 different things. But it actually reduces physical coupling.

The second way to do it is to have each thing that happens, generate a generic (-ish) event, and then bind these events to generic (-ish) receivers. This gets rid of the “OnEverything()” interface, but then instead the top-level code has to know about both the event source, and the event receiver, so the coupling moves from the names-of-functions-in-interfaces, to the wiring loom that hooks everything up at level load or begin play or whatever.

The third way is to have a global, or perhaps per-player, event bus. Define events that are of the form class UPlayerEvent : public UObject { FString EventType; UObject *Info; AActor *Actor; AActor *Actee; };
Then, whenever anything happens, an event of this class is posted on the bus, and there’s a generic mechanism to register yourself as a receiver of events, either all events, or just events of a particular EventType value.
Now, there’s a very narrow interface of physical coupling – just the UPlayerEvent declaration, and whatever the multicast delegate is for OnEventBusEvent. When you make a new thing that needs to send or receive these events, pass in the appropriate event bus as an argument. That’s the extent of the coupling!

The real drawback of the event bus system, is that you now don’t get type checking. If you send an event named ReceiveGold but register to receive events named GoldReceived, you won’t have the compiler tell you about the mis-match, you just won’t receive the event in question, and some quest will have silently broken.

Event busses are super popular, but I’ve been burned by the un-typed event IDs often enough that I try to avoid them. The wiring loom of generic multicast delegates bound to generic event receivers, know about by some top-level hooking-upping code, ends up being somewhat of a chore, but can work well, and is the easiest to extend – add another delegate somewhere, nothing else needs to know about it unless it wants to use it. The “everything” notification interface, is the most concrete, and gives you good compiler error messages, and as long as you can keep the notification functions from using any too specialized data structures for arguments, it de-couples pretty well. Adding a new kind of notification does mean everyone who implements it needs a re-compile, though.

2 Likes