Step by Step Guide on How to Implement Device Input (Shake/Tilt) and Moving Objects in Unity

 

Step by Step Guide on How to Implement Device Input (Shake/Tilt) and Moving Objects in Unity

Want to make your Unity puzzles truly interactive, reacting to players' physical actions? This guide will show you how to implement device shake detection in Unity and how to make objects move with device tilt step by step. These advanced mechanics are key to creating engaging and "outside-the-box" challenges, reminiscent of popular mobile puzzle games.

Estimated Time: 60-90 minutes
Difficulty: Advanced Intermediate
Prerequisites: Your existing "Tricky Logic Puzzle" project, including the InteractableObject and GameManager scripts from previous steps.

Let's dive into integrating device-specific inputs!

Step 1: Implement Device Shake Detection

The first step by step we'll tackle is detecting when a player "shakes" their device. This will be handled primarily within the GameManager script.

Step-by-Step: Modify 

  1. Open GameManager.cs in your code editor.

  2. Add variables for shakeSoundshakeDetectionThresholdshakeDetectionTimeshakeTimer, and isShakingPuzzleActive.

  3. Implement a  within the Update() loop to monitor device acceleration.

  4. Add a new  to process the shake event.

  5. Replace the entire content of your GameManager.cs script with the updated code provided below.

Key Changes in 

  • : A new AudioClip for playing a sound when the device shakes.

  • : New serialized fields to control shake sensitivity (shakeDetectionThresholdshakeDetectionTime).

  •  Method: Now continuously calls CheckForShake() when a shake puzzle is active.

  • : Monitors Input.acceleration to determine if a shake has occurred.

  • : A new public method to manage the outcome of a successful shake.

  • : Updated to set the isShakingPuzzleActive flag based on the current puzzle's type.

  • : Added to the switch statement in HandleTapInteraction to ensure tapping a shake object is incorrect.

C#
using UnityEngine;
using TMPro;
using System.Collections;
using System.Linq;

public class GameManager : MonoBehaviour
{
    public static GameManager Instance { get; private set; }

    [Header("UI References")]
    [SerializeField] private TextMeshProUGUI questionText;
    [SerializeField] private TextMeshProUGUI feedbackText;
    [SerializeField] private TextMeshProUGUI hintText;
    [SerializeField] private GameObject hintPanel;
    [SerializeField] private InteractableObject[] allSceneInteractableObjects;
    [SerializeField] private Camera uiCamera; // NEW: Reference to the UI Camera for RectTransformUtility

    [Header("Audio References")]
    [SerializeField] private AudioSource audioSource;
    [SerializeField] private AudioClip correctSound;
    [SerializeField] private AudioClip incorrectSound;
    [SerializeField] private AudioClip tapSound;
    [SerializeField] private AudioClip shakeSound; // NEW: For shake interaction

    [Header("Game Settings")]
    [SerializeField] private float feedbackDisplayDuration = 2f;

    [Header("Device Input Settings")] // NEW
    [SerializeField] private float shakeDetectionThreshold = 2.0f; // Magnitude of acceleration to detect a shake
    [SerializeField] private float shakeDetectionTime = 0.5f; // How long acceleration must be above threshold
    private float shakeTimer;
    private bool isShakingPuzzleActive = false; // Flag for shake-based puzzles
    private bool isTiltingPuzzleActive = false; // NEW: Flag for tilt-based puzzles

    [Header("Puzzle Configuration")]
    [SerializeField] private PuzzleData[] allPuzzles;
    private int currentPuzzleIndex = 0;

    // For Multi-Stage Puzzles
    private int currentCorrectInteractions = 0;
    private int currentSequenceStep = 0;
    public bool IsDragging { get; set; } = false;

    private void Awake()
    {
        if (Instance != null && Instance != this)
        {
            Destroy(gameObject);
            return;
        }
        Instance = this;

        InitializeUI();
        LoadPuzzle(currentPuzzleIndex);
    }

    private void InitializeUI()
    {
        feedbackText?.gameObject.SetActive(false);
        if (feedbackText == null) Debug.LogError("Feedback Text is not assigned!");
        if (questionText == null) Debug.LogError("Question Text is not assigned!");
        hintPanel?.SetActive(false);
        if (hintText == null) Debug.LogError("Hint Text is not assigned!");
        if (audioSource == null)
        {
            audioSource = GetComponent<AudioSource>();
            if (audioSource == null) Debug.LogError("AudioSource not found!");
        }
        if (uiCamera == null) { uiCamera = Camera.main; if (uiCamera == null) { Debug.LogError("UI Camera is not assigned! Ensure your Main Camera is tagged 'MainCamera' or assigned here."); } }

        // Ensure Input.gyro is enabled for device input (gyro is often more precise than accelerometer for tilt, but acc. also works for shake).
        Input.gyro.enabled = true;
        Input.backButtonLeavesApp = true; // Standard Android back button behavior
    }

    private void Update() // NEW
    {
        if (!feedbackText.gameObject.activeSelf)
        {
            // Only check for shake if a shake-based puzzle is active
            if (isShakingPuzzleActive)
            {
                CheckForShake();
            }
            // Tilt logic is handled by InteractableObject.Update()
        }
        // Handle Android back button
        if (Input.GetKeyDown(KeyCode.Escape))
        {
            Application.Quit();
        }
    }

    private void CheckForShake() // NEW
    {
        Vector3 acceleration = Input.acceleration;
        float accelerationMagnitude = acceleration.sqrMagnitude; 

        if (accelerationMagnitude > shakeDetectionThreshold * shakeDetectionThreshold)
        {
            shakeTimer += Time.deltaTime;
            if (shakeTimer >= shakeDetectionTime)
            {
                Debug.Log("Device shaken!");
                HandleShakeInteraction(true); // Treat as a correct shake for now
                shakeTimer = 0f; // Reset timer
            }
        }
        else
        {
            shakeTimer = 0f; // Reset timer if acceleration drops below threshold
        }
    }

    public void HandleTapInteraction(InteractableObject tappedObject)
    {
        if (feedbackText.gameObject.activeSelf || IsDragging) return;
        PlaySound(tapSound);
        HideHint();

        bool isCorrect = false;
        PuzzleData currentPuzzle = allPuzzles[currentPuzzleIndex];

        switch (tappedObject.GetInteractionType())
        {
            case InteractableObjectType.Tap_SingleCorrect:
                isCorrect = currentPuzzle.interactableObjects.Contains(tappedObject) && !currentPuzzle.isMultiStagePuzzle;
                break;
            case InteractableObjectType.Tap_SequencePart:
                if (currentPuzzle.isMultiStagePuzzle && currentPuzzle.sequenceCorrectOrder.Length > currentSequenceStep &&
                    tappedObject == currentPuzzle.sequenceCorrectOrder[currentSequenceStep])
                {
                    isCorrect = true;
                    tappedObject.SetHighlightColor(Color.green);
                    currentSequenceStep++;
                }
                else { isCorrect = false; }
                break;
            case InteractableObjectType.Tap_FakeWrong:
                isCorrect = false;
                break;
            case InteractableObjectType.Drag_ToTarget:
            case InteractableObjectType.Swipe_OffScreen:
            case InteractableObjectType.NonInteractable:
            case InteractableObjectType.Shake_Device: // If a shake object is tapped, it's wrong
                isCorrect = false;
                break;
            case InteractableObjectType.Move_WithDeviceTilt: // Also wrong to tap a tilt object
                isCorrect = false;
                break;
        }

        ProcessInteractionResult(isCorrect, tappedObject);
    }

    public void HandleDragDropInteraction(InteractableObject draggedObject, InteractableObject targetObject, bool isCorrectDrop)
    {
        if (feedbackText.gameObject.activeSelf) return;
        PlaySound(tapSound); 
        HideHint();

        bool isCorrect = isCorrectDrop && draggedObject.GetInteractionType() == InteractableObjectType.Drag_ToTarget;
        ProcessInteractionResult(isCorrect, draggedObject);
    }

    public void HandleSwipeInteraction(InteractableObject swipedObject, bool isCorrectSwipe)
    {
        if (feedbackText.gameObject.activeSelf) return;
        PlaySound(tapSound);
        HideHint();

        bool isCorrect = isCorrectSwipe && swipedObject.GetInteractionType() == InteractableObjectType.Swipe_OffScreen;
        ProcessInteractionResult(isCorrect, swipedObject);
    }

    // NEW: Handle Shake Interaction
    public void HandleShakeInteraction(bool isCorrectShake)
    {
        if (feedbackText.gameObject.activeSelf) return;
        PlaySound(shakeSound); 
        HideHint();

        ProcessInteractionResult(isCorrectShake, null); // No specific interactable object for shake
    }

    // NEW: Handle Tilt Interaction
    public void HandleTiltInteraction(bool isCorrectTilt)
    {
        if (feedbackText.gameObject.activeSelf) return;
        PlaySound(correctSound); // Play correct sound directly
        HideHint();

        ProcessInteractionResult(isCorrectTilt, null); // No specific interacted object needed
    }

    private void ProcessInteractionResult(bool isCorrect, InteractableObject interactedObject = null)
    {
        if (isCorrect)
        {
            PlaySound(correctSound);
            ShowFeedback("CORRECT!", Color.green);
            currentCorrectInteractions++;

            if (currentCorrectInteractions >= allPuzzles[currentPuzzleIndex].totalCorrectInteractionsNeeded)
            { StartCoroutine(HideFeedbackAfterDelay(true)); }
            else { StartCoroutine(HideFeedbackAfterDelay(false)); }
        }
        else
        {
            PlaySound(incorrectSound);
            ShowFeedback("TRY AGAIN!", Color.red);
            interactedObject?.SetHighlightColor(Color.red, true);
            StartCoroutine(ResetPuzzleAfterDelay());
        }
    }

    private void ShowFeedback(string message, Color color)
    {
        if (feedbackText != null)
        {
            feedbackText.gameObject.SetActive(true);
            feedbackText.text = message;
            feedbackText.color = color;
        }
    }

    private IEnumerator HideFeedbackAfterDelay(bool loadNext)
    {
        yield return new WaitForSeconds(feedbackDisplayDuration);
        feedbackText?.gameObject.SetActive(false);

        if (loadNext)
        {
            currentPuzzleIndex++;
            if (currentPuzzleIndex < allPuzzles.Length) { LoadPuzzle(currentPuzzleIndex); }
            else
            {
                Debug.Log("All puzzles completed!");
                ShowFeedback("Game Completed!", Color.yellow);
            }
        }
        ResetAllInteractables();
    }

    private IEnumerator ResetPuzzleAfterDelay()
    {
        yield return new WaitForSeconds(feedbackDisplayDuration);
        feedbackText?.gameObject.SetActive(false);
        ResetAllInteractables();
    }

    private void ResetAllInteractables()
    {
        if (allSceneInteractableObjects != null)
        {
            foreach (InteractableObject obj in allSceneInteractableObjects) { if (obj != null) { obj.ResetVisuals(); } }
        }
        currentCorrectInteractions = 0;
        currentSequenceStep = 0;
        IsDragging = false;
    }

    private void LoadPuzzle(int index)
    {
        if (index < 0 || index >= allPuzzles.Length) { Debug.LogError("Attempted to load invalid puzzle index: " + index); return; }

        PuzzleData puzzleToLoad = allPuzzles[index];

        currentCorrectInteractions = 0;
        currentSequenceStep = 0;
        IsDragging = false;
        isShakingPuzzleActive = false; // Reset flags
        isTiltingPuzzleActive = false;

        // Set flags based on puzzle interactable types
        foreach (InteractableObject obj in puzzleToLoad.interactableObjects)
        {
            if (obj.GetInteractionType() == InteractableObjectType.Shake_Device) { isShakingPuzzleActive = true; }
            if (obj.GetInteractionType() == InteractableObjectType.Move_WithDeviceTilt) { isTiltingPuzzleActive = true; }
        }

        foreach (InteractableObject obj in allSceneInteractableObjects) { if (obj != null) { obj.gameObject.SetActive(false); } }
        questionText.text = puzzleToLoad.question;
        foreach (InteractableObject obj in puzzleToLoad.interactableObjects) { if (obj != null) { obj.gameObject.SetActive(true); obj.ResetVisuals(); } }
        hintText.text = puzzleToLoad.hint;
        HideHint();

        Debug.Log($"Loaded Puzzle {index + 1}: {puzzleToLoad.question}");
    }

    public void ShowHint() { if (hintPanel != null && allPuzzles != null && allPuzzles.Length > currentPuzzleIndex && allPuzzles[currentPuzzleIndex].hint != "") { hintPanel.SetActive(true); } }
    public void HideHint() { hintPanel?.SetActive(false); }
    private void PlaySound(AudioClip clip) { if (audioSource != null && clip != null) { audioSource.PlayOneShot(clip); } }

    public Camera GetUICamera() { return uiCamera; } // NEW: Public getter for UI Camera
}
  1. Save the GameManager.cs script.

Step-by-Step: Add Shake Sound in Unity Editor

  1. In your _Audio folder in the Project panel, drag a suitable "shake" or "rattle" sound effect. If you don't have one, you can use a placeholder for now.

  2. Select _GameManager in the Hierarchy.

  3. In the Inspector, locate the GameManager (Script) component.

  4. Drag your new audio clip into the Shake Sound slot.

Step 2: Implement Object Movement by Tilting

Next, we'll enable specific objects to move in response to the device's tilt. This requires changes to InteractableObject and a new helper script.

Step-by-Step: Modify 

  1. Open InteractableObject.cs in your code editor.

  2. Add new  to the enum.

  3. Implement an  that calls MoveWithTilt() if the object's interaction type is Move_WithDeviceTilt.

  4. Add  like tiltSpeed and moveBounds to control the tilt behavior.

  5. Create a  to read Input.acceleration and apply movement within defined bounds.

  6. Add a  for external triggers to signal successful interaction.

  7. Replace the entire content of your InteractableObject.cs script with the updated code below.

Key Changes in 

  •  and  New enum values for these interaction types.

  •  Header: New serialized fields (tiltSpeedmoveBoundshasReachedTargetArea) for tilt-controlled objects.

  •  Method: Added to specifically handle Move_WithDeviceTilt objects.

  • : Uses Input.acceleration to calculate movement and clamps the object's position within moveBounds using RectTransformUtility.

  • : A public method that GameManager (or another script) will call when a tilt-controlled object successfully completes its goal (e.g., enters a target area).

C#
using UnityEngine;
using UnityEngine.UI;
using UnityEngine.EventSystems;
using DG.Tweening;

public enum InteractableObjectType
{
    Tap_SingleCorrect,
    Drag_ToTarget,
    Tap_SequencePart,
    Tap_FakeWrong,
    Swipe_OffScreen,
    Shake_Device,        // NEW: Puzzle solved by shaking the device
    Move_WithDeviceTilt, // NEW: Object moves with device tilt
    NonInteractable
}

public class InteractableObject : MonoBehaviour, IPointerClickHandler, IBeginDragHandler, IDragHandler, IEndDragHandler
{
    [Header("Interaction Settings")]
    [SerializeField] private InteractableObjectType interactionType = InteractableObjectType.Tap_SingleCorrect;
    [SerializeField] private InteractableObject correctDropTarget; // Only relevant for Drag_ToTarget

    [Header("Sequence Specifics (for Tap_SequencePart)")]
    public int sequenceOrder = -1;

    [Header("Swipe Specifics (for Swipe_OffScreen)")]
    [SerializeField] private float swipeThreshold = 300f;
    [SerializeField] private float swipeDuration = 0.3f;
    [SerializeField] private Vector2 swipeDirection = new Vector2(1, 0);
    public GameObject objectToRevealOnSwipe;

    [Header("Tilt Specifics (for Move_WithDeviceTilt)")] // NEW
    [SerializeField] private float tiltSpeed = 100f; // How fast the object moves with tilt
    [SerializeField] private RectTransform moveBounds; // NEW: The area this object can move within
    [SerializeField] private bool hasReachedTargetArea = false; // NEW: If this object needs to reach a certain area

    [Header("Animation Settings")]
    [SerializeField] private float wiggleDuration = 0.2f;
    [SerializeField] private float wiggleStrength = 10f;
    [SerializeField] private int wiggleVibrato = 10;
    [SerializeField] private float wiggleElasticity = 1f;

    // Internal references
    private Image imageComponent;
    private Color initialColor;
    private Vector3 initialPosition;
    private Quaternion initialRotation;
    private RectTransform rectTransform;
    private Vector3 dragStartPos;

    private void Awake()
    {
        imageComponent = GetComponent<Image>();
        if (imageComponent != null) { initialColor = imageComponent.color; }
        rectTransform = GetComponent<RectTransform>();
        initialPosition = rectTransform.position;
        initialRotation = rectTransform.localRotation;
    }

    private void Update() // NEW: For tilt movement
    {
        if (interactionType == InteractableObjectType.Move_WithDeviceTilt && gameObject.activeSelf)
        {
            MoveWithTilt();
        }
    }

    private void MoveWithTilt() // NEW
    {
        Vector3 acceleration = Input.acceleration; // Raw accelerometer data

        // Adjust acceleration based on typical portrait screen orientation.
        // Assuming X maps to device X, Y maps to device Y (which is often inverted from a screen perspective).
        float moveX = acceleration.x * tiltSpeed * Time.deltaTime;
        float moveY = acceleration.y * tiltSpeed * Time.deltaTime; 

        Vector3 newPosition = rectTransform.position + new Vector3(moveX, moveY, 0);

        // Apply movement within defined bounds if moveBounds is set
        if (moveBounds != null)
        {
            // Convert screen position to canvas-relative position for bounds checking
            Vector2 localPoint;
            RectTransformUtility.ScreenPointToLocalPointInRectangle(
                moveBounds, newPosition, GameManager.Instance.GetUICamera(), out localPoint
            );
            
            // Keep within bounds
            localPoint.x = Mathf.Clamp(localPoint.x, moveBounds.rect.xMin, moveBounds.rect.xMax);
            localPoint.y = Mathf.Clamp(localPoint.y, moveBounds.rect.yMin, moveBounds.rect.yMax);

            // Convert back to screen position
            newPosition = RectTransformUtility.LocalPointToScreenPoint(GameManager.Instance.GetUICamera(), localPoint, moveBounds);
        }
        rectTransform.position = newPosition;

        // Note: Checking if target area reached is handled by TiltTargetTrigger.
        // The 'hasReachedTargetArea' flag here is used internally to prevent multiple reports.
    }

    public void OnPointerClick(PointerEventData eventData)
    {
        if (interactionType == InteractableObjectType.NonInteractable || 
            interactionType == InteractableObjectType.Shake_Device ||
            interactionType == InteractableObjectType.Move_WithDeviceTilt) return;
        if (GameManager.Instance.IsDragging) return;

        Debug.Log(gameObject.name + " was tapped!");
        GameManager.Instance.HandleTapInteraction(this);
    }

    public void OnBeginDrag(PointerEventData eventData)
    {
        if (interactionType != InteractableObjectType.Drag_ToTarget && interactionType != InteractableObjectType.Swipe_OffScreen) return;

        Debug.Log(gameObject.name + " began dragging.");
        transform.SetAsLastSibling();
        GameManager.Instance.IsDragging = true;
        dragStartPos = rectTransform.position;
    }

    public void OnDrag(PointerEventData eventData)
    {
        if (interactionType != InteractableObjectType.Drag_ToTarget && interactionType != InteractableObjectType.Swipe_OffScreen) return;

        rectTransform.position += (Vector3)eventData.delta;
    }

    public void OnEndDrag(PointerEventData eventData)
    {
        if (interactionType != InteractableObjectType.Drag_ToTarget && interactionType != InteractableObjectType.Swipe_OffScreen) return;

        Debug.Log(gameObject.name + " ended dragging.");
        imageComponent.color = initialColor;
        GameManager.Instance.IsDragging = false;

        bool isCorrectInteraction = false;
        
        if (interactionType == InteractableObjectType.Drag_ToTarget)
        {
            if (correctDropTarget != null)
            {
                GameObject droppedOnObject = eventData.pointerEnter;
                if (droppedOnObject != null && droppedOnObject.GetComponent<InteractableObject>() == correctDropTarget)
                { isCorrectInteraction = true; }
            }
            GameManager.Instance.HandleDragDropInteraction(this, correctDropTarget, isCorrectInteraction);
            if (!isCorrectInteraction) ResetVisuals(); // Only reset if incorrect
        }
        else if (interactionType == InteractableObjectType.Swipe_OffScreen)
        {
            Vector3 dragDistance = rectTransform.position - dragStartPos;
            Vector3 projectedDistance = Vector3.Project(dragDistance, swipeDirection.normalized);

            if (projectedDistance.magnitude >= swipeThreshold && Vector3.Dot(dragDistance.normalized, swipeDirection.normalized) > 0.5f)
            {
                isCorrectInteraction = true;
                rectTransform.DOMove(rectTransform.position + (Vector3)swipeDirection.normalized * 1000f, swipeDuration)
                             .SetEase(Ease.OutQuad)
                             .OnComplete(() =>
                             {
                                 gameObject.SetActive(false);
                                 if (objectToRevealOnSwipe != null) { objectToRevealOnSwipe.SetActive(true); }
                             });
            }
            else { isCorrectInteraction = false; }
            GameManager.Instance.HandleSwipeInteraction(this, isCorrectInteraction);
            if (!isCorrectInteraction) { ResetVisuals(); }
        }
    }

    public void ResetVisuals()
    {
        if (imageComponent != null) { imageComponent.color = initialColor; }
        if (rectTransform != null)
        {
            rectTransform.DOKill(true);
            rectTransform.position = initialPosition;
            rectTransform.localRotation = initialRotation;
        }
        hasReachedTargetArea = false; // Reset for tilt puzzles
    }

    public void SetHighlightColor(Color color, bool doWiggle = false)
    {
        if (imageComponent != null) { imageComponent.color = color; }
        if (doWiggle) { Wiggle(); }
    }

    private void Wiggle()
    {
        rectTransform.DOKill();
        rectTransform.DOPunchRotation(
            new Vector3(0f, 0f, wiggleStrength), wiggleDuration, wiggleVibrato, wiggleElasticity
        ).SetEase(Ease.OutElastic).OnComplete(() => rectTransform.localRotation = initialRotation);
    }

    public InteractableObjectType GetInteractionType() => interactionType;
    public int GetSequenceOrder() => sequenceOrder;

    // NEW: Method for Tilt puzzles to report success (e.g., if it hits an invisible trigger)
    public void ReportTiltSuccess()
    {
        if (interactionType == InteractableObjectType.Move_WithDeviceTilt && !hasReachedTargetArea)
        {
            hasReachedTargetArea = true; // Prevents multiple calls
            GameManager.Instance.HandleTiltInteraction(true);
        }
    }
}
  1. Save the InteractableObject.cs script.

Step 3: Final GameManager Updates for Tilt Interaction

The GameManager needs a specific way to be informed when a tilt-controlled object reaches its goal. We'll also add a helper for the UI camera.

Step-by-Step: Update 

  1. Open GameManager.cs again.

  2. Add a new  This method will be called by the TiltTargetTrigger script (created below) when the tilt object enters its target.

  3. Add a  and a GetUICamera() helper method. This is crucial for RectTransformUtility to correctly calculate bounds.

  4. Update the  to activate isTiltingPuzzleActive for relevant puzzles.

  5. Replace the entire content of your GameManager.cs script with this final updated code for this step.

C#
using UnityEngine;
using TMPro;
using System.Collections;
using System.Linq;

public class GameManager : MonoBehaviour
{
    public static GameManager Instance { get; private set; }

    [Header("UI References")]
    [SerializeField] private TextMeshProUGUI questionText;
    [SerializeField] private TextMeshProUGUI feedbackText;
    [SerializeField] private TextMeshProUGUI hintText;
    [SerializeField] private GameObject hintPanel;
    [SerializeField] private InteractableObject[] allSceneInteractableObjects;
    [SerializeField] private Camera uiCamera; // NEW: Reference to the UI Camera for RectTransformUtility

    [Header("Audio References")]
    [SerializeField] private AudioSource audioSource;
    [SerializeField] private AudioClip correctSound;
    [SerializeField] private AudioClip incorrectSound;
    [SerializeField] private AudioClip tapSound;
    [SerializeField] private AudioClip shakeSound;

    [Header("Game Settings")]
    [SerializeField] private float feedbackDisplayDuration = 2f;

    [Header("Device Input Settings")]
    [SerializeField] private float shakeDetectionThreshold = 2.0f;
    [SerializeField] private float shakeDetectionTime = 0.5f;
    private float shakeTimer;
    private bool isShakingPuzzleActive = false;
    private bool isTiltingPuzzleActive = false; // NEW: Flag for tilt-based puzzles

    [Header("Puzzle Configuration")]
    [SerializeField] private PuzzleData[] allPuzzles;
    private int currentPuzzleIndex = 0;

    // For Multi-Stage Puzzles
    private int currentCorrectInteractions = 0;
    private int currentSequenceStep = 0;
    public bool IsDragging { get; set; } = false;

    private void Awake()
    {
        if (Instance != null && Instance != this) { Destroy(gameObject); return; }
        Instance = this;

        InitializeUI();
        LoadPuzzle(currentPuzzleIndex);
    }

    private void InitializeUI()
    {
        feedbackText?.gameObject.SetActive(false);
        if (feedbackText == null) Debug.LogError("Feedback Text is not assigned!");
        if (questionText == null) Debug.LogError("Question Text is not assigned!");
        hintPanel?.SetActive(false);
        if (hintText == null) Debug.LogError("Hint Text is not assigned!");
        if (audioSource == null) { audioSource = GetComponent<AudioSource>(); if (audioSource == null) { Debug.LogError("AudioSource not found!"); } }
        // Ensure UI Camera is assigned, defaulting to Camera.main if not
        if (uiCamera == null) { uiCamera = Camera.main; if (uiCamera == null) { Debug.LogError("UI Camera is not assigned! Ensure your Main Camera is tagged 'MainCamera' or assigned here."); } }

        Input.gyro.enabled = true; // Enable gyroscope for more precise tilt
        Input.backButtonLeavesApp = true; // Standard Android back button behavior
    }

    private void Update()
    {
        if (!feedbackText.gameObject.activeSelf)
        {
            if (isShakingPuzzleActive) { CheckForShake(); }
            // Tilt logic is handled by InteractableObject.Update()
        }
        // Handle Android back button
        if (Input.GetKeyDown(KeyCode.Escape))
        {
            Application.Quit();
        }
    }

    private void CheckForShake()
    {
        float accelerationMagnitude = Input.acceleration.sqrMagnitude;
        if (accelerationMagnitude > shakeDetectionThreshold * shakeDetectionThreshold)
        {
            shakeTimer += Time.deltaTime;
            if (shakeTimer >= shakeDetectionTime)
            {
                Debug.Log("Device shaken!");
                HandleShakeInteraction(true);
                shakeTimer = 0f;
            }
        }
        else { shakeTimer = 0f; }
    }

    public void HandleTapInteraction(InteractableObject tappedObject)
    {
        if (feedbackText.gameObject.activeSelf || IsDragging) return;
        PlaySound(tapSound); HideHint();

        bool isCorrect = false;
        PuzzleData currentPuzzle = allPuzzles[currentPuzzleIndex];

        switch (tappedObject.GetInteractionType())
        {
            case InteractableObjectType.Tap_SingleCorrect:
                isCorrect = currentPuzzle.interactableObjects.Contains(tappedObject) && !currentPuzzle.isMultiStagePuzzle;
                break;
            case InteractableObjectType.Tap_SequencePart:
                if (currentPuzzle.isMultiStagePuzzle && currentPuzzle.sequenceCorrectOrder.Length > currentSequenceStep &&
                    tappedObject == currentPuzzle.sequenceCorrectOrder[currentSequenceStep])
                { isCorrect = true; tappedObject.SetHighlightColor(Color.green); currentSequenceStep++; }
                else { isCorrect = false; }
                break;
            case InteractableObjectType.Tap_FakeWrong:
                isCorrect = false;
                break;
            default: // Tapping other types is generally incorrect
                isCorrect = false;
                break;
        }
        ProcessInteractionResult(isCorrect, tappedObject);
    }

    public void HandleDragDropInteraction(InteractableObject draggedObject, InteractableObject targetObject, bool isCorrectDrop)
    {
        if (feedbackText.gameObject.activeSelf) return;
        PlaySound(tapSound); HideHint();

        bool isCorrect = isCorrectDrop && draggedObject.GetInteractionType() == InteractableObjectType.Drag_ToTarget;
        ProcessInteractionResult(isCorrect, draggedObject);
    }

    public void HandleSwipeInteraction(InteractableObject swipedObject, bool isCorrectSwipe)
    {
        if (feedbackText.gameObject.activeSelf) return;
        PlaySound(tapSound); HideHint();

        bool isCorrect = isCorrectSwipe && swipedObject.GetInteractionType() == InteractableObjectType.Swipe_OffScreen;
        ProcessInteractionResult(isCorrect, swipedObject);
    }

    public void HandleShakeInteraction(bool isCorrectShake)
    {
        if (feedbackText.gameObject.activeSelf) return;
        PlaySound(shakeSound); HideHint();

        ProcessInteractionResult(isCorrectShake, null);
    }

    // NEW: Handle Tilt Interaction
    public void HandleTiltInteraction(bool isCorrectTilt)
    {
        if (feedbackText.gameObject.activeSelf) return;
        PlaySound(correctSound); // Play correct sound directly
        HideHint();

        ProcessInteractionResult(isCorrectTilt, null); // No specific interacted object needed
    }

    private void ProcessInteractionResult(bool isCorrect, InteractableObject interactedObject = null)
    {
        if (isCorrect)
        {
            PlaySound(correctSound);
            ShowFeedback("CORRECT!", Color.green);
            currentCorrectInteractions++;

            if (currentCorrectInteractions >= allPuzzles[currentPuzzleIndex].totalCorrectInteractionsNeeded)
            { StartCoroutine(HideFeedbackAfterDelay(true)); }
            else { StartCoroutine(HideFeedbackAfterDelay(false)); }
        }
        else
        {
            PlaySound(incorrectSound);
            ShowFeedback("TRY AGAIN!", Color.red);
            interactedObject?.SetHighlightColor(Color.red, true);
            StartCoroutine(ResetPuzzleAfterDelay());
        }
    }

    private void ShowFeedback(string message, Color color)
    {
        if (feedbackText != null) { feedbackText.gameObject.SetActive(true); feedbackText.text = message; feedbackText.color = color; }
    }

    private IEnumerator HideFeedbackAfterDelay(bool loadNext)
    {
        yield return new WaitForSeconds(feedbackDisplayDuration);
        if (feedbackText != null) { feedbackText.gameObject.SetActive(false); }

        if (loadNext)
        {
            currentPuzzleIndex++;
            if (currentPuzzleIndex < allPuzzles.Length) { LoadPuzzle(currentPuzzleIndex); }
            else
            { Debug.Log("All puzzles completed!"); ShowFeedback("Game Completed!", Color.yellow); }
        }
        ResetAllInteractables();
    }

    private IEnumerator ResetPuzzleAfterDelay()
    {
        yield return new WaitForSeconds(feedbackDisplayDuration);
        if (feedbackText != null) { feedbackText.gameObject.SetActive(false); }
        ResetAllInteractables();
    }

    private void ResetAllInteractables()
    {
        if (allSceneInteractableObjects != null)
        { foreach (InteractableObject obj in allSceneInteractableObjects) { if (obj != null) { obj.ResetVisuals(); } } }
        currentCorrectInteractions = 0;
        currentSequenceStep = 0;
        IsDragging = false;
    }

    private void LoadPuzzle(int index)
    {
        if (index < 0 || index >= allPuzzles.Length) { Debug.LogError("Attempted to load invalid puzzle index: " + index); return; }

        PuzzleData puzzleToLoad = allPuzzles[index];

        currentCorrectInteractions = 0;
        currentSequenceStep = 0;
        IsDragging = false;
        isShakingPuzzleActive = false; // Reset flags
        isTiltingPuzzleActive = false;

        // Set flags based on puzzle interactable types
        foreach (InteractableObject obj in puzzleToLoad.interactableObjects)
        {
            if (obj.GetInteractionType() == InteractableObjectType.Shake_Device) { isShakingPuzzleActive = true; }
            if (obj.GetInteractionType() == InteractableObjectType.Move_WithDeviceTilt) { isTiltingPuzzleActive = true; }
        }

        foreach (InteractableObject obj in allSceneInteractableObjects) { if (obj != null) { obj.gameObject.SetActive(false); } }
        questionText.text = puzzleToLoad.question;
        foreach (InteractableObject obj in puzzleToLoad.interactableObjects) { if (obj != null) { obj.gameObject.SetActive(true); obj.ResetVisuals(); } }
        hintText.text = puzzleToLoad.hint;
        HideHint();

        Debug.Log($"Loaded Puzzle {index + 1}: {puzzleToLoad.question}");
    }

    public void ShowHint() { if (hintPanel != null && allPuzzles != null && allPuzzles.Length > currentPuzzleIndex && allPuzzles[currentPuzzleIndex].hint != "") { hintPanel.SetActive(true); } }
    public void HideHint() { hintPanel?.SetActive(false); }
    private void PlaySound(AudioClip clip) { if (audioSource != null && clip != null) { audioSource.PlayOneShot(clip); } }

    public Camera GetUICamera() { return uiCamera; } // NEW: Public getter for UI Camera
}
  1. Save the GameManager.cs script.

Step 4: Create New Objects and Configure Puzzles in Unity

This step-by-step guide will walk you through setting up two new puzzles in Unity: one for shaking the device and another for tilting an object into a target area.

A. Step-by-Step: Create Shake Puzzle Marker (for Puzzle 6)

This empty GameObject will act as a non-visible marker for a shake-based puzzle.

  1. In the Hierarchy, right-click Canvas.

  2. Select Create Empty. Rename it ShakeMarker.

  3. Add an InteractableObject component to ShakeMarker.

  4. In the Inspector, set its Interaction Type to Shake_Device.

    • (Optional: If Unity added an Image component, you can deactivate or remove it as this object won't be visible.)

  5. Crucially: Uncheck the checkbox next to ShakeMarker in the Hierarchy to make it initially inactive (hidden).

B. Step-by-Step: Create Tiltable Object and Bounds (for Puzzle 7)

This involves creating the ball, its movement area, and the target hole.

Tiltable Object:

  1. Right-click Canvas in the Hierarchy. Select UI > Image. Rename it TiltBall.

  2. In the Inspector, set its properties:

    • Rect Transform: Pos X: 0, Pos Y: 0, Width: 100, Height: 100

    • Image (Script): Color: Yellow

  3. Add an InteractableObject component.

    • Interaction Type: Move_WithDeviceTilt

    • Tilt Speed: 200 (You'll likely fine-tune this value during testing.)

    • Move Bounds: Leave empty for now; we'll assign the TiltAreaBounds here shortly.

    • Has Reached Target Area: Leave unchecked.

Movement Bounds (Visible for setup, then can be invisible):

  1. Right-click Canvas. Select UI > Image. Rename it TiltAreaBounds.

  2. In the Inspector, set its properties:

    • Rect Transform: Pos X: 0, Pos Y: 0, Width: 800, Height: 1500 (Adjust these dimensions to fit your desired screen layout and "play area.")

    • Image (Script): Color: A light, semi-transparent color (e.g., light gray with Alpha 50), so you can visualize the play area.

  3. Right-click on TiltAreaBounds in the Hierarchy. Select UI > Image. Rename it TargetHole.

  4. In the Inspector, set its properties (relative to TiltAreaBounds):

    • Rect Transform: Pos X: 0, Pos Y: 600 (adjust this Y position so it's clearly within TiltAreaBounds and acts as a target, e.g., towards the top), Width: 120, Height: 120

    • Image (Script): Color: Red

  5. Add a Box Collider 2D component to TargetHole.

    • Check the Is Trigger checkbox.

  6. Add a new C# script to TargetHole. Right-click TargetHole in the Inspector, select Add Component, then type TiltTargetTrigger and select New Script. Create and Add.

Step-by-Step: Create 

  1. In your _Scripts folder, find the newly created TiltTargetTrigger.cs script.

  2. Open TiltTargetTrigger.cs and replace its content with the following code:

C#
using UnityEngine;

public class TiltTargetTrigger : MonoBehaviour
{
    private void OnTriggerEnter2D(Collider2D other)
    {
        // Check if the entering collider belongs to the TiltBall
        InteractableObject tiltObject = other.GetComponent<InteractableObject>();
        if (tiltObject != null && tiltObject.GetInteractionType() == InteractableObjectType.Move_WithDeviceTilt)
        {
            Debug.Log("TiltBall entered target hole!");
            tiltObject.ReportTiltSuccess(); // Inform the TiltBall it hit the target
        }
    }

    private void Awake()
    {
        // Ensure a Rigidbody2D is present on this object for trigger detection to work
        // It should be kinematic to prevent it from being affected by physics.
        if (GetComponent<Rigidbody2D>() == null)
        {
            Rigidbody2D rb = gameObject.AddComponent<Rigidbody2D>();
            rb.isKinematic = true; 
            rb.gravityScale = 0;   
        }
    }
}
  1. Save the TiltTargetTrigger.cs script.

Step-by-Step: Assign 

  1. Select TiltBall in the Hierarchy.

  2. In its InteractableObject component, drag TiltAreaBounds (from the Hierarchy) into the Move Bounds slot.

Crucially: Uncheck the checkboxes next to TiltBallTiltAreaBounds, and TargetHole in the Hierarchy to hide them initially.

C. Step-by-Step: Update InteractableObject Settings for Existing Objects (Review)

Double-check that all your existing objects have the correct Interaction Type assigned:

  • SquareObjectCircleObjectTap_SingleCorrect

  • DragMeObjectDrag_ToTarget

  • DropTargetObjectNonInteractable

  • Seq1_SquareSeq2_CircleSeq3_TriangleTap_SequencePart (and correct sequence order)

  • FakeWrongButtonTap_FakeWrong

  • TrueButton (from Puzzle 4): Tap_SingleCorrect

  • SwipeAwayBlockSwipe_OffScreen

  • HiddenButtonTap_SingleCorrect

D. Step-by-Step: Create New PuzzleData Assets

We need to define the new puzzles (Puzzle 6 and Puzzle 7).

Puzzle 6: Shake Device

  1. In your _Scripts folder, right-click Create > TrickyLogicPuzzle > Puzzle Data.

  2. Name it Puzzle6_ShakeDevice.

  3. In the Inspector, configure Puzzle6_ShakeDevice:

    • Puzzle Name: Shake It Off

    • Question: Shake your device!

    • Hint: Sometimes you just need to clear your head.

    • Is Multi Stage Puzzle: UNCHECKED (unless you want a shake then another action).

    • Total Correct Interactions Needed: 1

    • Interactable Objects: Set Size to 1. Drag ShakeMarker into Element 0.

Puzzle 7: Tilt to Target

  1. In your _Scripts folder, right-click **Create > TrickyLogicPuzzle > Puzzle Data`.

  2. Name it Puzzle7_TiltToTarget.

  3. In the Inspector, configure Puzzle7_TiltToTarget:

    • Puzzle Name: Guide the Ball

    • Question: Guide the yellow ball to the red hole!

    • Hint: Move your device carefully.

    • Is Multi Stage Puzzle: UNCHECKED (again, unless you want tilt then another action).

    • Total Correct Interactions Needed: 1

    • Interactable Objects: Set Size to 3. Drag TiltBallTiltAreaBounds, and TargetHole into the elements.

E. Step-by-Step: Update _GameManager's References

The _GameManager needs to be aware of the new objects and puzzles.

  1. Select the _GameManager GameObject in the Hierarchy.

  2. In the Inspector, under the GameManager (Script) component:

    • All Scene Interactable Objects:

      • Set Size to 14 (add ShakeMarkerTiltBallTiltAreaBoundsTargetHole to your existing 10).

      • Drag ALL 14 GameObjects from the Hierarchy into their respective slots.

    • UI Camera: Drag your Main Camera from the Hierarchy into this slot.

    • All Puzzles Array:

      • Set Size to 7.

      • Drag your new Puzzle6_ShakeDevice into Element 5.

      • Drag your new Puzzle7_TiltToTarget into Element 6.

F. Step-by-Step: Setup Canvas for Physics

For UI elements to interact with 2D colliders (like our TargetHole), the Canvas needs a special component.

  1. Select your Canvas object in the Hierarchy.

  2. In the Inspector, click Add Component and search for Physics 2D Raycaster. Add it.

  3. Ensure your EventSystem object (usually automatically created by Unity) in the Hierarchy has both Standalone Input Module and Touch Input Module components.

G. Step-by-Step: Set Main Camera Tag

The GameManager (and RectTransformUtility) relies on the MainCamera tag.

  1. Select your Main Camera object in the Hierarchy.

  2. In its Inspector, go to the Tag dropdown at the top.

  3. Select MainCamera. If this tag doesn't exist, select Add Tag..., create a new tag named MainCamera, then re-select your camera and assign it.

  4. Save Your Scene: File > Save (or Ctrl+S / Cmd+S).

Step 5: Test the New Puzzles!

Now for the exciting part: seeing your device inputs in action!

  1. Run the Game: Click the Play button in Unity.

  2. Play through Puzzles 1-5 to reach Puzzle 6.

    • Puzzle 6 (Shake Device):

      • Question: "Shake your device!"

      • How to test shake detection step by step:

        • If deployed to a physical Android/iOS device: Vigorously shake your device.

        • If using Unity Remote in the Editor: Connect your device via USB, ensure Unity Remote is configured (Edit > Project Settings > Editor > Unity Remote), and shake the physical device.

        • In Editor (without Unity Remote/simulator): Input.acceleration will generally return default values. Robust testing requires a device or a simulator with accelerometer input.

      • You should hear the assigned shake sound, then "CORRECT!" feedback, and the puzzle will advance.

    • Puzzle 7 (Tilt to Target):

      • Question: "Guide the yellow ball to the red hole!"

      • You should see the TiltAreaBounds, the TiltBall (yellow), and the TargetHole (red).

      • How to move objects with device tilt step by step:

        • If deployed to a physical Android/iOS device or using Unity Remote: Tilt your physical device to move the yellow TiltBall around the screen.

        • Guide the  so it physically overlaps (enters the trigger of) the TargetHole.

      • Once the TiltBall enters the TargetHole, "CORRECT!" should appear, and the game will complete.

Important Notes for Device Input:

  • Unity Remote: For detailed instructions on how to set up Unity Remote for device input testing step by step, refer to Unity's official documentation. It's essential for testing accelerometer/gyroscope input directly from your device within the Unity editor.

  • Actual Builds: Device input works most reliably on actual Android/iOS builds. In the editor without Unity Remote, Input.acceleration often returns static values.

  • Calibration: Accelerometer/gyroscope data can be noisy. The shakeDetectionThreshold and tiltSpeed values will likely require fine-tuning to achieve a smooth and responsive user experience that feels good on various devices.

This step by step guide successfully integrates physical device interactions into your Unity game, adding a fantastic dimension to your puzzles! You now have a robust suite of core mechanics for creating diverse and engaging "Brain Test" style puzzles.

Comments

Popular posts from this blog

Step-by-Step Guide on How to Create a GDD (Game Design Document)

Unity Scriptable Objects: A Step-by-Step Tutorial

Unity 2D Tilemap Tutorial for Procedural Level Generation