Selaa lähdekoodia

Initial commit

ArtyomV2X 1 kuukausi sitten
vanhempi
sitoutus
d8abbc8165
15 muutettua tiedostoa jossa 1464 lisäystä ja 0 poistoa
  1. 74 0
      BoneLookAtPlayer.cs
  2. 28 0
      Checkpoint.cs
  3. 51 0
      ImpactSoundPlayer.cs
  4. 96 0
      InteractScript.cs
  5. 141 0
      JumpPad.cs
  6. 50 0
      LaserBeam.cs
  7. 151 0
      LaserReceiver.cs
  8. 358 0
      LinkedObjectSystem.cs
  9. 102 0
      LogicScript.cs
  10. 35 0
      PickupDetector.cs
  11. 179 0
      PickupFizzler.cs
  12. 77 0
      PickupMovementFixer.cs
  13. 44 0
      PickupSpeedLimiter.cs
  14. 33 0
      PlayerTrigger.cs
  15. 45 0
      TimedTeleport.cs

+ 74 - 0
BoneLookAtPlayer.cs

@@ -0,0 +1,74 @@
+using UdonSharp;
+using UnityEngine;
+using VRC.SDKBase;
+
+[UdonBehaviourSyncMode(BehaviourSyncMode.NoVariableSync)]
+[DisallowMultipleComponent]
+[AddComponentMenu("Artyom Scripting System/Bone Look At Player")]
+public class BoneLookAtPlayer : UdonSharpBehaviour
+{
+    public GameObject objectToRotate; // The object to rotate
+    public float rotationSpeed = 90.0f; // Maximum rotation speed in degrees per second
+    public Vector3 rotationOffset; // Rotation offset for each axis
+
+    private Transform objectTransform;
+    private VRCPlayerApi[] players = new VRCPlayerApi[0]; // Reused every frame, grown when more players join
+
+    void Start()
+    {
+        if (objectToRotate == null)
+        {
+            Debug.LogError("[AUMSS] Bone Look At Player: Object To Rotate isn't assigned, so nothing will turn.");
+            enabled = false; // Stops Update, which would otherwise throw and halt the script
+            return;
+        }
+        objectTransform = objectToRotate.transform;
+    }
+
+    void Update()
+    {
+        if (objectTransform == null) return; // Not set up (enabled = false only takes effect next frame)
+        VRCPlayerApi nearestPlayer = GetNearestPlayer();
+        if (nearestPlayer == null) return;
+
+        Vector3 playerHeadPosition = nearestPlayer.GetTrackingData(VRCPlayerApi.TrackingDataType.Head).position;
+
+        // Calculate the direction to look
+        Vector3 directionToLook = playerHeadPosition - objectTransform.position;
+        if (directionToLook != Vector3.zero)
+        {
+            Quaternion targetRotation = Quaternion.LookRotation(directionToLook);
+
+            // Apply the rotation offset
+            targetRotation *= Quaternion.Euler(rotationOffset);
+
+            objectTransform.rotation = Quaternion.RotateTowards(objectTransform.rotation, targetRotation, rotationSpeed * Time.deltaTime);
+        }
+    }
+
+    VRCPlayerApi GetNearestPlayer()
+    {
+        int count = VRCPlayerApi.GetPlayerCount();
+        if (players.Length < count) players = new VRCPlayerApi[count];
+        VRCPlayerApi.GetPlayers(players);
+
+        VRCPlayerApi nearestPlayer = null;
+        float closestDistance = float.MaxValue;
+        Vector3 currentPosition = transform.position;
+
+        for (int i = 0; i < count; i++)
+        {
+            VRCPlayerApi player = players[i];
+            if (!Utilities.IsValid(player)) continue; // The local player counts too
+
+            float distance = Vector3.Distance(currentPosition, player.GetPosition());
+            if (distance < closestDistance)
+            {
+                closestDistance = distance;
+                nearestPlayer = player;
+            }
+        }
+
+        return nearestPlayer;
+    }
+}

+ 28 - 0
Checkpoint.cs

@@ -0,0 +1,28 @@
+using UdonSharp;
+using UnityEngine;
+using VRC.SDKBase;
+
+[UdonBehaviourSyncMode(BehaviourSyncMode.NoVariableSync)]
+[DisallowMultipleComponent]
+[AddComponentMenu("Artyom Scripting System/Checkpoint")]
+public class Checkpoint : UdonSharpBehaviour
+{
+    public GameObject targetObject; // The game object to move
+
+    // Each player has their own progress, so this stays local: syncing it let one player's checkpoint block everyone's
+    private bool hasBeenUsed = false;
+
+    public override void OnPlayerTriggerEnter(VRCPlayerApi player)
+    {
+        if (hasBeenUsed || !player.isLocal) return;
+        if (targetObject == null)
+        {
+            Debug.LogError("[AUMSS] Checkpoint: Target Object isn't assigned.");
+            return;
+        }
+
+        targetObject.transform.SetPositionAndRotation(transform.position, transform.rotation);
+        hasBeenUsed = true;
+        gameObject.SetActive(false); // Hides the checkpoint for this player once used
+    }
+}

+ 51 - 0
ImpactSoundPlayer.cs

@@ -0,0 +1,51 @@
+using UdonSharp;
+using UnityEngine;
+
+[UdonBehaviourSyncMode(BehaviourSyncMode.NoVariableSync)]
+[DisallowMultipleComponent]
+[AddComponentMenu("Artyom Scripting System/Impact Sound Player")]
+public class ImpactSoundPlayer : UdonSharpBehaviour
+{
+    public GameObject smallImpactSoundObject; // GameObject for small impact sound
+    public GameObject mediumImpactSoundObject; // GameObject for medium impact sound
+    public GameObject largeImpactSoundObject; // GameObject for large impact sound
+
+    public float mediumImpactThreshold = 5.0f; // Threshold for medium impact
+    public float largeImpactThreshold = 10.0f; // Threshold for large impact
+
+    [Tooltip("Impacts slower than this play nothing. 0 plays a sound for every contact, however gentle.")]
+    public float minimumImpact = 0f;
+    [Tooltip("Seconds after a sound before the next can play. 0 means no limit.")]
+    public float cooldown = 0f;
+
+    private float nextSoundTime;
+
+    private void OnCollisionEnter(Collision collision)
+    {
+        float impactForce = collision.relativeVelocity.magnitude;
+        if (impactForce < minimumImpact || Time.time < nextSoundTime) return;
+        nextSoundTime = Time.time + cooldown;
+
+        if (impactForce >= largeImpactThreshold)
+        {
+            PlaySound(largeImpactSoundObject);
+        }
+        else if (impactForce >= mediumImpactThreshold)
+        {
+            PlaySound(mediumImpactSoundObject);
+        }
+        else
+        {
+            PlaySound(smallImpactSoundObject);
+        }
+    }
+
+    private void PlaySound(GameObject soundObject)
+    {
+        if (soundObject != null)
+        {
+            soundObject.SetActive(false); // Reset the sound object
+            soundObject.SetActive(true);  // Activate the sound object to play the sound
+        }
+    }
+}

+ 96 - 0
InteractScript.cs

@@ -0,0 +1,96 @@
+using UdonSharp;
+using UnityEngine;
+using VRC.SDKBase;
+using VRC.Udon;
+using System;
+
+[Obsolete("Replaced with Linked Object System")]
+[UdonBehaviourSyncMode(BehaviourSyncMode.NoVariableSync)]
+[DisallowMultipleComponent]
+
+[AddComponentMenu("Artyom Scripting System/_DEPRECATED_/Interact Script")]
+public class InteractScript : UdonSharpBehaviour
+{
+    public Animator[] animators; // Array to hold Animators
+    public string[] parameterNames; // Array to hold corresponding parameter names
+    public InteractionType interactionType = InteractionType.None; // Dropdown for interaction type
+    public float temporaryDuration = 2.0f; // Duration for temporary interaction
+    public int isOccupied;
+
+    public GameObject audioObjectTrue; // GameObject to enable when the state changes to true
+    public GameObject audioObjectFalse; // GameObject to enable when the state changes to false
+
+    void Start()
+    {
+        isOccupied = 0;
+    }
+
+    private void UpdateAnims(bool state)
+    {
+        for (int i = 0; i < animators.Length; i++)
+        {
+            animators[i].SetBool(parameterNames[i], state);
+        }
+
+        // Enable or disable GameObjects based on the state
+        if (state)
+        {
+            if (audioObjectFalse != null) audioObjectFalse.SetActive(false);
+            if (audioObjectTrue != null) audioObjectTrue.SetActive(true);
+        }
+        else
+        {
+            if (audioObjectTrue != null) audioObjectTrue.SetActive(false);
+            if (audioObjectFalse != null) audioObjectFalse.SetActive(true);
+        }
+    }
+
+    public override void Interact()
+    {
+        Networking.SetOwner(Networking.LocalPlayer, gameObject); // Ensure the local player is the owner
+        if (interactionType == InteractionType.Temporary)
+        {
+            isOccupied = isOccupied + 1;
+            UpdateAnims(true);
+            SendCustomNetworkEvent(VRC.Udon.Common.Interfaces.NetworkEventTarget.All, nameof(NetworkUpdateAnims));
+            SendCustomEventDelayedSeconds(nameof(RevertTemporaryInteraction), temporaryDuration);
+        }
+        else if (interactionType == InteractionType.Permanent)
+        {
+            isOccupied = isOccupied + 1;
+            UpdateAnims(true);
+            SendCustomNetworkEvent(VRC.Udon.Common.Interfaces.NetworkEventTarget.All, nameof(NetworkUpdateAnims));
+        }
+        else if (interactionType == InteractionType.Toggle)
+        {
+            switch(isOccupied)
+            {
+                case 0:
+                    isOccupied = isOccupied + 1;
+                    UpdateAnims(true);
+                    break;
+                case 1:
+                    isOccupied = 0;
+                    UpdateAnims(false);
+                    break;
+            }
+            SendCustomNetworkEvent(VRC.Udon.Common.Interfaces.NetworkEventTarget.All, nameof(NetworkUpdateAnims));
+        }
+    }
+
+    public void RevertTemporaryInteraction()
+    {
+        isOccupied = isOccupied - 1;
+        if (isOccupied < 1)
+        {
+            UpdateAnims(false);
+        }
+        SendCustomNetworkEvent(VRC.Udon.Common.Interfaces.NetworkEventTarget.All, nameof(NetworkUpdateAnims));
+    }
+
+    public void NetworkUpdateAnims()
+    {
+        bool state = isOccupied > 0;
+        UpdateAnims(state);
+    }
+}

+ 141 - 0
JumpPad.cs

@@ -0,0 +1,141 @@
+using UdonSharp;
+using UnityEngine;
+using VRC.SDK3.Components;
+using VRC.SDKBase;
+
+[UdonBehaviourSyncMode(BehaviourSyncMode.NoVariableSync)]
+[DisallowMultipleComponent]
+[AddComponentMenu("Artyom Scripting System/Jump Pad Controller")]
+public class JumpPad : UdonSharpBehaviour
+{
+    public bool debug;
+    public Animator launchAnim;
+    public string animName = "isLaunch";
+    public Transform jumpTarget;
+    public Transform self;
+    public float arcTime = 1.2f;
+    public float walkLimit = 0.2f;
+    public float runLimit = 0.4f;
+    public float strafeLimit = 0.2f;
+    private Vector3 appliedVelocity;
+
+    private float defaultWalk;
+    private float defaultStrafe;
+    private float defaultRun;
+
+    private VRCPlayerApi player;
+    private bool ready;
+    private int localLaunches; // Launches of the local player still in the air
+    private int animLaunches; // Launches of anyone still holding the launch animation on
+
+    void Start()
+    {
+        // The inspector's Animator wins; only fall back to one on this object
+        if (launchAnim == null) launchAnim = GetComponent<Animator>();
+        if (self == null) self = transform;
+        if (jumpTarget == null)
+        {
+            Debug.LogError("[AUMSS] Jump Pad Controller: Jump Target isn't assigned, so the pad won't launch anything.");
+            return;
+        }
+        ready = true;
+        if (debug) Debug.Log("Jump Pad Controller: Velocity Calculated to:" + CalculateInitialVelocity(self.position, jumpTarget.position, Physics.gravity.y, arcTime).ToString());
+    }
+
+    public static Vector3 CalculateInitialVelocity(Vector3 origin, Vector3 target, float gravity, float time)
+    {
+        // Compute the differences in position
+        Vector3 displacement = target - origin;
+
+        // Calculate the initial velocity components
+        Vector3 initialVelocity = new Vector3(
+            displacement.x / time,                              // Horizontal X velocity
+            (displacement.y - 0.5f * gravity * time * time) / time, // Vertical Y velocity
+            displacement.z / time                               // Horizontal Z velocity
+        );
+
+        return initialVelocity;
+    }
+
+    private float LaunchDuration()
+    {
+        return Mathf.Max(arcTime - 0.2f, 0f);
+    }
+
+    public override void OnPlayerTriggerEnter(VRCPlayerApi collidedPlayer)
+    {
+        if (!ready) return;
+        StartLaunchAnim();
+        SendCustomEventDelayedSeconds(nameof(EndLaunchAnim), LaunchDuration());
+        // Remote players get launched by their own client, and speed/velocity calls on them throw and halt this script
+        if (!collidedPlayer.isLocal) return;
+
+        player = collidedPlayer;
+        // Only save the speeds when not already mid-launch: mid-launch they're the limits, and would be kept forever
+        if (localLaunches == 0)
+        {
+            defaultRun = player.GetRunSpeed();
+            defaultStrafe = player.GetStrafeSpeed();
+            defaultWalk = player.GetWalkSpeed();
+        }
+        localLaunches++;
+
+        // Players fall with the world's gravity scaled by their own gravity strength
+        appliedVelocity = CalculateInitialVelocity(self.position, jumpTarget.position, Physics.gravity.y * player.GetGravityStrength(), arcTime);
+        LimitMobility();
+        player.SetVelocity(appliedVelocity);
+
+        if (debug) Debug.Log("Jump Pad Controller: Player Velocity Set to:" + appliedVelocity.ToString());
+        SendCustomEventDelayedSeconds(nameof(Mobilize), LaunchDuration());
+    }
+
+    private void OnTriggerEnter(Collider other)
+    {
+        if (!ready || other == null) return;
+        VRC_Pickup pickup = other.GetComponent<VRC_Pickup>();
+        Rigidbody body = other.GetComponent<Rigidbody>();
+        if (pickup == null || body == null) return;
+        // A synced pickup is launched by its owner; anyone else setting its velocity just fights the sync
+        if (other.GetComponent<VRCObjectSync>() != null && !Networking.IsOwner(other.gameObject)) return;
+
+        appliedVelocity = CalculateInitialVelocity(self.position, jumpTarget.position, Physics.gravity.y, arcTime);
+        pickup.Drop();
+        body.velocity = appliedVelocity;
+    }
+
+    public void LimitMobility()
+    {
+        if (!Utilities.IsValid(player)) return;
+        player.SetWalkSpeed(walkLimit);
+        player.SetRunSpeed(runLimit);
+        player.SetStrafeSpeed(strafeLimit);
+        if (debug) Debug.Log("Jump Pad Controller: Limited Player Mobility for " + arcTime.ToString() + " seconds");
+    }
+
+    public void Mobilize()
+    {
+        if (localLaunches > 0) localLaunches--;
+        if (localLaunches > 0 || !Utilities.IsValid(player)) return; // Still in the air from a later launch
+        player.SetWalkSpeed(defaultWalk);
+        player.SetRunSpeed(defaultRun);
+        player.SetStrafeSpeed(defaultStrafe);
+        if (debug) Debug.Log("Jump Pad Controller: De-Limited Player Mobility after" + arcTime.ToString() + " seconds");
+    }
+
+    private void StartLaunchAnim()
+    {
+        animLaunches++;
+        if (launchAnim == null || string.IsNullOrEmpty(animName)) return;
+        launchAnim.SetBool(animName, true);
+        if (debug) Debug.Log("Jump Pad Controller: Animator State attempted to be set to True");
+    }
+
+    public void EndLaunchAnim()
+    {
+        if (animLaunches > 0) animLaunches--;
+        if (animLaunches > 0) return; // Someone else launched since; their launch ends it
+        if (launchAnim == null || string.IsNullOrEmpty(animName)) return;
+        launchAnim.SetBool(animName, false);
+        if (debug) Debug.Log("Jump Pad Controller: Animator State attempted to be set to False");
+    }
+}

+ 50 - 0
LaserBeam.cs

@@ -0,0 +1,50 @@
+using UdonSharp;
+using UnityEngine;
+
+[UdonBehaviourSyncMode(BehaviourSyncMode.NoVariableSync)]
+[DisallowMultipleComponent]
+[AddComponentMenu("Artyom Scripting System/Laser Beam Controller")]
+public class LaserBeam : UdonSharpBehaviour
+{
+    public LineRenderer lineRenderer; // Line Renderer for the laser beam
+    public Transform laserOrigin; // Origin point of the laser
+    public float maxDistance = 100.0f; // Maximum distance the laser can reach
+    public LayerMask layerMask; // Layer mask to specify which colliders to detect
+    public GameObject endPointObject; // GameObject to place at the end of the laser line
+    [Tooltip("Whether the beam stops at trigger colliders, such as a Laser Receiver's trigger.")]
+    public bool hitTriggers = true;
+
+    void Start()
+    {
+        // World-space points draw correctly whichever object the Line Renderer is on
+        if (lineRenderer != null) lineRenderer.useWorldSpace = true;
+        if (layerMask.value == 0)
+            Debug.LogWarning("[AUMSS] Laser Beam Controller: Layer Mask is Nothing, so the beam passes through everything.");
+    }
+
+    void Update()
+    {
+        if (lineRenderer == null || laserOrigin == null)
+        {
+            return;
+        }
+
+        RaycastHit hit;
+        Vector3 laserEndPoint = laserOrigin.position + (laserOrigin.forward * maxDistance);
+        QueryTriggerInteraction triggers = hitTriggers ? QueryTriggerInteraction.Collide : QueryTriggerInteraction.Ignore;
+
+        if (Physics.Raycast(laserOrigin.position, laserOrigin.forward, out hit, maxDistance, layerMask.value, triggers))
+        {
+            laserEndPoint = hit.point;
+        }
+
+        lineRenderer.SetPosition(0, laserOrigin.position);
+        lineRenderer.SetPosition(1, laserEndPoint);
+
+        // Place the endPointObject at the end of the laser line
+        if (endPointObject != null)
+        {
+            endPointObject.transform.position = laserEndPoint;
+        }
+    }
+}

+ 151 - 0
LaserReceiver.cs

@@ -0,0 +1,151 @@
+using UdonSharp;
+using UnityEngine;
+using VRC.SDKBase;
+
+[UdonBehaviourSyncMode(BehaviourSyncMode.NoVariableSync)]
+[DisallowMultipleComponent]
+[AddComponentMenu("Artyom Scripting System/Laser Receiver Controller")]
+public class LaserReceiver : UdonSharpBehaviour
+{
+    public GameObject targetObject; // The object to turn on/off
+    public int laserLayer = 22; // Layer number to detect the laser
+    public Animator[] animators; // Array to hold Animators
+    public string[] parameterNames; // Array to hold corresponding parameter names
+    public GameObject audioObjectTrue; // GameObject to enable when the state changes to true
+    public GameObject audioObjectFalse; // GameObject to enable when the state changes to false
+    public GameObject audioObjectLoop; // GameObject to loop while the state is true
+    public Collider triggerCollider; // Reference to the trigger collider (this object's collider if empty)
+    public Renderer targetRenderer; // Renderer to change material
+    public Material materialTrue; // Material to apply when the state is true
+    public Material materialFalse; // Material to apply when the state is false
+    public int materialIndex = 0; // Index of the material to change
+    [Tooltip("Log each change of the number of lasers inside")]
+    public bool debugMode;
+
+    private int colliderCount = 0; // Counter for colliders with the laser layer
+    private Collider[] collidersInsideTrigger = new Collider[16]; // Reused every frame
+    private BoxCollider box; // Set when the trigger is a box: checked exactly, rotation included
+
+    private void Start()
+    {
+        if (triggerCollider == null) triggerCollider = GetComponent<Collider>();
+        if (triggerCollider == null)
+        {
+            Debug.LogError("[AUMSS] Laser Receiver Controller: no Trigger Collider assigned, and none on this object.");
+            enabled = false; // Stops Update, which would otherwise throw and halt the script
+            return;
+        }
+        box = triggerCollider.GetComponent<BoxCollider>();
+        if (box != triggerCollider) box = null; // Another collider on the same object: fall back to the bounds
+
+        // Ensure the target object is initially off
+        if (targetObject != null)
+        {
+            targetObject.SetActive(false);
+        }
+
+        // Ensure the looping audio is initially off
+        if (audioObjectLoop != null)
+        {
+            audioObjectLoop.SetActive(false);
+        }
+
+        // Initial check for colliders inside the trigger
+        UpdateColliderCount();
+    }
+
+    private void Update()
+    {
+        if (triggerCollider == null) return; // Not set up (enabled = false only takes effect next frame)
+        // Continuously check for colliders inside the trigger
+        UpdateColliderCount();
+    }
+
+    private void UpdateColliderCount()
+    {
+        Vector3 boxCenter;
+        Vector3 boxHalfExtents;
+        Quaternion boxRotation;
+        if (box != null)
+        {
+            Transform boxTransform = box.transform;
+            boxCenter = boxTransform.TransformPoint(box.center);
+            boxHalfExtents = Vector3.Scale(box.size, boxTransform.lossyScale) * 0.5f;
+            boxHalfExtents = new Vector3(Mathf.Abs(boxHalfExtents.x), Mathf.Abs(boxHalfExtents.y), Mathf.Abs(boxHalfExtents.z));
+            boxRotation = boxTransform.rotation;
+        }
+        else
+        {
+            // Other collider shapes: the world-space box around them
+            boxCenter = triggerCollider.bounds.center;
+            boxHalfExtents = triggerCollider.bounds.extents;
+            boxRotation = Quaternion.identity;
+        }
+
+        int found = Physics.OverlapBoxNonAlloc(boxCenter, boxHalfExtents, collidersInsideTrigger, boxRotation,
+            1 << laserLayer, QueryTriggerInteraction.UseGlobal);
+
+        // Update the collider count based on the result
+        int newColliderCount = 0;
+        for (int i = 0; i < found; i++)
+        {
+            if (collidersInsideTrigger[i].GetComponent<VRC_Pickup>() == null)
+            {
+                newColliderCount++;
+            }
+        }
+
+        if (newColliderCount != colliderCount)
+        {
+            colliderCount = newColliderCount;
+            if (debugMode) Debug.Log("[AUMSS] Laser Receiver Controller: lasers inside: " + colliderCount);
+
+            // Turn on or off the target object based on the collider count
+            bool on = colliderCount > 0;
+            if (targetObject != null)
+            {
+                targetObject.SetActive(on);
+            }
+            UpdateAnims(on);
+        }
+    }
+
+    private void UpdateAnims(bool state)
+    {
+        int count = Mathf.Min(animators.Length, parameterNames.Length);
+        for (int i = 0; i < count; i++)
+        {
+            if (animators[i] != null && !string.IsNullOrEmpty(parameterNames[i]))
+            {
+                animators[i].SetBool(parameterNames[i], state);
+            }
+        }
+
+        // Enable or disable GameObjects based on the state
+        if (state)
+        {
+            if (audioObjectFalse != null) audioObjectFalse.SetActive(false);
+            if (audioObjectTrue != null) audioObjectTrue.SetActive(true);
+            if (audioObjectLoop != null) audioObjectLoop.SetActive(true); // Enable looping audio
+            if (targetRenderer != null && materialTrue != null) SetMaterial(materialTrue); // Change material to true
+        }
+        else
+        {
+            if (audioObjectTrue != null) audioObjectTrue.SetActive(false);
+            if (audioObjectFalse != null) audioObjectFalse.SetActive(true);
+            if (audioObjectLoop != null) audioObjectLoop.SetActive(false); // Disable looping audio
+            if (targetRenderer != null && materialFalse != null) SetMaterial(materialFalse); // Change material to false
+        }
+    }
+
+    private void SetMaterial(Material material)
+    {
+        // sharedMaterials: .materials would make a new copy of every material on each change
+        Material[] materials = targetRenderer.sharedMaterials;
+        if (materialIndex >= 0 && materialIndex < materials.Length)
+        {
+            materials[materialIndex] = material;
+            targetRenderer.sharedMaterials = materials;
+        }
+    }
+}

+ 358 - 0
LinkedObjectSystem.cs

@@ -0,0 +1,358 @@
+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<Collider>();
+        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);
+        }
+    }
+
+    /// <summary>Ends a Temporary interaction (run by whoever owns this object when the time is up).</summary>
+    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();
+    }
+
+    /// <summary>
+    /// 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.
+    /// </summary>
+    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);
+        }
+    }
+
+    /// <summary>
+    /// 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.
+    /// </summary>
+    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();
+    }
+
+    /// <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--)
+        {
+            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<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);
+
+        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]);
+        }
+    }
+
+    /// <summary>Seats the local player in the station, if there is one: they turned this on, or walked into the trigger.</summary>
+    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
+}

+ 102 - 0
LogicScript.cs

@@ -0,0 +1,102 @@
+using UdonSharp;
+using UnityEngine;
+using VRC.SDKBase;
+using VRC.Udon;
+using System;
+
+[Obsolete("Replaced with Linked Object System")]
+[UdonBehaviourSyncMode(BehaviourSyncMode.Manual)]
+[DisallowMultipleComponent]
+
+[AddComponentMenu("Artyom Scripting System/_DEPRECATED_/Logic Script")]
+public class LogicScript : UdonSharpBehaviour
+{
+    public Animator[] animators; // Array to hold Animators
+    public string[] parameterNames; // Array to hold corresponding parameter names
+    public bool playerTriggerEnabled = true;
+    public bool pickupTriggerEnabled = true;
+    
+    [UdonSynced]
+    public int isOccupied;
+    
+    public GameObject audioObjectTrue; // GameObject to enable when the state changes to true
+    public GameObject audioObjectFalse; // GameObject to enable when the state changes to false
+
+    void Start()
+    {
+        isOccupied = 0;
+    }
+
+    private void UpdateAnims(bool state)
+    {
+        for (int i = 0; i < animators.Length; i++)
+        {
+            animators[i].SetBool(parameterNames[i], state);
+        }
+
+        // Enable or disable GameObjects based on the state
+        if (state)
+        {
+            if (audioObjectFalse != null) audioObjectFalse.SetActive(false);
+            if (audioObjectTrue != null) audioObjectTrue.SetActive(true);
+        }
+        else
+        {
+            if (audioObjectTrue != null) audioObjectTrue.SetActive(false);
+            if (audioObjectFalse != null) audioObjectFalse.SetActive(true);
+        }
+    }
+
+    private void OnTriggerEnter(Collider other)
+    {
+        if (pickupTriggerEnabled)
+        {
+            VRC_Pickup pickup = other.GetComponent<VRC_Pickup>();
+            if (pickup != null)
+            {
+                isOccupied = isOccupied + 1;
+                UpdateAnims(true);
+            }
+        }
+    }
+
+    public override void OnPlayerTriggerEnter(VRCPlayerApi player)
+    {
+        if (playerTriggerEnabled)
+        {
+            isOccupied = isOccupied + 1;
+            if (isOccupied > 0)
+            {
+                UpdateAnims(true);
+            }
+        }
+    }
+
+    private void OnTriggerExit(Collider other)
+    {
+        if (pickupTriggerEnabled)
+        {
+            VRC_Pickup pickup = other.GetComponent<VRC_Pickup>();
+            if (pickup != null)
+            {
+                isOccupied = isOccupied - 1;
+                if (isOccupied < 1)
+                {
+                    UpdateAnims(false);
+                }
+            }
+        }
+    }
+
+    public override void OnPlayerTriggerExit(VRCPlayerApi player)
+    {
+        if (playerTriggerEnabled)
+        {
+            isOccupied = isOccupied - 1;
+            if (isOccupied < 1)
+            {
+                UpdateAnims(false);
+            }
+        }
+    }
+}

+ 35 - 0
PickupDetector.cs

@@ -0,0 +1,35 @@
+using UdonSharp;
+using UnityEngine;
+using VRC.SDKBase;
+using VRC.Udon;
+using System;
+
+[Obsolete("Replaced with Linked Object System")]
+[UdonBehaviourSyncMode(BehaviourSyncMode.NoVariableSync)]
+[DisallowMultipleComponent]
+
+[AddComponentMenu("Artyom Scripting System/_DEPRECATED_/Pickup Detector")]
+public class PickupDetector : UdonSharpBehaviour
+{
+    // You can specify the pickup object here if you want to check for a specific object
+    public Animator targetAnimator; // The Animator component to control
+    public string parameterName; // The name of the boolean parameter to change
+
+    private void OnTriggerEnter(Collider other)
+    {
+        VRC_Pickup pickup = other.GetComponent<VRC_Pickup>();
+        if (pickup != null)
+        {
+            targetAnimator.SetBool(parameterName, true);
+        }
+    }
+
+    private void OnTriggerExit(Collider other)
+    {
+        VRC_Pickup pickup = other.GetComponent<VRC_Pickup>();
+        if (pickup != null)
+        {
+            targetAnimator.SetBool(parameterName, false);
+        }
+    }
+}

+ 179 - 0
PickupFizzler.cs

@@ -0,0 +1,179 @@
+using UdonSharp;
+using UnityEngine;
+using VRC.SDKBase;
+using VRC.SDK3.Components;
+using System;
+[Obsolete("Component under review for further dubugging... Pardon our dust!")]
+
+[UdonBehaviourSyncMode(BehaviourSyncMode.NoVariableSync)]
+[DisallowMultipleComponent]
+[AddComponentMenu("Artyom Scripting System/Pickup Fizzler")]
+public class PickupFizzler : UdonSharpBehaviour
+{
+    public AudioSource fizzlerSound; // Sound to play when a pickup is fizzled
+    public AudioSource playerEnterSound; // Sound to play when a player enters the collider
+    public int pickupLayer = 13; // Layer number for the pickup (VRChat's Pickup layer)
+    public float fizzleDuration = 2.0f; // Duration for the fizzle effect
+    public Material fizzleMaterial; // Material to apply during the fizzle effect
+    private Material[][] originalMaterials;
+    private VRC_Pickup pickup;
+    private VRCObjectSync objectSync;
+    private Renderer[] renderers;
+    private Collider pickupCollider;
+    private Rigidbody pickupRigidbody;
+    private bool isFizzling = false;
+    private float fizzleTimer = 0.0f;
+    private Vector3 storedVelocity;
+    private Vector3 storedAngularVelocity;
+    private Transform soundParent; // Where the fizzle sound goes back to after following a pickup
+    private Vector3 soundLocalPosition;
+    private Quaternion soundLocalRotation;
+
+    private void Start()
+    {
+        // Ensure the AudioSource is initially inactive
+        if (fizzlerSound != null)
+        {
+            fizzlerSound.Stop();
+            soundParent = fizzlerSound.transform.parent;
+            soundLocalPosition = fizzlerSound.transform.localPosition;
+            soundLocalRotation = fizzlerSound.transform.localRotation;
+        }
+
+        if (playerEnterSound != null)
+        {
+            playerEnterSound.Stop();
+        }
+    }
+
+    private void OnTriggerEnter(Collider other)
+    {
+        // Check if the collider is on the pickup layer and not already fizzling
+        if (other == null || other.gameObject.layer != pickupLayer || isFizzling) return;
+
+        // Check if the collider has a VRC_Pickup component
+        pickup = other.GetComponent<VRC_Pickup>();
+        if (pickup == null) return;
+
+        // Get the VRCObjectSync component
+        objectSync = pickup.GetComponent<VRCObjectSync>();
+        if (objectSync == null)
+        {
+            Debug.LogError("[AUMSS] Pickup Fizzler: no VRCObjectSync on " + pickup.gameObject.name + ", so it can't be respawned.");
+            return;
+        }
+
+        // Get the pickup's collider and rigidbody
+        pickupCollider = pickup.GetComponent<Collider>();
+        pickupRigidbody = pickup.GetComponent<Rigidbody>();
+
+        // Store the current velocity and angular velocity
+        if (pickupRigidbody != null)
+        {
+            storedVelocity = pickupRigidbody.velocity;
+            storedAngularVelocity = pickupRigidbody.angularVelocity;
+        }
+
+        // Swap every material for the fizzle material. sharedMaterials: .materials would copy them all each time
+        renderers = pickup.GetComponentsInChildren<Renderer>();
+        originalMaterials = new Material[renderers.Length][];
+        for (int i = 0; i < renderers.Length; i++)
+        {
+            originalMaterials[i] = renderers[i].sharedMaterials;
+            Material[] newMaterials = new Material[originalMaterials[i].Length];
+            for (int j = 0; j < newMaterials.Length; j++)
+            {
+                newMaterials[j] = fizzleMaterial;
+            }
+            renderers[i].sharedMaterials = newMaterials;
+        }
+
+        // Play the fizzler sound
+        if (fizzlerSound != null)
+        {
+            fizzlerSound.transform.SetParent(pickup.transform);
+            fizzlerSound.transform.localPosition = Vector3.zero;
+            fizzlerSound.Play();
+        }
+
+        // Disable the pickup's collider
+        if (pickupCollider != null)
+        {
+            pickupCollider.enabled = false;
+        }
+
+        // Force the player to drop the pickup
+        pickup.Drop();
+
+        // Start the fizzling process
+        isFizzling = true;
+        fizzleTimer = fizzleDuration;
+    }
+
+    public override void OnPlayerTriggerEnter(VRCPlayerApi player)
+    {
+        if (playerEnterSound != null && player.isLocal)
+        {
+            playerEnterSound.Play();
+        }
+    }
+
+    private void Update()
+    {
+        if (!isFizzling) return;
+
+        if (!Utilities.IsValid(pickup))
+        {
+            // The pickup was destroyed mid-fizzle: put the sound back and wait for the next one
+            ResetSound();
+            isFizzling = false;
+            return;
+        }
+
+        fizzleTimer -= Time.deltaTime;
+        // Only the owner moves and respawns it; anyone else would fight the sync
+        bool isOwner = Networking.IsOwner(pickup.gameObject);
+
+        // Apply the stored velocity and angular velocity
+        if (isOwner && pickupRigidbody != null)
+        {
+            pickupRigidbody.velocity = storedVelocity;
+            pickupRigidbody.angularVelocity = storedAngularVelocity;
+        }
+        pickup.Drop();
+
+        if (fizzleTimer <= 0.0f)
+        {
+            // Respawn the pickup using VRCObjectSync
+            if (isOwner && objectSync != null)
+            {
+                objectSync.Respawn();
+            }
+
+            ResetSound();
+
+            // Re-enable the pickup's collider
+            if (pickupCollider != null)
+            {
+                pickupCollider.enabled = true;
+            }
+
+            // Reset the original materials
+            for (int i = 0; i < renderers.Length; i++)
+            {
+                if (renderers[i] != null) renderers[i].sharedMaterials = originalMaterials[i];
+            }
+
+            isFizzling = false;
+        }
+    }
+
+    private void ResetSound()
+    {
+        if (fizzlerSound == null) return;
+        fizzlerSound.Stop();
+        fizzlerSound.transform.SetParent(soundParent);
+        fizzlerSound.transform.localPosition = soundLocalPosition;
+        fizzlerSound.transform.localRotation = soundLocalRotation;
+    }
+}

+ 77 - 0
PickupMovementFixer.cs

@@ -0,0 +1,77 @@
+using UdonSharp;
+using UnityEngine;
+using VRC.SDK3.Components;
+using VRC.SDKBase;
+
+[UdonBehaviourSyncMode(BehaviourSyncMode.NoVariableSync)]
+[DisallowMultipleComponent]
+[AddComponentMenu("Artyom Scripting System/Pickup Following Mover Fix")]
+public class PickupMovementFixer : UdonSharpBehaviour
+{
+    public GameObject targetObject;  // The object to follow
+
+    private int pickupLayer = 13;  // The layer assigned to pickup objects
+    private Collider triggerCollider;
+    private Vector3 previousTargetPosition;
+    private Vector3 targetVelocity;
+    private bool carrying; // While the target moves, and for one frame after it stops
+    private bool ready;
+
+    private void Start()
+    {
+        triggerCollider = GetComponent<Collider>();
+        if (triggerCollider == null || !triggerCollider.isTrigger)
+        {
+            Debug.LogError("[AUMSS] Pickup Following Mover Fix: Collider must be set as a trigger.");
+        }
+        if (targetObject == null)
+        {
+            Debug.LogError("[AUMSS] Pickup Following Mover Fix: Target Object isn't assigned.");
+            enabled = false;
+            return;
+        }
+        previousTargetPosition = targetObject.transform.position;
+        ready = true;
+    }
+
+    // LateUpdate runs after Update scripts and Animators have moved the target this frame,
+    // so the movement measured here matches this frame's deltaTime
+    private void LateUpdate()
+    {
+        if (!ready) return; // Not set up (enabled = false only takes effect next frame)
+        Vector3 currentTargetPosition = targetObject.transform.position;
+        Vector3 newVelocity = (currentTargetPosition - previousTargetPosition) / Time.deltaTime;
+        previousTargetPosition = currentTargetPosition;
+
+        // A still target leaves the pickups to their own physics, so they can fall and slide.
+        // The frame it stops still applies (zero), so the pickups stop with it instead of sliding off.
+        bool moving = newVelocity.sqrMagnitude > 0.0001f;
+        carrying = moving || targetVelocity.sqrMagnitude > 0.0001f;
+        targetVelocity = newVelocity;
+    }
+
+    private void OnTriggerEnter(Collider other)
+    {
+        Carry(other);
+    }
+
+    private void OnTriggerStay(Collider other)
+    {
+        Carry(other);
+    }
+
+    private void Carry(Collider other)
+    {
+        if (!ready || !carrying || other == null || other.gameObject.layer != pickupLayer) return;
+        VRC_Pickup pickup = other.GetComponent<VRC_Pickup>();
+        if (pickup == null || pickup.IsHeld) return;
+        // A synced pickup is moved by its owner; anyone else setting its velocity just fights the sync
+        if (other.GetComponent<VRCObjectSync>() != null && !Networking.IsOwner(other.gameObject)) return;
+
+        Rigidbody pickupRigidbody = other.GetComponent<Rigidbody>();
+        if (pickupRigidbody != null)
+        {
+            pickupRigidbody.velocity = targetVelocity;
+        }
+    }
+}

+ 44 - 0
PickupSpeedLimiter.cs

@@ -0,0 +1,44 @@
+using UdonSharp;
+using UnityEngine;
+using VRC.SDK3.Components;
+using VRC.SDKBase;
+
+[UdonBehaviourSyncMode(BehaviourSyncMode.NoVariableSync)]
+[DisallowMultipleComponent]
+[AddComponentMenu("Artyom Scripting System/Pickup Speed Limiter")]
+public class PickupSpeedLimiter : UdonSharpBehaviour
+{
+    public float maxSpeed = 5.0f; // Maximum allowed speed for the pickup
+    public bool debugSpeed;
+    private VRC_Pickup pickup; // Reference to the VRC_Pickup component
+    private Rigidbody rb;
+    private bool synced; // Has VRCObjectSync: only its owner limits it
+
+    void Start()
+    {
+        pickup = GetComponent<VRC_Pickup>();
+        rb = GetComponent<Rigidbody>();
+        synced = GetComponent<VRCObjectSync>() != null;
+
+        if (pickup == null || rb == null)
+        {
+            Debug.LogError("[AUMSS] Pickup Speed Limiter: needs a VRC_Pickup and a Rigidbody on this GameObject.");
+            enabled = false;
+        }
+    }
+
+    // FixedUpdate: once per physics step, so no step runs over the limit
+    void FixedUpdate()
+    {
+        if (pickup == null || rb == null) return; // Not set up (enabled = false only takes effect next frame)
+        if (pickup.IsHeld) return;
+        if (synced && !Networking.IsOwner(gameObject)) return; // Anyone else would fight the sync
+
+        Vector3 velocity = rb.velocity;
+        if (velocity.sqrMagnitude > maxSpeed * maxSpeed)
+        {
+            if (debugSpeed) Debug.LogWarning("[AUMSS] Pickup Speed Limiter: " + gameObject.name + " at " + velocity.magnitude.ToString() + ", limited to " + maxSpeed.ToString());
+            rb.velocity = velocity.normalized * maxSpeed;
+        }
+    }
+}

+ 33 - 0
PlayerTrigger.cs

@@ -0,0 +1,33 @@
+
+using UdonSharp;
+using UnityEngine;
+using VRC.SDKBase;
+using VRC.Udon;
+using System;
+
+[Obsolete("Replaced with Linked Object System")]
+[UdonBehaviourSyncMode(BehaviourSyncMode.NoVariableSync)]
+[DisallowMultipleComponent]
+
+[AddComponentMenu("Artyom Scripting System/_DEPRECATED_/Player Trigger")]
+public class PlayerTrigger : UdonSharpBehaviour
+{
+    public Animator targetAnimator; // The Animator component to control
+    public string parameterName; // The name of the boolean parameter to change
+
+    public override void OnPlayerTriggerStay(VRCPlayerApi player)
+    {
+        if (targetAnimator != null && !string.IsNullOrEmpty(parameterName))
+        {
+            targetAnimator.SetBool(parameterName, true); // Set the boolean parameter to true
+        }
+    }
+
+    public override void OnPlayerTriggerExit(VRCPlayerApi player)
+    {
+        if (targetAnimator != null && !string.IsNullOrEmpty(parameterName))
+        {
+            targetAnimator.SetBool(parameterName, false); // Set the boolean parameter to false
+        }
+    }
+}

+ 45 - 0
TimedTeleport.cs

@@ -0,0 +1,45 @@
+using UdonSharp;
+using UnityEngine;
+using VRC.SDKBase;
+
+[UdonBehaviourSyncMode(BehaviourSyncMode.NoVariableSync)]
+[AddComponentMenu("Artyom Scripting System/Timed Teleport")]
+[DisallowMultipleComponent]
+public class TimedTeleport : UdonSharpBehaviour
+{
+    [Header("Teleportation Settings")]
+    public Transform teleportTarget; // The transform to teleport the player to
+    public float delay = 1f; // The delay in seconds before teleportation
+    [Tooltip("Leaving the trigger before the delay is up cancels the teleport.")]
+    public bool cancelOnExit = false;
+
+    private bool pending; // A teleport is waiting: entering again doesn't queue another
+
+    public override void OnPlayerTriggerEnter(VRCPlayerApi player)
+    {
+        if (!player.isLocal || pending) return;
+        if (teleportTarget == null)
+        {
+            Debug.LogError("[AUMSS] Timed Teleport: Teleport Target isn't assigned.");
+            return;
+        }
+        pending = true;
+        SendCustomEventDelayedSeconds(nameof(TeleportPlayer), delay);
+    }
+
+    public override void OnPlayerTriggerExit(VRCPlayerApi player)
+    {
+        if (cancelOnExit && player.isLocal) pending = false;
+    }
+
+    public void TeleportPlayer()
+    {
+        if (!pending) return; // Cancelled by leaving the trigger
+        pending = false;
+        Networking.LocalPlayer.TeleportTo(teleportTarget.position,
+                                          teleportTarget.rotation,
+                                          VRC_SceneDescriptor.SpawnOrientation.Default,
+                                          false
+                                        );
+    }
+}