Quellcode durchsuchen

Sync Linked Object System interactions; track triggers per player and pickup (1.1.0)

Interactions ran only on the interacting client, and shared one counter with the triggers. Now the
interaction state is synced (Manual, late joiners included) and the triggers are tracked locally: each
player once, players leaving the instance and pickups disabled inside are dropped. excludeLayers works.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
ArtyomV2X vor 1 Monat
Ursprung
Commit
602db74483
4 geänderte Dateien mit 237 neuen und 143 gelöschten Zeilen
  1. 49 0
      Documentation~/LinkedObjectSystem.md
  2. 1 1
      README.md
  3. 186 141
      Runtime/LinkedObjectSystem.cs
  4. 1 1
      package.json

+ 49 - 0
Documentation~/LinkedObjectSystem.md

@@ -0,0 +1,49 @@
+## Linked Object System
+
+Drives Animator bools and two audio objects from interactions and triggers: doors, buttons, pressure plates, lights. It replaces the Interact Script, Logic Script, Pickup Detector and Player Trigger.
+
+## Requires:
+- **Collider** (set to Is Trigger for the trigger types; any collider for interactions)
+- A GameObject of its own, or one whose other Udon scripts use **Manual** sync (or none). It uses Manual sync itself, which can't share a GameObject with **VRC Object Sync** or Continuous scripts.
+
+## Debug Mode - Type: Boolean
+- Logs every change to the console.
+
+## Delayed Interact Time - Type: Float
+- How long a **Temporary** interaction stays on, in seconds.
+
+## Exclude Layers - Type: LayerMask
+- Pickups on these layers don't count for the **Object** trigger.
+
+## Trigger Type - Type: Enum
+- Who keeps the output on while inside the trigger:
+  - **None:** the trigger isn't used.
+  - **Player:** any player.
+  - **Object:** any pickup (a GameObject with VRC Pickup).
+  - **Both Player And Object:** either.
+
+## Interaction Type - Type: Enum
+- What interacting with the object does:
+  - **None:** it can't be interacted with.
+  - **Permanent:** turns the output on for good.
+  - **Temporary:** turns it on for **Delayed Interact Time** seconds; it can't be used again until then.
+  - **Toggle:** turns it on and off.
+
+## Output Animators / Output Parameter Names - Type: Arrays
+- Each Animator gets its matching bool parameter set to the output. Empty entries are skipped.
+
+## Audio Object True / Audio Object False - Type: GameObject
+- Audio Object True is active while the output is on, Audio Object False while it's off. Either can be left empty.
+
+---
+
+### Script Behavior:
+- The output is on while the interaction is on, **or** anyone or anything counted is inside the trigger.
+- Interactions are synced: everyone sees them, including players who join later.
+- Triggers aren't synced, because every player sees the same players and synced pickups enter. A player is only counted once, players who leave the instance stop counting, and pickups that are disabled or destroyed inside stop counting within a second.
+
+### Upgrading from 1.0.x:
+- Interactions used to show only for the player who interacted; now they're synced.
+- Interactions and triggers used to share one counter, so a trigger could end a Temporary interaction early or stop a Toggle; now they're separate.
+- Audio objects used to switch only when both were assigned; now each works on its own.
+- The behaviour switches to Manual sync. If it shares a GameObject with VRC Object Sync or a Continuous script, move it to its own GameObject.

+ 1 - 1
README.md

@@ -12,7 +12,7 @@ UdonSharp utility scripts for VRChat worlds. Every component is under **Add Comp
 | Jump Pad Controller | Launches players and pickups in an arc onto a target | [JumpPad](Documentation~/JumpPad.md) |
 | Laser Beam Controller | Draws a laser to whatever it hits | [LaserBeam](Documentation~/LaserBeam.md) |
 | Laser Receiver Controller | Turns things on while a laser reaches it | [LaserReceiver](Documentation~/LaserReceiver.md) |
-| Linked Object System | Drives Animator bools from interactions and triggers | |
+| Linked Object System | Drives Animator bools from synced interactions and triggers | [LinkedObjectSystem](Documentation~/LinkedObjectSystem.md) |
 | Pickup Fizzler | Portal-style fizzler that respawns pickups (under review) | [PickupFizzler](Documentation~/PickupFizzler.md) |
 | Pickup Following Mover Fix | Keeps pickups riding on moving platforms | [PickupMovementFixer](Documentation~/PickupMovementFixer.md) |
 | Pickup Speed Limiter | Caps how fast a thrown pickup can fly | [PickupSpeedLimiter](Documentation~/PickupSpeedLimiter.md) |

+ 186 - 141
Runtime/LinkedObjectSystem.cs

@@ -2,6 +2,7 @@ using UdonSharp;
 using UnityEngine;
 using VRC.SDKBase;
 
+[UdonBehaviourSyncMode(BehaviourSyncMode.Manual)]
 [DisallowMultipleComponent]
 [AddComponentMenu("Artyom Scripting System/Linked Object System")]
 public class LinkedObjectSystem : UdonSharpBehaviour
@@ -9,197 +10,241 @@ public class LinkedObjectSystem : UdonSharpBehaviour
     [Header("Script Settings\n")]
     [Tooltip("Print all variables to console for debug purposes")]
     public bool debugMode;
-    [Tooltip("Delay for Temporary interact, ignore if not needed")]
+    [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("Trigger behavior type")]
-    
 
+    [Header("Interactions Settings\n")]
+    [Tooltip("Who keeps the output on while inside this trigger")]
     public TriggerType triggerType = TriggerType.None;
-    [Tooltip("Interact via select type")]
+    [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; 
+    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;
-    
-    [UdonSynced]
-    private int isOccupied;
-    private float timer;
+    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()
     {
-        isOccupied = 0;
-        if (triggerType == TriggerType.BothPlayerAndObject)
+        playerTriggerEnabled = triggerType == TriggerType.Player || triggerType == TriggerType.BothPlayerAndObject;
+        pickupTriggerEnabled = triggerType == TriggerType.Object || triggerType == TriggerType.BothPlayerAndObject;
+        if (interactionType == InteractionType.None)
         {
-            playerTriggerEnabled = true;
-            pickupTriggerEnabled = true;
+            DisableInteractive = true;
         }
-        else if (triggerType == TriggerType.Player)
+    }
+
+    // ------------------------------------------------------------ interactions
+
+    public override void Interact()
+    {
+        if (interactionType == InteractionType.Permanent)
         {
-            playerTriggerEnabled = true;
-            pickupTriggerEnabled = false;
+            if (!interactState) SetInteractState(true);
         }
-        else if (triggerType == TriggerType.Object)
+        else if (interactionType == InteractionType.Toggle)
         {
-            playerTriggerEnabled = false;
-            pickupTriggerEnabled = true;
+            SetInteractState(!interactState);
         }
-        if (interactionType == InteractionType.None)
+        else if (interactionType == InteractionType.Temporary)
         {
-            DisableInteractive = true;
+            if (interactState) return;
+            SetInteractState(true);
+            SendCustomEventDelayedSeconds(nameof(DisableTemp), delayedInteractTime);
         }
     }
-    private void Update()
+
+    /// <summary>Ends a Temporary interaction (run by whoever owns this object when the time is up).</summary>
+    public void DisableTemp()
     {
-        if (interactionType == InteractionType.Temporary && isOccupied == 1)
-        {
-            if (debugMode) Debug.Log("Linked Object System: Interaction Type Checked As Temprary");
-            if (timer < delayedInteractTime)
-            {
-                isOccupied = 1;
-                if (debugMode) Debug.LogWarning("LOS: Logged isOccupied as " + isOccupied.ToString());
-                timer += Time.deltaTime;
-                if (debugMode) Debug.Log("Linked Object System: DeltaTime Update, New Value -> " + timer.ToString());
-            }
-            else if (timer >= delayedInteractTime)
-            {
-                isOccupied = 0;
-                if (debugMode) Debug.LogWarning("LOS: Logged isOccupied as " + isOccupied.ToString());
-                UpdateAnims(false);
-                timer = 0f;
-            }
-        }
+        if (!interactState || !Networking.IsOwner(gameObject)) return;
+        SetInteractState(false);
     }
-      
-    private void UpdateAnims(bool state)
+
+    private void SetInteractState(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);
-        }
+        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();
+    }
 
-        // Enable or disable GameObjects based on the state
-        if (audioObjectFalse != null && audioObjectTrue != null)
-        {
-            if (!state)
-            {
-                if (debugMode) Debug.Log("Linked Object System: GameObject set audio to false");
-                audioObjectFalse.SetActive(true);
-                audioObjectTrue.SetActive(false);
-            }
-            else
-            {
-                if (debugMode) Debug.Log("Linked Object System: GameObject set audio to true");
-                audioObjectTrue.SetActive(true);
-                audioObjectFalse.SetActive(false);
-            }
-        }
-    } 
-    private void OnTriggerEnter(Collider other)
+    public override void OnDeserialization()
     {
-        if (pickupTriggerEnabled && other.GetComponent<VRC_Pickup>())
-        {
-            isOccupied++;
-            if (debugMode) Debug.Log("Linked Object System: Pickup set trigger to " + isOccupied.ToString());
-            if (isOccupied > 0 && isOccupied <=2)
-            {
-                UpdateAnims(true);
-            }
-        }
+        if (debugMode) Debug.Log("Linked Object System: interaction received as " + interactState.ToString());
+        ApplyInteractState();
     }
 
-    public override void OnPlayerTriggerEnter(VRCPlayerApi player)
+    public override void OnOwnershipTransferred(VRCPlayerApi player)
     {
-        if (playerTriggerEnabled)
-        {
-            isOccupied++;
-            if (debugMode) Debug.Log("Linked Object System: Player set trigger to " + isOccupied.ToString());
-            if (isOccupied > 0 && isOccupied <=2)
-            {
-                UpdateAnims(true);
-            }
-        }
+        // 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 OnTriggerExit(Collider other)
+    private void ApplyInteractState()
     {
-        if (pickupTriggerEnabled)
-        {
-            VRC_Pickup pickup = other.GetComponent<VRC_Pickup>();
-            if (pickup != null)
-            {
-                isOccupied--;
-                if (debugMode) Debug.Log("Linked Object System: Pickup set trigger to " + isOccupied.ToString());
-                if (isOccupied == 0)
-                {
-                    UpdateAnims(false);
-                }
-            }
-        }
+        // 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)
-        {
-            isOccupied--;
-            if (debugMode) Debug.Log("Linked Object System: Player set trigger to " + isOccupied.ToString());
-            if (isOccupied == 0)
-            {
-                UpdateAnims(false);
-            }
-        }
+        if (!playerTriggerEnabled || player == null) return;
+        RemovePlayer(player.playerId);
     }
-    public override void Interact()
+
+    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 (interactionType.Equals(InteractionType.Permanent))
+        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)
         {
-            isOccupied++;
-            if (isOccupied == 1)
-            {
-                UpdateAnims(true);
-            }
-            
+            pickupCheckScheduled = true;
+            SendCustomEventDelayedSeconds(nameof(_CheckPickups), 1f);
         }
-        if (interactionType.Equals(InteractionType.Temporary))
+        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();
+    }
+
+    /// <summary>
+    /// Drops pickups that were disabled or destroyed while inside: those never get an exit. Runs every second
+    /// while any pickup is inside.
+    /// </summary>
+    public void _CheckPickups()
+    {
+        pickupCheckScheduled = false;
+        for (int i = pickupCount - 1; i >= 0; i--)
         {
-            if (isOccupied == 0)
-            {
-                isOccupied = 1;
-                if (debugMode) Debug.LogWarning("LOS: Logged isOccupied as " + isOccupied.ToString());
-                UpdateAnims(true);
-                DisableInteractive = true;
-                SendCustomEventDelayedSeconds(nameof(DisableTemp), delayedInteractTime+0.2f);
-            }
+            Collider pickup = pickupsInside[i];
+            if (!Utilities.IsValid(pickup) || !pickup.enabled || !pickup.gameObject.activeInHierarchy) RemovePickupAt(i);
         }
-        if (interactionType.Equals(InteractionType.Toggle))
+        UpdateOutput();
+        if (pickupCount > 0)
         {
-            if (isOccupied == 1) { 
-                isOccupied = 0;
-                UpdateAnims(false);
-            }
-            else {
-                isOccupied = 1;
-                UpdateAnims(true);
-            }
+            pickupCheckScheduled = true;
+            SendCustomEventDelayedSeconds(nameof(_CheckPickups), 1f);
         }
     }
-    public void DisableTemp()
+
+    private bool CountsAsPickup(Collider other)
     {
-        DisableInteractive = false;
-        isOccupied = 0;
-        if (debugMode) Debug.LogWarning("LOS: Logged isOccupied as " + isOccupied.ToString());
+        if (other == null) return false;
+        if ((excludeLayers.value & (1 << other.gameObject.layer)) != 0) return false;
+        return other.GetComponent<VRC_Pickup>() != 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
+
+    /// <summary>On while the interaction is on, or anyone or anything counted is inside the trigger.</summary>
+    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);
     }
 }
 
@@ -216,4 +261,4 @@ public enum TriggerType
     Player,
     Object,
     BothPlayerAndObject
-}
+}

+ 1 - 1
package.json

@@ -1,7 +1,7 @@
 {
     "name": "dev.av2x.aumss",
     "displayName": "AUMSS - Artyom's Udon Modular Scripting System",
-    "version": "1.0.2",
+    "version": "1.1.0",
     "unity": "2022.3",
     "description": "UdonSharp utility scripts for VRChat worlds: jump pads, checkpoints, lasers, pickup helpers, timed teleports and the Linked Object System.",
     "author": {