using Synty.AnimationBaseLocomotion.Samples.InputSystem; using UnityEngine; using UnityEngine.InputSystem; public class BowAimLayerController : MonoBehaviour { [Header("References")] [Tooltip("The InputReader on your character - same one the player controller uses")] [SerializeField] private InputReader _inputReader; [Tooltip("The Animator on your character")] [SerializeField] private Animator _animator; [Tooltip("Your main camera's transform - used to know where you're aiming")] [SerializeField] private Transform _cameraTransform; [Header("Weapon Management")] [Tooltip("The active bow used for damage and stats.")] [SerializeField] private Weapon _currentWeapon; [Tooltip("The Transform socket where the bow is held when equipped (e.g., Prop_L)")] [SerializeField] private Transform _equippedSocket; [Tooltip("The Transform socket where the bow rests when put away (e.g., Spine_03)")] [SerializeField] private Transform _unequippedSocket; [Header("Stats & Stamina")] [SerializeField] private PlayerStats _playerStats; [Tooltip("How much stamina per second is drained while holding the bow string back.")] [SerializeField] private float _staminaDrainRate = 15f; [Tooltip("How many seconds you need to hold the bow to reach maximum power and distance.")] [SerializeField] private float _maxDrawTime = 1.5f; [Header("Projectile Settings")] [Tooltip("The arrow prefab to spawn. Make sure it has the Projectile.cs script!")] [SerializeField] private Projectile _arrowPrefab; [Tooltip("Where the arrow spawns from (e.g. the Bow's string or hand)")] [SerializeField] private Transform _arrowSpawnPoint; [Tooltip("Layers the arrow can hit and damage")] [SerializeField] private LayerMask _targetLayers; [Header("Aim Settings")] [Tooltip("How fast the bow layer blends in and out when equipping. Higher = snappier.")] [SerializeField] private float _equipBlendSpeed = 5f; [Tooltip("How strongly the spine turns to face the camera's horizontal direction while aiming. 1 = fully locked to the camera.")] [SerializeField] private float _spineYawStrength = 1f; [Tooltip("The furthest the spine can twist away from the body, in degrees. Stops the upper body breaking when the camera swings fast.")] [SerializeField] private float _maxSpineYaw = 60f; [Tooltip("How fast the spine turns to track the camera, in degrees per second.")] [SerializeField] private float _spineYawSpeed = 720f; [Tooltip("How fast the aiming effects (spine tracking) blend in and out.")] [SerializeField] private float _aimBlendSpeed = 5f; [Tooltip("How much of the camera's up/down pitch the spine follows while aiming. 0.5-0.7 usually looks natural; 1 = full follow.")] [SerializeField] private float _aimPitchStrength = 0.6f; [Header("Rig Correction")] [Tooltip("Extra spine twist applied while aiming, in degrees. Leave at 0 for rigs where the aim pose already faces the right way (Sidekick). If a rig's upper body aims off to one side, dial this to correct it (try 90 or -90).")] [SerializeField] private float _stanceYawCorrection = 0f; [Header("Head Look")] [Tooltip("The furthest the head can look left/right of the character's forward direction while the bow is equipped but not aimed, in degrees.")] [SerializeField] private float _maxHeadYaw = 70f; [Tooltip("When the camera swings further behind than this angle, the head starts easing back to facing forward instead of chasing it. Stops the head snapping side to side when running toward the camera.")] [SerializeField] private float _headFalloffStart = 110f; [Tooltip("How fast the head turns, in degrees per second.")] [SerializeField] private float _headYawSpeed = 360f; [Header("Camera Zoom")] [Tooltip("Field of view while just aiming. Lower = more zoomed in.")] [SerializeField] private float _aimFov = 50f; [Tooltip("Field of view while drawing the bow. Lower = more zoomed in.")] [SerializeField] private float _drawFov = 42f; [Tooltip("How quickly the camera zooms in and out.")] [SerializeField] private float _fovBlendSpeed = 6f; // Is the bow currently equipped (toggled with F)? private bool _isEquipped; // Are we currently holding the aim button (right click)? private bool _isAiming; public bool IsAiming => _isAiming; // Are we currently holding the draw button (left click)? private bool _isDrawing; private Projectile _loadedArrow; private float _drawStartTime; private float _currentPowerMultiplier; // The current 0-1 blend weight for the bow layer (driven by equip state) private float _equipWeight; // The current 0-1 blend for the aiming effects (spine tracking etc) private float _aimWeight; // The BowAim layer index, looked up once by name in Start private int _aimLayer; // Bones used for the procedural aiming, grabbed from the humanoid rig private Transform _spineBone; private Transform _hipsBone; private Transform _headBone; // How the head bone's rotation relates to the character's rotation in // the default pose. Lets us build an absolute "look at the camera" // target with no per-rig axis guesswork. private Quaternion _headOffsetFromBody; // Smoothed head yaw so the head eases between look directions private float _currentHeadYaw; // Smoothed spine yaw so fast camera swings and the +/-180 wrap // can't snap the spine from one side to the other private float _currentSpineYaw; // The camera component we zoom, and its original field of view private Camera _camera; private float _defaultFov; private void Start() { // Find our layer by name so it doesn't break if you reorder layers _aimLayer = _animator.GetLayerIndex("BowAim"); // If the referenced weapon is a prefab asset (not in the scene), instantiate it if (_currentWeapon != null && !_currentWeapon.gameObject.scene.IsValid()) { _currentWeapon = Instantiate(_currentWeapon); } // Grab the bones we need from the humanoid rig _spineBone = _animator.GetBoneTransform(HumanBodyBones.Spine); _hipsBone = _animator.GetBoneTransform(HumanBodyBones.Hips); _headBone = _animator.GetBoneTransform(HumanBodyBones.Head); // Capture the head bone's rotation relative to the character while // it's in its default forward-facing pose. Used to build the // head-look target without guessing the rig's bone axes. if (_headBone != null) { _headOffsetFromBody = Quaternion.Inverse(transform.rotation) * _headBone.rotation; } // Find the actual Camera component for the FOV zoom, and remember // its starting field of view so we can return to it _camera = _cameraTransform.GetComponent(); if (_camera == null) { _camera = Camera.main; } if (_camera != null) { _defaultFov = _camera.fieldOfView; } // Start with the bow visuals matching the unequipped state UpdateWeaponSocket(); // Listen to the same aim events the Synty controller uses. // These fire when the aim button (right click) is pressed/released. _inputReader.onAimActivated += OnAimStart; _inputReader.onAimDeactivated += OnAimEnd; } private void OnDestroy() { // Always unsubscribe when this object is destroyed _inputReader.onAimActivated -= OnAimStart; _inputReader.onAimDeactivated -= OnAimEnd; } private void OnAimStart() { // Aiming only means anything with the bow out if (!_isEquipped) { return; } _isAiming = true; _animator.SetBool("IsAiming", true); // Use the pack's own walk mode to slow down while aiming - // invoking the event is the same as pressing the walk key _inputReader.onWalkToggled?.Invoke(); } private void OnAimEnd() { if (!_isAiming) { return; } _isAiming = false; _animator.SetBool("IsAiming", false); // Back to normal speed _inputReader.onWalkToggled?.Invoke(); // Lowering the bow cancels any draw in progress without firing CancelDraw(); } public bool IsEquipped => _isEquipped; public System.Collections.IEnumerator EquipWeapon() { if (_isEquipped) yield break; if (_currentWeapon == null) { Debug.LogWarning("Cannot equip! No bow is assigned."); yield break; } _isEquipped = true; _animator.Play("Equip", _aimLayer, 0f); // Wait for the hand to reach the bow on the back before snapping it to the hand yield return new WaitForSeconds(0.3f); UpdateWeaponSocket(); } public System.Collections.IEnumerator UnequipWeapon() { if (!_isEquipped) yield break; // Putting the bow away cancels aiming and drawing cleanly if (_isAiming) { OnAimEnd(); } _animator.Play("Unequip", _aimLayer, 0f); // Wait for the unequip animation to finish before snapping to the back // Adjust this 0.5f duration to match the length of your actual unequip animation! yield return new WaitForSeconds(0.5f); _isEquipped = false; UpdateWeaponSocket(); } private void UpdateWeaponSocket() { if (_currentWeapon == null) return; Transform targetSocket = _isEquipped ? _equippedSocket : _unequippedSocket; if (targetSocket != null) { _currentWeapon.transform.SetParent(targetSocket, false); if (_isEquipped) { _currentWeapon.transform.localPosition = _currentWeapon.equippedPositionOffset; _currentWeapon.transform.localRotation = Quaternion.Euler(_currentWeapon.equippedRotationOffset); } else { _currentWeapon.transform.localPosition = _currentWeapon.unequippedPositionOffset; _currentWeapon.transform.localRotation = Quaternion.Euler(_currentWeapon.unequippedRotationOffset); } } } public float CurrentDrawPercentage { get { if (!_isDrawing) return 0f; float drawDuration = Time.time - _drawStartTime; return Mathf.Clamp01(drawDuration / _maxDrawTime); } } private void CancelDraw() { if (_isDrawing) { _isDrawing = false; _animator.SetBool("IsDrawing", false); } if (_loadedArrow != null) { Destroy(_loadedArrow.gameObject); _loadedArrow = null; } } private void Update() { UpdateDrawAndFire(); // The layer weight follows the equip state: bow out = layer on. // The equip animation plays as the layer fades in. float equipTarget = _isEquipped ? 1f : 0f; _equipWeight = Mathf.MoveTowards(_equipWeight, equipTarget, _equipBlendSpeed * Time.deltaTime); _animator.SetLayerWeight(_aimLayer, _equipWeight); // The aiming effects (spine tracking, zoom) follow the aim state float aimTarget = _isAiming ? 1f : 0f; _aimWeight = Mathf.MoveTowards(_aimWeight, aimTarget, _aimBlendSpeed * Time.deltaTime); // Zoom the camera: default -> aim FOV -> draw FOV UpdateCameraZoom(); // Laser Sight Gizmo if (_isAiming && _arrowSpawnPoint != null && _camera != null) { Vector3 aimPoint = GetCrosshairAimPoint(); Debug.DrawLine(_arrowSpawnPoint.position, aimPoint, Color.red); } } private Vector3 GetCrosshairAimPoint() { if (_camera == null) return transform.position + transform.forward * 100f; Ray ray = _camera.ViewportPointToRay(new Vector3(0.5f, 0.5f, 0)); // EXTREMELY IMPORTANT: Ignore triggers! Otherwise we might hit invisible lock-on spheres or interact zones! RaycastHit[] hits = Physics.RaycastAll(ray, 100f, _targetLayers, QueryTriggerInteraction.Ignore); float closestDistance = float.MaxValue; Vector3 aimPoint = ray.GetPoint(100f); foreach (var hit in hits) { // IGNORE THE PLAYER! If the raycast hits our own body, skip it! if (hit.collider.transform.root == transform.root) continue; // IGNORE ARROWS! if (hit.collider.GetComponent() != null) continue; if (hit.distance < closestDistance) { closestDistance = hit.distance; aimPoint = hit.point; } } // PARALLAX FIX: // Because the camera is offset over the right shoulder, if you aim at something very close (like 1m away), // the arrow (in the left hand) has to violently turn sideways to cross the screen and hit that close point. // By forcing the aim point to be at least 10 meters away along the same sightline, // we guarantee the arrow always fires mostly straight forward, completely eliminating wild sideways shots! if (closestDistance < 4f) { aimPoint = ray.GetPoint(15f); } return aimPoint; } private void UpdateDrawAndFire() { // The Synty InputReader doesn't have an attack action, so we read // the left mouse button directly from the new Input System bool drawHeld = Mouse.current != null && Mouse.current.leftButton.isPressed; // Start drawing: left click pressed while aiming if (drawHeld && _isAiming && !_isDrawing) { // Only allow drawing if stamina is at least 10% if (_playerStats == null || _playerStats.HasStaminaPercentage(0.10f)) { _isDrawing = true; _drawStartTime = Time.time; _animator.SetBool("IsDrawing", true); } } // While drawing, drain stamina and calculate power if (_isDrawing) { float drawDuration = Time.time - _drawStartTime; // Starts at a minimum power of 20%, goes up to 100% over maxDrawTime _currentPowerMultiplier = Mathf.Lerp(0.2f, 1.0f, Mathf.Clamp01(drawDuration / _maxDrawTime)); // Tell the animator we've reached max draw so it can play a Shake animation bool isMaxPower = drawDuration >= _maxDrawTime; _animator.SetBool("IsMaxDraw", isMaxPower); if (_playerStats != null) { AnimatorStateInfo stateInfo = _animator.GetCurrentAnimatorStateInfo(_aimLayer); // Only drain stamina if the string is fully pulled back (in "Drawn" or "Shake" state) if (stateInfo.IsName("Drawn") || stateInfo.IsName("Shake")) { _playerStats.UseStamina(_staminaDrainRate * Time.deltaTime); // If stamina reaches 0, force the player to release the arrow if (_playerStats.currentStamina <= 0f) { drawHeld = false; // Force release } } } } // Release while drawing = fire the arrow if (!drawHeld && _isDrawing) { _isDrawing = false; _animator.SetBool("IsDrawing", false); _animator.SetBool("IsMaxDraw", false); _animator.SetTrigger("Fire"); } } /// /// Call this from an Animation Event on the 'Fire' animation! /// /// /// Call this from an Animation Event on the 'loadArrow' animation! /// public void loadArrow() { LoadArrow(); } public void LoadArrow() { if (_arrowPrefab == null || _arrowSpawnPoint == null) { Debug.LogWarning("Missing Arrow Prefab or Spawn Point on BowAimLayerController!"); return; } if (_loadedArrow == null) { // Spawn the arrow parented to the string/hand _loadedArrow = Instantiate(_arrowPrefab, _arrowSpawnPoint); _loadedArrow.transform.localPosition = _loadedArrow.loadedPositionOffset; _loadedArrow.transform.localRotation = Quaternion.Euler(_loadedArrow.loadedRotationOffset); // Disable the Projectile script so it doesn't fly away immediately _loadedArrow.enabled = false; } } /// /// Call this from an Animation Event on the 'Fire' animation! /// public void Fire() { FireArrow(); } public void FireArrow() { if (_loadedArrow == null) { // If we don't have a loaded arrow, spawn one instantly just in case LoadArrow(); if (_loadedArrow == null) return; } // 1. Find where the crosshair is looking (or auto-aim if locked on) Vector3 aimPoint; Synty.AnimationBaseLocomotion.Samples.SamplePlayerAnimationController playerController = GetComponent(); if (playerController != null && playerController.CurrentLockOnTarget != null) { // Auto aim directly at the locked on target's chest! aimPoint = playerController.CurrentLockOnTarget.transform.position + (Vector3.up * 1.2f); Debug.Log($"[Bow Debug] Locked on! Auto-aiming at: {aimPoint}"); } else { aimPoint = GetCrosshairAimPoint(); Debug.Log($"[Bow Debug] Calculated AimPoint from Crosshair: {aimPoint}"); } // 2. Unparent the arrow from the hand/string _loadedArrow.transform.SetParent(null); Debug.Log($"[Bow Debug] Arrow physically starts at: {_loadedArrow.transform.position}. Aim Point is at: {aimPoint}."); // 3. Point the arrow exactly at the target _loadedArrow.transform.LookAt(aimPoint); Debug.Log($"[Bow Debug] After LookAt, arrow is rotated to: {_loadedArrow.transform.eulerAngles}."); // 4. Get the damage from the current weapon (default to 10 if none) float damage = _currentWeapon != null ? _currentWeapon.damage : 10f; // 5. Enable the script to start flying! _loadedArrow.enabled = true; _loadedArrow.Initialize(damage, _targetLayers, _currentPowerMultiplier); // Clear the reference so we can load a new one _loadedArrow = null; } private void UpdateCameraZoom() { if (_camera == null) { return; } // Pick the target FOV for the current state float targetFov = _defaultFov; if (_isAiming) { targetFov = _isDrawing ? _drawFov : _aimFov; } // Ease toward it _camera.fieldOfView = Mathf.Lerp(_camera.fieldOfView, targetFov, _fovBlendSpeed * Time.deltaTime); } /// /// The camera's up/down angle in degrees: negative = looking up, /// positive = looking down. /// private float GetCameraPitch() { // eulerAngles.x gives 0-360, so convert to a -180 to 180 range float pitch = _cameraTransform.eulerAngles.x; if (pitch > 180f) pitch -= 360f; return pitch; } private void LateUpdate() { // Runs after the Animator has posed the character for this frame, // so these adjustments apply on top of whatever state is playing // (equip, idle, draw, shake, fire). if (_spineBone == null) { return; } Vector3 cameraFlat = _cameraTransform.forward; cameraFlat.y = 0f; Vector3 characterForward = transform.forward; characterForward.y = 0f; // --- While equipped but not aiming: keep the head looking forward --- // The relaxed bow stance turns the whole upper body sideways, which // looks good - but the head should follow the player's view. // (1 - aimWeight) fades this out as the spine tracking takes over. float idleHeadWeight = _equipWeight * (1f - _aimWeight); if (idleHeadWeight > 0.01f && _headBone != null && cameraFlat.sqrMagnitude > 0.0001f) { // How far off the character's forward is the camera looking? float rawYaw = Vector3.SignedAngle(characterForward, cameraFlat, Vector3.up); // Clamp to the neck's limits float targetYaw = Mathf.Clamp(rawYaw, -_maxHeadYaw, _maxHeadYaw); // Rear falloff: past the falloff angle, ease the head back to // facing forward instead of chasing the camera behind the // character. This looks natural when running toward the camera, // and it also removes the left/right snap at directly-behind. float absYaw = Mathf.Abs(rawYaw); if (absYaw > _headFalloffStart) { float falloff = Mathf.InverseLerp(180f, _headFalloffStart, absYaw); targetYaw *= falloff; } // Ease toward the target yaw so all changes are smooth _currentHeadYaw = Mathf.MoveTowards(_currentHeadYaw, targetYaw, _headYawSpeed * Time.deltaTime); // Build the look direction and the head's target rotation for it, // using the offset captured in the default pose Vector3 lookDir = Quaternion.AngleAxis(_currentHeadYaw, Vector3.up) * characterForward; Quaternion lookTarget = Quaternion.LookRotation(lookDir.normalized) * _headOffsetFromBody; // Blend by the idle weight so it fades cleanly into/out of aiming _headBone.rotation = Quaternion.Slerp(_headBone.rotation, lookTarget, idleHeadWeight); } // --- While aiming: full spine tracking --- if (_aimWeight <= 0f) { return; } // --- Rig correction: some rigs play the aim pose facing off to one // side. This twists the spine back by a fixed amount, but only while // aiming, so the relaxed equipped stance is left alone. Stays at 0 // (no effect) unless you set it. if (!Mathf.Approximately(_stanceYawCorrection, 0f)) { _spineBone.rotation = Quaternion.AngleAxis(_stanceYawCorrection * _aimWeight, Vector3.up) * _spineBone.rotation; } Vector3 hipsForward = _hipsBone.forward; hipsForward.y = 0f; // --- Yaw part 1: track the camera (smoothed) --- // How far the camera has swung away from the character's facing. // Smoothed so fast swings and the +/-180 wrap can't snap the spine. float yawToCamera = Vector3.SignedAngle(characterForward, cameraFlat, Vector3.up); yawToCamera = Mathf.Clamp(yawToCamera, -_maxSpineYaw, _maxSpineYaw); _currentSpineYaw = Mathf.MoveTowards(_currentSpineYaw, yawToCamera, _spineYawSpeed * Time.deltaTime); // --- Yaw part 2: cancel hip sway (raw, every frame) --- // The walk cycle wiggles the hips a few degrees each step. This is // measured and cancelled instantly - smoothing it would let the // wobble leak through to the upper body. float hipSwing = Vector3.SignedAngle(characterForward, hipsForward, Vector3.up); // Apply both: aim lead toward the camera, minus the hip wobble float totalYaw = (_currentSpineYaw - hipSwing) * _aimWeight * _spineYawStrength; _spineBone.rotation = Quaternion.AngleAxis(totalYaw, Vector3.up) * _spineBone.rotation; // --- Pitch: bend the spine up/down toward where the camera looks --- // Rotate around the aim direction's right axis so the bend is always // relative to where we're aiming. Scaled by aim weight so it fades // in and out with the bow. float cameraPitch = GetCameraPitch(); Vector3 aimRight = Quaternion.AngleAxis(90f, Vector3.up) * cameraFlat.normalized; _spineBone.rotation = Quaternion.AngleAxis(cameraPitch * _aimWeight * _aimPitchStrength, aimRight) * _spineBone.rotation; } }