Files
FablishRPG/Assets/Scripts/BowAimLayerController.cs

612 lines
23 KiB
C#
Raw Normal View History

2026-09-28 20:46:57 +01:00
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;
2026-09-29 16:11:54 +01:00
[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;
2026-09-28 20:46:57 +01:00
[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;
// Are we currently holding the draw button (left click)?
private bool _isDrawing;
private Projectile _loadedArrow;
2026-09-29 16:11:54 +01:00
private float _drawStartTime;
private float _currentPowerMultiplier;
2026-09-28 20:46:57 +01:00
// 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<Camera>();
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;
2026-09-29 16:11:54 +01:00
if (_currentWeapon == null)
{
Debug.LogWarning("Cannot equip! No bow is assigned.");
yield break;
}
2026-09-28 20:46:57 +01:00
_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);
}
}
}
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;
Ray ray = _camera.ViewportPointToRay(new Vector3(0.5f, 0.5f, 0));
if (Physics.Raycast(ray, out RaycastHit hit, 100f, _targetLayers))
{
aimPoint = hit.point;
}
else
{
aimPoint = ray.GetPoint(100f);
}
Debug.DrawLine(_arrowSpawnPoint.position, aimPoint, Color.red);
}
}
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)
{
2026-09-29 16:11:54 +01:00
// 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
}
}
}
2026-09-28 20:46:57 +01:00
}
// Release while drawing = fire the arrow
if (!drawHeld && _isDrawing)
{
_isDrawing = false;
_animator.SetBool("IsDrawing", false);
2026-09-29 16:11:54 +01:00
_animator.SetBool("IsMaxDraw", false);
2026-09-28 20:46:57 +01:00
_animator.SetTrigger("Fire");
}
}
/// <summary>
/// Call this from an Animation Event on the 'Fire' animation!
/// </summary>
/// <summary>
/// Call this from an Animation Event on the 'loadArrow' animation!
/// </summary>
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;
}
}
/// <summary>
/// Call this from an Animation Event on the 'Fire' animation!
/// </summary>
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;
}
2026-09-29 16:11:54 +01:00
// 1. Find where the crosshair is looking (or auto-aim if locked on)
2026-09-28 20:46:57 +01:00
Vector3 aimPoint;
2026-09-29 16:11:54 +01:00
Synty.AnimationBaseLocomotion.Samples.SamplePlayerAnimationController playerController =
GetComponent<Synty.AnimationBaseLocomotion.Samples.SamplePlayerAnimationController>();
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);
}
else if (_camera != null)
2026-09-28 20:46:57 +01:00
{
Ray ray = _camera.ViewportPointToRay(new Vector3(0.5f, 0.5f, 0));
if (Physics.Raycast(ray, out RaycastHit hit, 100f, _targetLayers))
{
aimPoint = hit.point;
}
else
{
// If we are looking into the sky, aim far out into the distance
aimPoint = ray.GetPoint(100f);
}
}
else
{
aimPoint = _arrowSpawnPoint.position + transform.forward * 100f;
}
// 2. Unparent the arrow from the hand/string
_loadedArrow.transform.SetParent(null);
// 3. Point the arrow exactly at the target
_loadedArrow.transform.LookAt(aimPoint);
// 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;
2026-09-29 16:11:54 +01:00
_loadedArrow.Initialize(damage, _targetLayers, _currentPowerMultiplier);
2026-09-28 20:46:57 +01:00
// 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);
}
/// <summary>
/// The camera's up/down angle in degrees: negative = looking up,
/// positive = looking down.
/// </summary>
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;
}
}