using UdonSharp; using UnityEngine; using VRC.SDKBase; using VRC.Udon; using VRC.Udon.Common.Interfaces; [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; [Header("Script Links\n")] [Tooltip("Udon scripts told when the output turns on or off")] public UdonBehaviour[] outputBehaviours; [Tooltip("For each script, the event called when the output turns on. Leave an entry empty for none")] public string[] onEventNames; [Tooltip("For each script, the event called when the output turns off. Leave an entry empty for none")] public string[] offEventNames; [Tooltip("Off: every player's client calls the events itself. On: only this object's owner calls them, as network " + "events to everyone (players who join later don't get them, and event names can't start with _)")] public bool networkEvents; [Header("Station\n")] [Tooltip("The player who turns this on by interacting, or who walks into the trigger, is seated here. They leave it themselves")] public VRC.SDK3.Components.VRCStation station; // Named in full: VRC.SDKBase has a VRCStation too // 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 bool playerCheckScheduled; private Collider triggerCollider; 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; triggerCollider = GetComponent(); if (interactionType == InteractionType.None) { DisableInteractive = true; } } // ------------------------------------------------------------ interactions public override void Interact() { if (interactionType == InteractionType.Permanent) { if (interactState) return; SetInteractState(true); SeatLocalPlayer(); } else if (interactionType == InteractionType.Toggle) { SetInteractState(!interactState); if (interactState) SeatLocalPlayer(); } else if (interactionType == InteractionType.Temporary) { if (interactState) return; SetInteractState(true); SeatLocalPlayer(); 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()); if (!playerCheckScheduled) { playerCheckScheduled = true; SendCustomEventDelayedSeconds(nameof(_CheckPlayers), 1f); } UpdateOutput(); if (player.isLocal) SeatLocalPlayer(); } /// /// Drops players who left without an exit: seated in a station elsewhere, or gone from the instance. Runs every /// second while any player is inside. /// public void _CheckPlayers() { playerCheckScheduled = false; for (int i = playerCount - 1; i >= 0; i--) { VRCPlayerApi player = VRCPlayerApi.GetPlayerById(playersInside[i]); if (!Utilities.IsValid(player) || !NearTrigger(player)) RemovePlayer(playersInside[i]); } if (playerCount > 0) { playerCheckScheduled = true; SendCustomEventDelayedSeconds(nameof(_CheckPlayers), 1f); } } /// /// Whether the player's feet or head are within a metre of the trigger's bounds. Generous on purpose: a player /// dropped here gets no new enter until they walk out and back in. /// private bool NearTrigger(VRCPlayerApi player) { if (triggerCollider == null) return true; Bounds bounds = triggerCollider.bounds; Vector3 center = bounds.center; Vector3 reach = bounds.extents + Vector3.one; return Within(player.GetPosition(), center, reach) || Within(player.GetTrackingData(VRCPlayerApi.TrackingDataType.Head).position, center, reach); } private bool Within(Vector3 point, Vector3 center, Vector3 reach) { Vector3 d = point - center; return Mathf.Abs(d.x) <= reach.x && Mathf.Abs(d.y) <= reach.y && Mathf.Abs(d.z) <= reach.z; } 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); SendEvents(state); } private void SendEvents(bool state) { // Networked: the owner tells everyone, so the others mustn't send it again if (networkEvents && !Networking.IsOwner(gameObject)) return; string[] names = state ? onEventNames : offEventNames; int count = Mathf.Min(outputBehaviours.Length, names.Length); for (int i = 0; i < count; i++) { if (outputBehaviours[i] == null || string.IsNullOrEmpty(names[i])) continue; if (debugMode) Debug.Log("Linked Object System: sending " + names[i] + (networkEvents ? " to everyone" : "")); if (networkEvents) outputBehaviours[i].SendCustomNetworkEvent(NetworkEventTarget.All, names[i]); else outputBehaviours[i].SendCustomEvent(names[i]); } } /// Seats the local player in the station, if there is one: they turned this on, or walked into the trigger. private void SeatLocalPlayer() { if (station == null) return; if (debugMode) Debug.Log("Linked Object System: seating the local player"); station.UseStation(Networking.LocalPlayer); } } public enum InteractionType { None, Permanent, Temporary, Toggle } public enum TriggerType { None, Player, Object, BothPlayerAndObject }