using UnityEngine; using UnityEngine.UI; [RequireComponent(typeof(Rigidbody))] public class CrateEscapePlayerControllerJoystick : MonoBehaviour { [Header("Joystick")] public Joystick moveJoystick; [Header("Movement")] public float moveSpeed = 4f; public float moveDamp = 12f; // smoothing for XZ public float maxSpeedMultiplier = 3f; // full-stick multiplier [Range(0f, 0.5f)] public float inputDeadZone = 0.1f; [Tooltip("If true, stick is interpreted relative to the camera view.")] public bool cameraRelative = true; public Transform cameraTransform; [Header("Movement Plane")] [Tooltip("If OFF, uses Static Up as the movement plane normal. If ON, raycasts to read ground normal under the character.")] public bool useDynamicGroundNormal = false; [Tooltip("Used when useDynamicGroundNormal = false. Set this to your level's 'up' direction (the plane normal).")] public Vector3 staticUp = Vector3.up; [Tooltip("Layer mask for reading ground normal when dynamic is ON.")] public LayerMask groundForNormalMask = ~0; [Tooltip("Ray length for sampling ground normal beneath groundCheck.")] public float groundNormalRayLength = 3f; [Header("Facing")] public bool faceMoveDirection = true; public float turnSpeed = 540f; // deg/sec [Header("Grounding")] public LayerMask groundMask = ~0; public float groundCheckRadius = 0.2f; public Transform groundCheck; [Header("Jump")] public float jumpForce = 7f; // upward impulse public bool allowAirJump = false; public Button jumpButton; [Header("Optional Animator Driver")] public ZibuAnimDriver anim; [Header("Input Locks")] public float initialLockSeconds = 0f; // keep 0 while testing public bool useUnscaledForInitialLock = false; public bool autoLockOnGameOver = true; [Header("Debug / Safety")] public bool debugLogs = true; // show values in console public bool drawMoveRay = true; // shows a cyan ray for move direction public bool keyboardFallbackInEditor = true; // WASD/Arrow fallback when joystick is null or zero public bool ignoreGameOverLock = false; // bypass GameManager lock (testing) // --- runtime --- Rigidbody _rb; Vector3 _velXZ; // horizontal velocity (XZ only) Vector3 _moveDirWorld; // desired move dir on movement plane float _stickMag; // 0..1 bool _inputEnabled = false; // true when controls active bool _jumpQueued; bool _usedAirJump; void Awake() { _rb = GetComponent(); _rb.useGravity = true; _rb.isKinematic = false; // Lock only tilt, never lock Position X/Y/Z here _rb.constraints = RigidbodyConstraints.FreezeRotationX | RigidbodyConstraints.FreezeRotationZ; // Good physics defaults if (_rb.mass <= 0f) _rb.mass = 1f; _rb.drag = 0f; // don’t damp velocity away _rb.angularDrag = 0.05f; _rb.collisionDetectionMode = CollisionDetectionMode.Continuous; if (!groundCheck) groundCheck = transform; if (!anim) anim = GetComponent(); if (!cameraTransform && Camera.main) cameraTransform = Camera.main.transform; } void OnValidate() { if (!cameraTransform && Camera.main) cameraTransform = Camera.main.transform; if (!groundCheck) groundCheck = transform; } void OnEnable() { _inputEnabled = false; StopAllCoroutines(); StartCoroutine(EnableAfterDelay(initialLockSeconds, useUnscaledForInitialLock)); if (jumpButton) jumpButton.onClick.AddListener(OnJumpUIButton); } void OnDisable() { if (jumpButton) jumpButton.onClick.RemoveListener(OnJumpUIButton); } System.Collections.IEnumerator EnableAfterDelay(float seconds, bool unscaled) { if (seconds > 0f) { if (unscaled) yield return new WaitForSecondsRealtime(seconds); else yield return new WaitForSeconds(seconds); } _inputEnabled = true; if (debugLogs) Debug.Log($"[CrateEscape] Controls ENABLED (delay={seconds}) on {name}"); } void Update() { bool blocked = IsBlocked(); if (!blocked) { ReadMoveInput(); } else { _stickMag = 0f; _moveDirWorld = Vector3.zero; } // animator hooks if (anim) { float speedUnits = Mathf.Lerp(0f, maxSpeedMultiplier, _stickMag); anim.SetGrounded(IsGrounded()); anim.SetSpeed(speedUnits); } // Reset air-jump on ground contact if (IsGrounded()) _usedAirJump = false; // Debug ray if (drawMoveRay && _moveDirWorld.sqrMagnitude > 0.0001f) Debug.DrawRay(transform.position + Vector3.up * 0.1f, _moveDirWorld.normalized, Color.cyan, 0f, false); if (debugLogs) { if (Time.frameCount % 30 == 0) { Debug.Log($"[CrateEscape] {name} inputEnabled={_inputEnabled} blocked={blocked} stickMag={_stickMag:F2} dir={_moveDirWorld}"); } } } void FixedUpdate() { bool blocked = IsBlocked(); Vector3 targetVelXZ = Vector3.zero; if (!blocked && _stickMag > inputDeadZone) { Vector3 dir = _moveDirWorld.normalized; float targetSpeed = moveSpeed * Mathf.Lerp(0f, maxSpeedMultiplier, _stickMag); targetVelXZ = dir * targetSpeed; if (faceMoveDirection && dir.sqrMagnitude > 0.0001f) { Quaternion targetRot = Quaternion.LookRotation(dir, Vector3.up); _rb.MoveRotation(Quaternion.RotateTowards(_rb.rotation, targetRot, turnSpeed * Time.fixedDeltaTime)); } } if (blocked) { // Stop horizontal movement but preserve Y (gravity / jump settle) _velXZ = Vector3.zero; _rb.velocity = new Vector3(0f, _rb.velocity.y, 0f); _jumpQueued = false; return; } // Smooth horizontal velocity (keep physics-driven Y) _velXZ = Vector3.Lerp(_velXZ, targetVelXZ, 1f - Mathf.Exp(-moveDamp * Time.fixedDeltaTime)); // Apply horizontal velocity while preserving current Y _rb.velocity = new Vector3(_velXZ.x, _rb.velocity.y, _velXZ.z); // Handle jump in physics step if (_jumpQueued) { _jumpQueued = false; DoJump(); } } // ---------- Input ---------- void ReadMoveInput() { Vector2 stick = Vector2.zero; // Prefer joystick if present and non-zero if (moveJoystick) { stick = new Vector2(moveJoystick.Horizontal, moveJoystick.Vertical); } #if UNITY_EDITOR // Optional keyboard fallback (Editor/testing) if (keyboardFallbackInEditor) { if (stick.sqrMagnitude <= 0.0001f) { float h = Input.GetAxisRaw("Horizontal"); // A/D or Left/Right float v = Input.GetAxisRaw("Vertical"); // W/S or Up/Down if (Mathf.Abs(h) > 0.01f || Mathf.Abs(v) > 0.01f) stick = new Vector2(h, v); } } #endif _stickMag = Mathf.Clamp01(stick.magnitude); if (_stickMag <= inputDeadZone) { _moveDirWorld = Vector3.zero; return; } Vector2 n = stick.normalized; if (cameraRelative && cameraTransform) { // Plane-aware camera relative vectors Vector3 planeUp = GetMovementUp(); // Project camera forward onto the plane Vector3 camF = Vector3.ProjectOnPlane(cameraTransform.forward, planeUp); if (camF.sqrMagnitude < 0.0001f) camF = Vector3.ProjectOnPlane(cameraTransform.up, planeUp); camF.Normalize(); // Right is perpendicular on the plane Vector3 camR = Vector3.Cross(planeUp, camF).normalized; // Re-orthonormalize camF to be safe camF = Vector3.Cross(camR, planeUp).normalized; _moveDirWorld = camR * n.x + camF * n.y; } else { // World-relative, but still constrained to the plane Vector3 planeUp = GetMovementUp(); Vector3 worldX = Vector3.ProjectOnPlane(Vector3.right, planeUp).normalized; Vector3 worldZ = Vector3.ProjectOnPlane(Vector3.forward, planeUp).normalized; _moveDirWorld = (worldX * n.x + worldZ * n.y); } } Vector3 GetMovementUp() { if (useDynamicGroundNormal && groundCheck) { // Cast along character's local down to find the surface normal Ray ray = new Ray(groundCheck.position + Vector3.up * 0.05f, -transform.up); if (Physics.Raycast(ray, out var hit, groundNormalRayLength, groundForNormalMask, QueryTriggerInteraction.Ignore)) { return hit.normal.normalized; } } // Fallback to static up return staticUp.sqrMagnitude > 0.0001f ? staticUp.normalized : Vector3.up; } void DoJump() { bool grounded = IsGrounded(); if (!grounded) { if (!(allowAirJump && !_usedAirJump)) return; _usedAirJump = true; } // Reset vertical velocity then apply impulse for consistent jumps Vector3 v = _rb.velocity; v.y = 0f; _rb.velocity = v; _rb.AddForce(Vector3.up * jumpForce, ForceMode.Impulse); if (anim) anim.PlayAnimation(AnimationState.JumpStart); if (debugLogs) Debug.Log($"[CrateEscape] {name} JUMP (grounded={grounded}, airUsed={_usedAirJump})"); } // ---------- Ground / Gizmos ---------- bool IsGrounded() { return Physics.CheckSphere(groundCheck.position, groundCheckRadius, groundMask, QueryTriggerInteraction.Ignore); } void OnDrawGizmosSelected() { if (!groundCheck) groundCheck = transform; Gizmos.color = Color.yellow; Gizmos.DrawWireSphere(groundCheck.position, groundCheckRadius); } // ---------- Public API ---------- public void LockControls(bool locked) { _inputEnabled = !locked; if (locked) { _stickMag = 0f; _moveDirWorld = Vector3.zero; _velXZ = Vector3.zero; if (_rb) _rb.velocity = new Vector3(0f, _rb.velocity.y, 0f); if (anim) { anim.SetSpeed(0f); } } } public void EnableControlsNow() { StopAllCoroutines(); _inputEnabled = true; if (debugLogs) Debug.Log($"[CrateEscape] Controls ENABLED by call on {name}"); } // ---------- Helpers ---------- bool IsBlocked() { if (!_inputEnabled) return true; if (!ignoreGameOverLock && autoLockOnGameOver) { var gm = CrateEscapeGameManager.Instance; if (gm != null && gm.isGameOver) return true; // freeze after game over } return false; } public void SetJoystick(Joystick j) => moveJoystick = j; // ---------- UI Button Hook ---------- /// Call this from a UI Button OnClick() to make the player jump. public void OnJumpUIButton() { if (IsBlocked()) return; if (IsGrounded() || (allowAirJump && !_usedAirJump)) _jumpQueued = true; } // ---------- Inspector Utilities ---------- [ContextMenu("Test/Nudge Forward")] void NudgeForward() { // Small forward push to verify movement pipeline _rb.velocity = new Vector3(2f, _rb.velocity.y, 0f); if (debugLogs) Debug.Log($"[CrateEscape] {name} NudgeForward called."); } }