// LightControlLerp.cs using System.Collections; using System.Collections.Generic; using UnityEngine; using Simuspaces.Simulation; public class LightControlLerp : SimuScriptBase { public SceneObject ClickObject; public List ControlledLights = new List(); [Header("Interaction")] public bool highlightObject = true; public float interactDistance = 10f; [Header("Light Intensity")] public float onIntensity = 350f; public float offIntensity = 0f; [Header("Lerped Movement")] // How often the server sends a new rotation waypoint. // 0.1 = 10 commands per second instead of every frame. public float movementCommandIntervalSeconds = 0.1f; // Local interpolation duration for each waypoint. // Keep this equal to or slightly longer than the command interval. public float rotationLerpSeconds = 0.12f; // Complete movement cycles per second. // 0.25 = one complete sweep every 4 seconds. public float movementFrequencyHz = 0.25f; [Header("Pattern Changes")] public float patternSwitchSeconds = 8f; public float patternBlendSeconds = 2f; public float colorSwitchSeconds = 0.75f; [Header("Beam Home Direction")] // 0 tilt points straight down. // Keep max tilt below 89 to avoid rotation flips. public float centerPanDegrees = 0f; public float centerTiltFromDownDegrees = 25f; public float maxTiltFromDownDegrees = 70f; [Header("Movement Size")] public float leftRightDegrees = 55f; public float upDownDegrees = 25f; public float circleDegrees = 28f; [Header("Per-Light Spread")] public float perLightPanSpreadDegrees = 9f; public float perLightTiltSpreadDegrees = 3f; bool animating; Coroutine lightLoop; Coroutine startupRoutine; readonly List activeLights = new List(); class LightState { public SVLight Light; public int PreviousPattern; public int CurrentPattern; public float PatternChangedTime; public float NextPatternTime; public float NextColorTime; public float TimeOffset; public float PanBias; public float TiltBias; public Color CurrentColor; } public override void Start() { base.Start(); if (startupRoutine != null) StopCoroutine(startupRoutine); startupRoutine = StartCoroutine(DelayedStartup()); } IEnumerator DelayedStartup() { RegisterClickObject(null); yield return null; RegisterClickObject(null); yield return new WaitForSeconds(0.25f); RegisterClickObject(null); ResetAllLightsToHome(); Log( "[QA Light Lerp] Ready. Valid lights = " + CountValidLights()); startupRoutine = null; } public override void OnUserJoin(SVUserInfo user) { string username = user != null ? user.Username : null; RegisterClickObject(username); } void RegisterClickObject(string targetUsername) { if (ClickObject == null || ClickObject.IsNull) { Log( "[QA Light Lerp] Slot a SceneObject into " + "ClickObject first."); return; } MakeInteractable( ClickObject, OnClicked, highlightObject, targetUsername, true, interactDistance, "Toggle dance lights"); } void OnClicked(SVInteractionInfo info) { int validCount = CountValidLights(); Log( "[QA Light Lerp] Click received. Valid lights = " + validCount); if (validCount <= 0) { animating = false; StopLightLoop(); Log( "[QA Light Lerp] Add lights to ControlledLights."); return; } animating = !animating; if (animating) { StartDanceLights(); } else { StopLightLoop(); ResetAllLightsToHome(); } string username = info != null ? info.Username : ""; string objectName = info != null ? info.ObjectName : ""; Log( "[QA Light Lerp] " + username + " clicked " + objectName + " animating=" + animating); } void StartDanceLights() { BuildActiveLightStates(); for (int i = 0; i < activeLights.Count; i++) { LightState state = activeLights[i]; if (!IsValidState(state)) continue; SetLightEnabled(state.Light, true); SetLightIntensity( state.Light, onIntensity); SetLightColor( state.Light, state.CurrentColor); Quaternion home = GetFloorFacingWorldQuaternion( state.PanBias, state.TiltBias); // Immediate placement before beginning timed movement. SetLightRotationImmediate( state.Light, home); } StopLightLoop(); lightLoop = StartCoroutine(AnimateLights()); } IEnumerator AnimateLights() { while (animating) { float commandInterval = Mathf.Max( 0.02f, movementCommandIntervalSeconds); float lerpTime = Mathf.Max( commandInterval, rotationLerpSeconds); float now = Time.time; // Aim at the position the light should reach when this // interpolation finishes. This avoids trailing behind // the intended sine-wave motion. float targetSampleTime = now + lerpTime; for ( int i = activeLights.Count - 1; i >= 0; i--) { LightState state = activeLights[i]; if (!IsValidState(state)) { activeLights.RemoveAt(i); continue; } UpdatePattern(state, now); UpdateColor(state, now); Vector2 offset = GetBlendedSweepOffset( state, targetSampleTime); Quaternion targetRotation = GetFloorFacingWorldQuaternion( state.PanBias + offset.x, state.TiltBias + offset.y); // Sends one destination + duration command. // The server and every client perform the smooth // quaternion interpolation locally. LerpLightRotation( state.Light, targetRotation, lerpTime); } if (activeLights.Count <= 0) { animating = false; break; } yield return new WaitForSeconds( commandInterval); } lightLoop = null; } bool IsValidState(LightState state) { return state != null && state.Light != null && !state.Light.IsNull; } void UpdatePattern( LightState state, float now) { if ( patternSwitchSeconds <= 0f || now < state.NextPatternTime) { return; } state.PreviousPattern = state.CurrentPattern; state.CurrentPattern = (state.CurrentPattern + 1) % 4; state.PatternChangedTime = now; state.NextPatternTime = now + Mathf.Max( 0.25f, patternSwitchSeconds); } void UpdateColor( LightState state, float now) { if (now < state.NextColorTime) return; state.CurrentColor = RandomNeonColor(); state.NextColorTime = now + Mathf.Max( 0.05f, colorSwitchSeconds); SetLightColor( state.Light, state.CurrentColor); } Vector2 GetBlendedSweepOffset( LightState state, float sampleTime) { float motionTime = sampleTime + state.TimeOffset; Vector2 previous = GetFloorSweepOffset( state.PreviousPattern, motionTime); Vector2 current = GetFloorSweepOffset( state.CurrentPattern, motionTime); if ( state.PreviousPattern == state.CurrentPattern) { return current; } float blendDuration = Mathf.Max( 0.01f, patternBlendSeconds); float blend = Mathf.Clamp01( (sampleTime - state.PatternChangedTime) / blendDuration); // Smootherstep. blend = blend * blend * blend * (blend * (blend * 6f - 15f) + 10f); return Vector2.Lerp( previous, current, blend); } void StopLightLoop() { if (lightLoop == null) return; StopCoroutine(lightLoop); lightLoop = null; } void BuildActiveLightStates() { activeLights.Clear(); int validCount = CountValidLights(); int validIndex = 0; float centerIndex = (validCount - 1) * 0.5f; float now = Time.time; if (ControlledLights == null) return; for (int i = 0; i < ControlledLights.Count; i++) { SVLight light = ControlledLights[i]; if (light == null || light.IsNull) continue; float spreadIndex = validIndex - centerIndex; int startingPattern = validIndex % 4; LightState state = new LightState(); state.Light = light; state.PreviousPattern = startingPattern; state.CurrentPattern = startingPattern; state.PatternChangedTime = now - Mathf.Max( 0.01f, patternBlendSeconds); state.NextPatternTime = now + Mathf.Max( 0.25f, patternSwitchSeconds) + validIndex * 0.35f; state.NextColorTime = now + Random.Range( 0f, Mathf.Max( 0.05f, colorSwitchSeconds)); state.TimeOffset = validIndex * 1.7f; state.PanBias = spreadIndex * perLightPanSpreadDegrees; state.TiltBias = ((validIndex % 3) - 1) * perLightTiltSpreadDegrees; state.CurrentColor = RandomNeonColor(); activeLights.Add(state); validIndex++; } } void ResetAllLightsToHome() { if (ControlledLights == null) return; Quaternion home = GetFloorFacingWorldQuaternion( 0f, 0f); for (int i = 0; i < ControlledLights.Count; i++) { SVLight light = ControlledLights[i]; if (light == null || light.IsNull) continue; SetLightEnabled( light, true); SetLightIntensity( light, offIntensity); SetLightColor( light, Color.white); // An immediate SetLocalRotation also cancels any // active rotation lerp on this light. SetLightRotationImmediate( light, home); } } int CountValidLights() { int count = 0; if (ControlledLights == null) return 0; for (int i = 0; i < ControlledLights.Count; i++) { if ( ControlledLights[i] != null && !ControlledLights[i].IsNull) { count++; } } return count; } void LerpLightRotation( SVLight light, Quaternion rotation, float lerpTime) { if (light == null || light.IsNull) return; LerpLocalRotation( light, CleanRotation(rotation.eulerAngles), lerpTime); } void SetLightRotationImmediate( SVLight light, Quaternion rotation) { if (light == null || light.IsNull) return; SetLocalRotation( light, CleanRotation(rotation.eulerAngles)); } Quaternion GetFloorFacingWorldQuaternion( float panOffsetDegrees, float tiltOffsetDegrees) { float maxTilt = Mathf.Clamp( Mathf.Abs( maxTiltFromDownDegrees), 1f, 85f); float pan = centerPanDegrees + panOffsetDegrees; float tilt = Mathf.Clamp( centerTiltFromDownDegrees + tiltOffsetDegrees, -maxTilt, maxTilt); Vector3 beamDirection = Quaternion.AngleAxis( pan, Vector3.up) * Quaternion.AngleAxis( tilt, Vector3.right) * Vector3.down; if ( beamDirection.sqrMagnitude < 0.0001f) { beamDirection = Vector3.down; } return Quaternion.LookRotation( beamDirection.normalized, Vector3.forward); } Vector2 GetFloorSweepOffset( int pattern, float time) { float cycles = Mathf.Max( 0f, movementFrequencyHz); float phase = time * cycles * Mathf.PI * 2f; switch (pattern) { case 0: return new Vector2( Mathf.Sin(phase) * leftRightDegrees, Mathf.Sin( phase * 0.5f) * upDownDegrees * 0.2f); case 1: return new Vector2( Mathf.Sin( phase * 0.45f) * leftRightDegrees * 0.2f, Mathf.Sin(phase) * upDownDegrees); case 2: return new Vector2( Mathf.Sin(phase) * circleDegrees, Mathf.Cos(phase) * circleDegrees); case 3: return new Vector2( Mathf.Sin( phase * 1.1f) * leftRightDegrees, Mathf.Sin( phase * 0.8f + 1.2f) * upDownDegrees); } return Vector2.zero; } Color RandomNeonColor() { return Color.HSVToRGB( Random.value, 1f, 1f); } Vector3 CleanRotation(Vector3 rotation) { return new Vector3( CleanAngle(rotation.x), CleanAngle(rotation.y), CleanAngle(rotation.z)); } float CleanAngle(float angle) { if ( float.IsNaN(angle) || float.IsInfinity(angle)) { return 0f; } while (angle > 180f) angle -= 360f; while (angle < -180f) angle += 360f; return angle; } }