Manager Scripts
This section covers manager scripts. In this project, manager scripts act as the control center for different high-level systems. Rather than handling logic in individual tools or UI components, managers oversee global coordination, such as which tool is active, how the UI behaves, or what object is selected. These scripts ensure the app behaves consistently and modularly, making it easier to scale and maintain.
Having this hierarchy allows other scripts to do their job without having to worry about the big picture. For example, the BrushTool
just draws, but how do we know when do enable it? Thats where manager scripts come into play.
State Manager
The StateManager
is a centralized script that controls which drawing tool mode is active at any given time and manages the currently selected object in the AR space. It also handles user interactions like selecting, deleting, or duplicating objects. This script is key to keeping tool behavior modular and transitions clean between tools.
Singleton Pattern
As mentioned in the Tool Scripts section,StateManager
follows a singleton pattern so other scripts can easily access it and ensures there is only one StateManager.
Mode Management
The manager keeps track of which tool mode is active (brush, line, shape, erase, etc.) and enables/disables them accordingly.
public void SwitchMode(ToolType newMode)
{
DisableMode(mode);
mode = newMode;
EnableMode(mode);
}
Avatar Manager
Avatar Manager
handles avatar creation by communicating with the MQTT server and serializing posiitonal data so the avatar can be recreated on remote clients.
This diagram illustrates how our avatar generation system works with the MQTT server. Let's trace through what happens when one of these messages is sent from Client A:
- Client A moves to a new position in their world - let's say coordinates (5, 1, 2).
- Client A then sends this position update to the MQTT server, including their client ID, coordinates, and mural ID.
- The MQTT server broadcasts this message to ALL subscribed clients - this includes Client B (watching) and even Client A (the sender).
- Client A also receives the same message back from the server.
- Client B receives the message containing Client A's position data.
- Client A skips the message because the client ID matches their own - they don't create an avatar for themselves.
- Client B processes the message: checks the client ID, sees it's from Client A (not themselves), and either creates a new red avatar for Client A or updates the existing one to position (5, 1, 2). Now Client B now sees Client A's avatar move to the new position in real-time.
This happens continuously, 10 times per second for every connected client.
Avatar Data
This is a serializable class that defines the data we want to send over to be broadcasted to other clients.
[Serializable]
public class AvatarData
{
public string clientID;
public float x, y, z;
public string username;
public int muralId;
}
Update()
public void Update()
{
if (string.IsNullOrEmpty(myClientID) || trackingTarget == null)
return;
if (Time.time - lastSendTime >= (1f / sendRate))
{
SendMyPosition();
lastSendTime = Time.time;
}
}
sendRate
).
SendMyPosition()
private void SendMyPosition()
{
if (ClientManager.Instance == null || !ClientManager.Instance.connected)
return;
Vector3 pos = trackingTarget.position;
string username_local = "Default";
GameObject obj = GameObject.FindWithTag("Username");
if (obj != null)
{
UsernameManager = obj;
GetUsername getUsername = UsernameManager.GetComponent<GetUsername>();
if (getUsername != null && !string.IsNullOrEmpty(getUsername.inputText))
{
username_local = getUsername.inputText;
}
}
var avatarData = new AvatarData
{
clientID = myClientID,
x = pos.x,
y = pos.y,
z = pos.z,
username = username_local,
muralId = currentMuralId
};
_ = ClientManager.Instance.SendAvatarUpdate(avatarData);
}
AvatarData
object and forwards it to ClientManager
for network transmission. This method is called periodically in Update()
based on sendRate
. SendAvatarUpdate
is a method in ClientManager
that configures the topic and message and actually publishes it to the broker.
StartHeartbeatChecker()
private async Task StartHeartbeatChecker()
{
while (isRunning && this != null)
{
await Task.Delay((int)(heartbeatCheckInterval * 1000));
if (!isRunning) break;
unitySyncContext.Post(_ => CheckForTimedOutClients(), null);
}
}
CheckForTimedOutClients()
private void CheckForTimedOutClients()
{
float currentTime = Time.time;
var clientsToRemove = new List<string>();
foreach (var kvp in lastHeartbeatTime)
{
string clientID = kvp.Key;
float lastTime = kvp.Value;
if (clientID == myClientID) continue;
if (currentTime - lastTime > heartbeatTimeout)
{
Debug.Log($"[AvatarManager] Client {clientID} timed out");
clientsToRemove.Add(clientID);
}
}
foreach (string clientID in clientsToRemove)
{
RemoveAvatar(clientID);
}
}
heartbeatTimeout
seconds. It looks through the dictionary in lastHeartbeatTime
and if the last heartbeat they sent was larger than heartbeatTimeout
(which is a serializable variable that you can configure), the avatar is classified as "inactive" and is removed.
UpdateRemoteAvatar(AvatarData data)
private void UpdateRemoteAvatar(AvatarData data)
{
Vector3 newPosition = new Vector3(data.x, data.y, data.z);
if (remoteAvatars.ContainsKey(data.clientID))
{
GameObject avatar = remoteAvatars[data.clientID];
if (avatar != null)
{
avatar.transform.position = newPosition;
}
else
{
remoteAvatars.Remove(data.clientID);
lastHeartbeatTime.Remove(data.clientID);
}
}
else
{
CreateNewAvatar(data.clientID, newPosition, data.username);
}
}
CreateNewAvatar(string clientID, Vector3 position, string username)
private void CreateNewAvatar(string clientID, Vector3 position, string username)
{
if (avatarPrefab == null)
{
Debug.LogError("[AvatarManager] Avatar prefab is not assigned!");
return;
}
GameObject newAvatar = Instantiate(avatarPrefab, position, Quaternion.identity);
newAvatar.name = $"Avatar_{clientID}";
Transform canvas = newAvatar.transform.GetChild(0).GetChild(0);
TextMeshPro tmp = canvas.GetComponent<TextMeshPro>();
tmp.text = username;
remoteAvatars[clientID] = newAvatar;
Debug.Log($"[AvatarManager] Created new avatar for {clientID} at {position}");
}
HandleAvatarUpdate(AvatarData data)
public void HandleAvatarUpdate(AvatarData data)
{
if (data.muralId != currentMuralId)
{
Debug.Log($"[AvatarManager] Ignoring avatar from mural {data.muralId}, currently on mural {currentMuralId}");
return;
}
unitySyncContext.Post(_ =>
{
lastHeartbeatTime[data.clientID] = Time.time;
UpdateRemoteAvatar(data);
}, null);
}
UpdateRemoteAvatar
. UnitySyncContext
is used to safely invoke Unity methods on the main thread from async tasks.
RemoveAvatar(string ClientID)
public void RemoveAvatar(string clientID)
{
if (remoteAvatars.ContainsKey(clientID))
{
GameObject avatar = remoteAvatars[clientID];
if (avatar != null){
Destroy(avatar);
}
remoteAvatars.Remove(clientID);
}
lastHeartbeatTime.Remove(clientID);
}
remoteAvatars
dictionary which pairs the avatar GameObject with a specific clientID, destroys the avatar GameObject, and removes the clientID from the relevant dictionaries.
ClearAllAvatars()
public void ClearAllAvatars()
{
foreach (var kvp in remoteAvatars)
{
if (kvp.Value != null)
Destroy(kvp.Value);
}
remoteAvatars.Clear();
lastHeartbeatTime.Clear();
}
OnMuralSwitch(int newMuralID)
public void OnMuralSwitch(int newMuralId)
{
if (ClientManager.Instance != null && ClientManager.Instance.connected)
{
_ = SendDisconnectMessage();
}
ClearAllAvatars();
currentMuralId = newMuralId;
}
SendDisconnectMessage()
private async Task SendDisconnectMessage()
{
if (ClientManager.Instance == null || !ClientManager.Instance.connected)
return;
var disconnectData = new AvatarData
{
clientID = myClientID,
x = 0,
y = 0,
z = 0,
username = "DISCONNECT",
muralId = currentMuralId
};
string json = JsonConvert.SerializeObject(disconnectData);
var message = new MqttApplicationMessageBuilder()
.WithTopic($"mural_{currentMuralId}/avatar/disconnect")
.WithPayload(json)
.WithQualityOfServiceLevel(MQTTnet.Protocol.MqttQualityOfServiceLevel.AtMostOnce)
.Build();
await ClientManager.Instance.mqttClient.PublishAsync(message);
}