Methods of object interaction in Unity. How to work with patterns and connections in your code
Devs Daddy
Posted on April 2, 2024
Introduction
Hey, everybody. When creating any game - in it, your entities always have to interact in some way, regardless of the goals - whether it's displaying a health bar to a player or buying an item from a merchant - it all requires some architecture to communicate between the entities. Today we're going to look at what methods you can use to achieve this and how to reduce the CPU load in your projects.
First, let's define some example. Let's say we have some store where the player will buy some item.
Direct access to references and methods
If we want to go head-on, we explicitly specify references on our mono-objects. The player will know about a particular merchant, and execute the merchant's buy method by passing the parameters of what he wants to buy, and the merchant will find out from the player if he has resources and return the result of the trade.
Let's represent this as abstract code:
class Player : MonoBehaviour {
// Direct Links
public Trader;
// Player Data
public long Money => money;
private long money = 1000;
private List<int> items = new List<int>();
public bool HasItem(int itemIndex){
return items.ContainsKey(itemId);
}
public void AddMoney(long addMoney){
money += addMoney;
}
public void AddItem(int itemId){
items.Add(itemId);
}
}
class Trader : MonoBehaviour {
private Dictionary<int, long> items = new Dictionary<int, long>();
// Purchase Item Method
public bool PurchaseItem(Player player, int itemId){
// Find item in DB and Check Player Money
if(!items.ContainsKey(itemId)) return false;
if(player.Money < items[itemId]) return false;
// Check Player Item
if(player.HasItem(itemId) return false;
player.AddMoney((-1)*items[itemId]);
player.AddItem(itemId);
}
}
So, what are the problematic points here?
- The player knows about the merchant and keeps a link to him. If we want to change the merchant, we will have to change the reference to him.
- The player directly accesses the merchant's methods and vice versa. If we want to change their structure, we will have to change both.
Next, let's look at the different options for how you can improve your life with different connections.
Singleton and Interfaces
The first thing that may come to mind in order to detach a little is to create a certain handler class, in our case let it be Singleton. It will process our requests, and so that we don't depend on the implementation of a particular class, we can translate the merchant to interfaces.
So, let's visualize this as abstract code:
// Abstract Player Interface
interface IPlayer {
bool HasItem(int itemIndex);
bool HasMoney(long money);
void AddMoney(long addMoney);
void AddItem(int itemId);
}
// Abstract Trader Interface
interface ITrader {
bool HasItem(int itemId);
bool PurchaseItem(int itemId);
long GetItemPrice(int itemId);
}
class Player : MonoBehaviour {
// Direct Links
public Trader;
// Player Data
public long Money => money;
private long money = 1000;
private List<int> items = new List<int>();
public bool HasItem(int itemIndex){
return items.ContainsKey(itemId);
}
public bool HasMoney(long needMoney){
return money > needMoney;
}
public void AddMoney(long addMoney){
money += addMoney;
}
public void AddItem(int itemId){
items.Add(itemId);
}
}
class Trader : MonoBehaviour, ITrader {
private Dictionary<int, long> items = new Dictionary<int, long>();
public bool PurchaseItem(int itemId){
if(!items.ContainsKey(itemId)) return false;
items.Remove(items[itemId]);
return true;
}
public bool HasItem(int itemId){
return items.ContainsKey(itemId);
}
public long GetItemPrice(int itemId){
return items[itemId];
}
}
// Our Trading Management Singleton
class Singleton : MonoBehaviour{
public static Singleton Instance { get; private set; }
public ITrader trader;
private void Awake() {
if (Instance != null && Instance != this) {
Destroy(this);
} else {
Instance = this;
}
}
public bool PurchaseItem(IPlayer player, int itemId){
long price = trader.GetItemPrice(itemId);
if(!trader.HasItem(itemId)) return false;
if(!player.HasMoney(price)) return false;
// Check Player Item
if(player.HasItem(itemId) return false;
trader.PurchaseItem(itemId);
player.AddMoney((-1)*price);
player.AddItem(itemId);
}
}
What we did:
- Created interfaces that help us decouple from a particular merchant or player implementation.
- Created Singleton, which helps us not to address merchants directly, but to interact through a single layer that can manage more than just merchants.
Pub-Sub / Event Containers
This is all fine, but we still have bindings as bindings to specific methods and the actual class-layer itself. So, how can we avoid this? The PubSub pattern and/or any of your event containers can come to the rescue.
How does it work?
In this case, we make it so that neither the player nor the merchant is aware of the existence of one or the other in this world. For this purpose we use the event system and exchange only them.
As an example, we will use an off-the-shelf library implementation of the PubSub pattern. We will completely remove the Singleton class, and instead we will exchange events.
For example, PubSub Library for Unity:
https://github.com/supermax/pubsub
Our code with PubSub Pattern:
// Our Purchase Request Payload
class PurchaseRequest {
public int TransactionId;
public long Money;
public int ItemId;
}
// Our Purchase Response Payload
class PurchaseResult {
public int TransactionId;
public bool IsComplete = false;
public bool HasMoney = false;
public int ItemId;
public long Price;
}
// Our Player
class Player : MonoBehaviour {
private int currentTransactionId;
private long money = 1000;
private List<int> items = new List<int>();
private void Start(){
Messenger.Default.Subscribe<PurchaseResult>(OnPurchaseResult);
}
private void OnDestroy(){
Messenger.Default.Unsubscribe<PurchaseResult>(OnPurchaseResult);
}
private void Purchase(int itemId){
if(items.Contains(itemId)) return;
currentTransactionId = Random.Range(0, 9999); // Change it with Real ID
PurchaseRequest payload = new PurchaseRequest {
TransactionId = currentTransactionId,
Money = money,
ItemId = itemId
};
Messenger.Default.Publish(payload);
}
private void OnPurchaseResult(PurchaseResult result){
if(!result.IsComplete || !result.HasMoney) {
// Show Error Here
return;
}
// Add Item Here and Remove Money
items.Add(result.ItemId);
money -= result.Price;
}
}
// Our Trader
class Trader : MonoBehaviour {
private Dictionary<int, long> items = new Dictionary<int, long>();
private void Start(){
Messenger.Default.Subscribe<PurchaseRequest>(OnPurchaseResult);
}
private void OnDestroy(){
Messenger.Default.Unsubscribe<PurchaseRequest>(OnPurchaseResult);
}
private void OnPurchaseRequest(PurchaseRequest request){
OnPurchaseResult payload = new OnPurchaseResult {
TransactionId = request.TransactionId,
ItemId = request.ItemId,
IsComplete = items.Contains(request.ItemId),
HasMoney = request.Money < items[request.ItemId]
};
payload.Price = items[request.ItemId];
if(payload.IsComplete && payload.HasMoney)
items.Remove(items[request.ItemId]);
Messenger.Default.Publish(payload);
}
}
What we've accomplished here:
- Decoupled from the implementation of the methods. Now a player or a merchant does not care what happens inside and in principle who will fulfill his instructions.
- Decoupled from the relationships between objects. Now the player may not know about the existence of the merchant and vice versa
We can also replace subscriptions to specific Payload classes with interfaces and work specifically with them. This way we can accept different purchase events for different object types / buyers.
Data Layers
It's also good practice to separate our logic from the data we're storing. In this case, instead of handling merchant and player inventory and resource management, we would have separate resource management classes. In our case, we would simply subscribe to events not in the player and merchant classes, but in the resource management classes.
In conclusion
In this uncomplicated way, we have detached almost all the links in our code, leaving only the sending of events to our container. We can make the code even more flexible by transferring everything to interfaces, putting data into handlers (Data Layers) and displaying everything in the UI using reactive fields.
Next time I'll talk about reactivity and how to deal with query queuing issues.
Thanks and Good Luck!
Posted on April 2, 2024
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.