using UdonSharp;
using UnityEngine;
using VRC.SDKBase;
[UdonBehaviourSyncMode(BehaviourSyncMode.Manual)]
[DisallowMultipleComponent]
[AddComponentMenu("Artyom Scripting System/Linked Object System")]
public class LinkedObjectSystem : UdonSharpBehaviour
{
[Header("Script Settings\n")]
[Tooltip("Print all variables to console for debug purposes")]
public bool debugMode;
[Tooltip("How long a Temporary interaction stays on, in seconds. Ignore if not needed")]
public float delayedInteractTime = 0f;
[Tooltip("Objects on these layers don't count for the Object trigger")]
public LayerMask excludeLayers;
[Header("Interactions Settings\n")]
[Tooltip("Who keeps the output on while inside this trigger")]
public TriggerType triggerType = TriggerType.None;
[Tooltip("What interacting does. Synced: everyone sees it, including players who join later")]
public InteractionType interactionType = InteractionType.None;
[Header("Animator Links (bool only)\n")]
public Animator[] outputAnimators;
public string[] outputParameterNames;
[Space]
public GameObject audioObjectTrue;
public GameObject audioObjectFalse;
// PRIVATE VARS
// Interactions only happen on the interacting player's client, so their result is synced.
// Triggers fire on every client for every player and synced pickup, so those are tracked locally.
[UdonSynced]
private bool interactState;
private bool playerTriggerEnabled = false;
private bool pickupTriggerEnabled = false;
private int[] playersInside = new int[100]; // Player IDs: a player can't be counted twice, and leaving the instance removes them
private int playerCount;
private Collider[] pickupsInside = new Collider[32];
private int pickupCount;
private bool pickupCheckScheduled;
private bool output; // What the animators and audio objects show now
void Start()
{
playerTriggerEnabled = triggerType == TriggerType.Player || triggerType == TriggerType.BothPlayerAndObject;
pickupTriggerEnabled = triggerType == TriggerType.Object || triggerType == TriggerType.BothPlayerAndObject;
if (interactionType == InteractionType.None)
{
DisableInteractive = true;
}
}
// ------------------------------------------------------------ interactions
public override void Interact()
{
if (interactionType == InteractionType.Permanent)
{
if (!interactState) SetInteractState(true);
}
else if (interactionType == InteractionType.Toggle)
{
SetInteractState(!interactState);
}
else if (interactionType == InteractionType.Temporary)
{
if (interactState) return;
SetInteractState(true);
SendCustomEventDelayedSeconds(nameof(DisableTemp), delayedInteractTime);
}
}
/// Ends a Temporary interaction (run by whoever owns this object when the time is up).
public void DisableTemp()
{
if (!interactState || !Networking.IsOwner(gameObject)) return;
SetInteractState(false);
}
private void SetInteractState(bool state)
{
if (!Networking.IsOwner(gameObject)) Networking.SetOwner(Networking.LocalPlayer, gameObject);
interactState = state;
RequestSerialization();
if (debugMode) Debug.Log("Linked Object System: interaction set to " + state.ToString());
ApplyInteractState();
}
public override void OnDeserialization()
{
if (debugMode) Debug.Log("Linked Object System: interaction received as " + interactState.ToString());
ApplyInteractState();
}
public override void OnOwnershipTransferred(VRCPlayerApi player)
{
// The owner left mid-way through a Temporary interaction: the new owner ends it
if (player.isLocal && interactionType == InteractionType.Temporary && interactState)
SendCustomEventDelayedSeconds(nameof(DisableTemp), delayedInteractTime);
}
private void ApplyInteractState()
{
// A Temporary interaction can't be used again until it's over
if (interactionType == InteractionType.Temporary) DisableInteractive = interactState;
UpdateOutput();
}
// ------------------------------------------------------------ player trigger
public override void OnPlayerTriggerEnter(VRCPlayerApi player)
{
if (!playerTriggerEnabled || !Utilities.IsValid(player)) return;
int id = player.playerId;
if (IndexOfPlayer(id) >= 0 || playerCount == playersInside.Length) return;
playersInside[playerCount] = id;
playerCount++;
if (debugMode) Debug.Log("Linked Object System: players inside: " + playerCount.ToString());
UpdateOutput();
}
public override void OnPlayerTriggerExit(VRCPlayerApi player)
{
if (!playerTriggerEnabled || player == null) return;
RemovePlayer(player.playerId);
}
public override void OnPlayerLeft(VRCPlayerApi player)
{
// Leaving the instance doesn't always count as leaving the trigger
if (!playerTriggerEnabled || player == null) return;
RemovePlayer(player.playerId);
}
private int IndexOfPlayer(int id)
{
for (int i = 0; i < playerCount; i++)
if (playersInside[i] == id) return i;
return -1;
}
private void RemovePlayer(int id)
{
int index = IndexOfPlayer(id);
if (index < 0) return;
playerCount--;
playersInside[index] = playersInside[playerCount];
if (debugMode) Debug.Log("Linked Object System: players inside: " + playerCount.ToString());
UpdateOutput();
}
// ------------------------------------------------------------ object trigger
private void OnTriggerEnter(Collider other)
{
if (!pickupTriggerEnabled || !CountsAsPickup(other)) return;
if (IndexOfPickup(other) >= 0 || pickupCount == pickupsInside.Length) return;
pickupsInside[pickupCount] = other;
pickupCount++;
if (debugMode) Debug.Log("Linked Object System: pickups inside: " + pickupCount.ToString());
if (!pickupCheckScheduled)
{
pickupCheckScheduled = true;
SendCustomEventDelayedSeconds(nameof(_CheckPickups), 1f);
}
UpdateOutput();
}
private void OnTriggerExit(Collider other)
{
if (!pickupTriggerEnabled || other == null) return;
int index = IndexOfPickup(other);
if (index < 0) return;
RemovePickupAt(index);
if (debugMode) Debug.Log("Linked Object System: pickups inside: " + pickupCount.ToString());
UpdateOutput();
}
///
/// Drops pickups that were disabled or destroyed while inside: those never get an exit. Runs every second
/// while any pickup is inside.
///
public void _CheckPickups()
{
pickupCheckScheduled = false;
for (int i = pickupCount - 1; i >= 0; i--)
{
Collider pickup = pickupsInside[i];
if (!Utilities.IsValid(pickup) || !pickup.enabled || !pickup.gameObject.activeInHierarchy) RemovePickupAt(i);
}
UpdateOutput();
if (pickupCount > 0)
{
pickupCheckScheduled = true;
SendCustomEventDelayedSeconds(nameof(_CheckPickups), 1f);
}
}
private bool CountsAsPickup(Collider other)
{
if (other == null) return false;
if ((excludeLayers.value & (1 << other.gameObject.layer)) != 0) return false;
return other.GetComponent() != null;
}
private int IndexOfPickup(Collider other)
{
for (int i = 0; i < pickupCount; i++)
if (pickupsInside[i] == other) return i;
return -1;
}
private void RemovePickupAt(int index)
{
pickupCount--;
pickupsInside[index] = pickupsInside[pickupCount];
pickupsInside[pickupCount] = null;
}
// ------------------------------------------------------------ output
/// On while the interaction is on, or anyone or anything counted is inside the trigger.
private void UpdateOutput()
{
bool on = interactState || playerCount > 0 || pickupCount > 0;
if (on == output) return;
output = on;
UpdateAnims(on);
}
private void UpdateAnims(bool state)
{
int count = Mathf.Min(outputAnimators.Length, outputParameterNames.Length);
for (int i = 0; i < count; i++)
{
if (outputAnimators[i] != null && !string.IsNullOrEmpty(outputParameterNames[i]))
outputAnimators[i].SetBool(outputParameterNames[i], state);
}
// Enable or disable GameObjects based on the state
if (debugMode) Debug.Log("Linked Object System: GameObject set audio to " + state.ToString());
if (audioObjectTrue != null) audioObjectTrue.SetActive(state);
if (audioObjectFalse != null) audioObjectFalse.SetActive(!state);
}
}
public enum InteractionType
{
None,
Permanent,
Temporary,
Toggle
}
public enum TriggerType
{
None,
Player,
Object,
BothPlayerAndObject
}