Данный проект разработан в рамках технического задания
Требования:
- Механика избиения головы
- Видимый урон
- Иммерсивность, креатив
В качестве сущности, которой можно наносить удары используется модель "манекена", которая состоит из:
- Головы, которая сопротивляется ударам и возвращается в исходное положение. Однако, с получением урона голова ослабевает и перестает так же активно сопротивляться ударам
- Торса, который при ослабевании просто наклоняется
При этом, урон с конечностей также передается в основу - сам манекен. При снижении здоровья манекена до нуля он распадается.
Если нанести добивающий удар по голове, она отлетит и включится отдельная камера с постобработкой, аналог Kill Cam
В проекте представлены 3 вида оружия:
- Бита, наносящая удары сбоку
- Автопанчер
- Перчатки
Код реализации Joint
HeadPunchDemo/Assets/Scripts/Ragdoll/HeadJointController.cs
Lines 1 to 88 in e995e40
| using UnityEngine; | |
| namespace Scripts.Ragdoll | |
| { | |
| [RequireComponent(typeof(Rigidbody))] | |
| public sealed class HeadJointController : MonoBehaviour | |
| { | |
| [SerializeField] | |
| private Rigidbody _torsoRB; | |
| [Header("Anchor Settings")] | |
| [SerializeField] | |
| private Vector3 _anchor = new Vector3(0f, -0.2f, 0f); | |
| [SerializeField] | |
| private Vector3 _connectedAnchor = new Vector3(0f, 0.2f, 0f); | |
| [Header("Joint Limits")] | |
| [SerializeField] | |
| private JointData _jointData; | |
| private Rigidbody _headRB; | |
| private ConfigurableJoint _joint; | |
| private SoftJointLimit _hightJointLimit; | |
| private SoftJointLimit _lowJointLimit; | |
| private JointDrive _jointDrive; | |
| private void Start() | |
| { | |
| _headRB = GetComponent<Rigidbody>(); | |
| CreateJoint(); | |
| SetupJointMax(); | |
| } | |
| private void CreateJoint() | |
| { | |
| var rb = GetComponent<Rigidbody>(); | |
| rb.isKinematic = false; | |
| rb.useGravity = false; | |
| _joint = gameObject.AddComponent<ConfigurableJoint>(); | |
| _joint.connectedBody = _torsoRB; | |
| // Locked motion | |
| _joint.xMotion = ConfigurableJointMotion.Locked; | |
| _joint.yMotion = ConfigurableJointMotion.Locked; | |
| _joint.zMotion = ConfigurableJointMotion.Locked; | |
| // Limited rotation | |
| _joint.angularXMotion = ConfigurableJointMotion.Limited; | |
| _joint.angularYMotion = ConfigurableJointMotion.Limited; | |
| _joint.angularZMotion = ConfigurableJointMotion.Limited; | |
| // Anchors | |
| _joint.autoConfigureConnectedAnchor = false; | |
| _joint.anchor = _anchor; | |
| _joint.connectedAnchor = _connectedAnchor; | |
| } | |
| public void SetupJoint(float power) | |
| { | |
| Debug.Log($"Joint setup: {power}", this.gameObject); | |
| _jointData.GetInterpolated(power, _headRB.mass, out float rotationLimit, out float spring, out float damper, out float maxForce); | |
| // Angular limits | |
| _hightJointLimit = new SoftJointLimit { limit = rotationLimit }; | |
| _lowJointLimit = new SoftJointLimit { limit = -rotationLimit }; | |
| _joint.lowAngularXLimit = _lowJointLimit; | |
| _joint.highAngularXLimit = _hightJointLimit; | |
| _joint.angularYLimit = _hightJointLimit; | |
| _joint.angularZLimit = _hightJointLimit; | |
| // Return to origin pos | |
| _jointDrive = new JointDrive | |
| { | |
| positionSpring = spring, | |
| positionDamper = damper, | |
| maximumForce = maxForce, | |
| }; | |
| _joint.angularXDrive = _jointDrive; | |
| _joint.angularYZDrive = _jointDrive; | |
| } | |
| private void SetupJointMax() => SetupJoint(1.0F); | |
| } | |
| } |
HeadPunchDemo/Assets/Scripts/Ragdoll/JointData.cs
Lines 1 to 27 in e995e40
| using System; | |
| using UnityEngine; | |
| namespace Scripts.Ragdoll | |
| { | |
| [Serializable] | |
| public class JointData | |
| { | |
| public Vector2 rotationLimitRange = new Vector2(45f, 25f); | |
| public Vector2 springRange = new Vector2(0.0f, 100f); | |
| public Vector2 damperRange = new Vector2(0.0f, 10f); | |
| public Vector2 maximumForce = new Vector2(0.025f, 5f); | |
| public void GetInterpolated(float t, float mass, out float rotationLimit, out float spring, out float damper, out float maxForce) | |
| { | |
| rotationLimit = Mathf.Lerp(rotationLimitRange.x, rotationLimitRange.y, t); | |
| // forces multiplied by mass | |
| spring = Mathf.Lerp(springRange.x, springRange.y, t) * mass; | |
| damper = Mathf.Lerp(damperRange.x, damperRange.y, t) * mass; | |
| maxForce = Mathf.Lerp(maximumForce.x, maximumForce.y, t) * mass; | |
| } | |
| //public void GetMax(out float rotationLimit, out float spring, out float damper, out float maxForce) | |
| // => GetInterpolated(1.0f, out rotationLimit, out spring, out damper, out maxForce); | |
| } | |
| } |
Код реализации оружия
HeadPunchDemo/Assets/Scripts/Weapons/BaseSimpleWeapon.cs
Lines 1 to 63 in e995e40
| using Scripts.Interactive.Punching; | |
| using Scripts.Animations; | |
| using Scripts.Weapons.Data; | |
| using UnityEngine; | |
| using Scripts.Interactive.Painting; | |
| namespace Scripts.Weapons | |
| { | |
| [DisallowMultipleComponent] | |
| public abstract class BaseSimpleWeapon : MonoBehaviour, IAnimationHitListener | |
| { | |
| protected static LayerMask Paintable { get; private set; } | |
| protected static LayerMask Punchable { get; private set; } | |
| protected static LayerMask Damageable { get; private set; } | |
| protected static Ray GetScreenRay() => Camera.main.ViewportPointToRay(new Vector3(0.5f, 0.5f, 0f)); | |
| [SerializeField] | |
| protected WeaponData weaponData; | |
| [SerializeField] | |
| private Transform _playerTF; | |
| protected Vector3 PlayerPosition => _playerTF.position; | |
| private void Awake() | |
| { | |
| // Hardcode | |
| Paintable = 1 << LayerMask.NameToLayer("Paintable"); | |
| Punchable = 1 << LayerMask.NameToLayer("Punchable"); | |
| Damageable = 1 << LayerMask.NameToLayer("Damageable"); | |
| } | |
| public void HandleAnimationHit() | |
| { | |
| // This should be done in individual systems to observe SRP | |
| // However, since this is a demo, the logic is kept in a god-class for simplicity and clarity | |
| // Ray should go from Player or Weapon not from the Screen | |
| Ray screenRay = GetScreenRay(); | |
| HandlePaint(screenRay); | |
| HandlePunch(screenRay); | |
| } | |
| protected virtual void HandlePunch(Ray screenRay) | |
| { | |
| if (Physics.Raycast(screenRay, out RaycastHit hit, weaponData.AttackRange, Punchable) && hit.rigidbody != null && hit.collider.TryGetComponent<IPunchable>(out IPunchable punchable)) | |
| { | |
| Vector3 force = screenRay.direction * Random.Range(weaponData.KnockbackRange.x, weaponData.KnockbackRange.y); | |
| punchable.ReceivePunch(new PunchData() { Damage = weaponData.Damage, KnockbackForce = force, HitPoint = hit.point, HitObject = hit.rigidbody.gameObject }); | |
| } | |
| } | |
| protected virtual void HandlePaint(Ray screenRay) | |
| { | |
| if (Physics.Raycast(screenRay, out RaycastHit hit, weaponData.AttackRange, Paintable) && hit.rigidbody != null && hit.collider.TryGetComponent<IPaintable>(out IPaintable paintable)) | |
| { | |
| paintable.Paint(hit.point, weaponData.PaintParams); | |
| } | |
| } | |
| } | |
| } |
HeadPunchDemo/Assets/Scripts/Weapons/Concrete/BatWeapon.cs
Lines 1 to 116 in e995e40
| using Scripts.Interactive.Punching; | |
| using Scripts.Animations; | |
| using Scripts.Animations.Bat; | |
| using UnityEngine; | |
| using Scripts.Interactive.Painting; | |
| namespace Scripts.Weapons.Concrete | |
| { | |
| public sealed class BatWeapon : BaseSimpleWeapon, ISemiAutoWeapon, IAnimationHitListener | |
| { | |
| private enum AttackDirection | |
| { | |
| None = 0, | |
| Left, | |
| Right, | |
| } | |
| [SerializeField] | |
| private BatAnimator _animator; | |
| [SerializeField] | |
| private Transform _leftAttackOrigin; | |
| [SerializeField] | |
| private Transform _rightAttackOrigin; | |
| private float _lastShootTime; | |
| private AttackDirection _lastAttackDirection; | |
| private void Start() | |
| { | |
| _lastAttackDirection = Random.value > 0.5F ? AttackDirection.Left : AttackDirection.Right; | |
| } | |
| private void OnEnable() | |
| { | |
| _animator.OnAnimationHitEvent += HandleAnimationHit; | |
| } | |
| private void OnDisable() | |
| { | |
| _animator.OnAnimationHitEvent -= HandleAnimationHit; | |
| } | |
| public void TryPunch() | |
| { | |
| if (Time.time <= _lastShootTime + weaponData.Cooldown) | |
| return; | |
| if (_animator.IsAttacking) | |
| return; | |
| _lastShootTime = Time.time; | |
| if (_lastAttackDirection == AttackDirection.Left) | |
| { | |
| _animator.PlayAttackR(); | |
| _lastAttackDirection = AttackDirection.Right; | |
| } | |
| else | |
| { | |
| _animator.PlayAttackL(); | |
| _lastAttackDirection = AttackDirection.Left; | |
| } | |
| } | |
| protected sealed override void HandlePunch(Ray screenRay) | |
| { | |
| //1) Raycast from L/R | |
| //2) If raycast lr -> punch from them | |
| //3) If no LR -> raycast front; | |
| Vector3 attackOrigin = _lastAttackDirection == AttackDirection.Left ? _leftAttackOrigin.position : _rightAttackOrigin.position; | |
| if (Physics.Raycast(screenRay, out RaycastHit hit, weaponData.AttackRange, Punchable) && hit.rigidbody != null && hit.collider.TryGetComponent<IPunchable>(out IPunchable punchable)) | |
| { | |
| Vector3 hitDirection = screenRay.direction; | |
| Vector3 hitOrigin = hit.point; | |
| float forcePower = Random.Range(weaponData.KnockbackRange.x, weaponData.KnockbackRange.y); | |
| float damage = weaponData.Damage; | |
| Vector3 sideDirection = (hit.rigidbody.position - attackOrigin).normalized; | |
| Ray sideRay = new Ray(attackOrigin, sideDirection); | |
| if (Physics.Raycast(sideRay, out RaycastHit sideHit, float.MaxValue, Punchable) && sideHit.rigidbody != null && sideHit.rigidbody == hit.rigidbody) | |
| { | |
| hitDirection = sideRay.direction; | |
| hitOrigin = sideHit.point; | |
| } | |
| Vector3 force = hitDirection * forcePower; | |
| punchable.ReceivePunch(new PunchData() { Damage = weaponData.Damage, KnockbackForce = force, HitPoint = hitOrigin, HitObject = hit.rigidbody.gameObject }); | |
| } | |
| } | |
| protected override void HandlePaint(Ray screenRay) | |
| { | |
| Vector3 attackOrigin = _lastAttackDirection == AttackDirection.Left ? _leftAttackOrigin.position : _rightAttackOrigin.position; | |
| if (Physics.Raycast(screenRay, out RaycastHit hit, weaponData.AttackRange, Paintable) && hit.rigidbody != null && hit.collider.TryGetComponent<IPaintable>(out IPaintable paintable)) | |
| { | |
| Vector3 sideDirection = (hit.rigidbody.position - attackOrigin).normalized; | |
| Ray sideRay = new Ray(attackOrigin, sideDirection); | |
| if (Physics.Raycast(sideRay, out RaycastHit sideHit, float.MaxValue, Paintable) && sideHit.rigidbody != null && sideHit.rigidbody == hit.rigidbody) | |
| { | |
| paintable.Paint(sideHit.point, weaponData.PaintParams); | |
| } | |
| else | |
| { | |
| paintable.Paint(hit.point, weaponData.PaintParams); | |
| } | |
| } | |
| } | |
| } | |
| } |
HeadPunchDemo/Assets/Scripts/Weapons/Concrete/GlovesWeapon.cs
Lines 1 to 59 in e995e40
| using Scripts.Animations; | |
| using Scripts.Animations.Gloves; | |
| using UnityEngine; | |
| namespace Scripts.Weapons.Concrete | |
| { | |
| public sealed class GlovesWeapon : BaseSimpleWeapon, ISemiAutoWeapon, IAnimationHitListener | |
| { | |
| private enum AttackDirection | |
| { | |
| None = 0, | |
| Left, | |
| Right, | |
| } | |
| [SerializeField] | |
| private GlovesAnimator _animator; | |
| private float _lastShootTime; | |
| private AttackDirection _lastAttackDirection; | |
| private void Start() | |
| { | |
| _lastAttackDirection = Random.value > 0.5F ? AttackDirection.Left : AttackDirection.Right; | |
| } | |
| private void OnEnable() | |
| { | |
| _animator.OnAnimationHitEvent += HandleAnimationHit; | |
| } | |
| private void OnDisable() | |
| { | |
| _animator.OnAnimationHitEvent -= HandleAnimationHit; | |
| } | |
| public void TryPunch() | |
| { | |
| if (Time.time <= _lastShootTime + weaponData.Cooldown) | |
| return; | |
| if (_animator.IsAttacking) | |
| return; | |
| _lastShootTime = Time.time; | |
| if (_lastAttackDirection == AttackDirection.Left) | |
| { | |
| _animator.PlayAttackR(); | |
| _lastAttackDirection = AttackDirection.Right; | |
| } | |
| else | |
| { | |
| _animator.PlayAttackL(); | |
| _lastAttackDirection = AttackDirection.Left; | |
| } | |
| } | |
| } | |
| } |
HeadPunchDemo/Assets/Scripts/Weapons/Concrete/PuncherWeapon.cs
Lines 1 to 46 in e995e40
| using Scripts.Animations.Puncher; | |
| using UnityEngine; | |
| namespace Scripts.Weapons.Concrete | |
| { | |
| public sealed class PuncherWeapon : BaseSimpleWeapon, IFullAutoWeapon | |
| { | |
| [SerializeField] | |
| private PuncherAnimator _animator; | |
| private float _lastShootTime; | |
| private bool _fireToggled; | |
| private void OnEnable() | |
| { | |
| _animator.OnAnimationHitEvent += HandleAnimationHit; | |
| } | |
| private void OnDisable() | |
| { | |
| _animator.OnAnimationHitEvent -= HandleAnimationHit; | |
| _fireToggled = false; | |
| } | |
| private void Update() | |
| { | |
| if (_fireToggled) | |
| TryPunch(); | |
| } | |
| public void TryStartFiring() => _fireToggled = true; | |
| public void TryStopFiring() => _fireToggled = false; | |
| private void TryPunch() | |
| { | |
| if (Time.time <= _lastShootTime + weaponData.Cooldown) | |
| return; | |
| if (_animator.IsFiring) | |
| return; | |
| _lastShootTime = Time.time; | |
| _animator.PlayFire(); | |
| } | |
| } | |
| } |
Ниже приведено видео, на котором показано нанесение урона оружием и соответствующее ослабевание головы при получении урона:
head_weakened.mp4
Для нанесения видимого урона реализована механика покраски. Для этого используются шейдеры.
Код реализации
HeadPunchDemo/Assets/Scripts/Interactive/Painting/Paintable.cs
Lines 1 to 109 in e995e40
| using Scripts.Helpers.Rendering; | |
| using System.Collections.Generic; | |
| using UnityEngine; | |
| using UnityEngine.Rendering; | |
| namespace Scripts.Interactive.Painting | |
| { | |
| [DisallowMultipleComponent] | |
| [RequireComponent(typeof(Renderer), typeof(Rigidbody), typeof(MeshCollider))] | |
| public sealed class Paintable : MonoBehaviour, IPaintable | |
| { | |
| private Renderer _renderer; | |
| private Material _material; | |
| #region Graphic | |
| private const int TexSize = 1024; | |
| private const string SplatmaskShaderName = "Ink/Splatmask"; | |
| private const string BlendShaderName = "Ink/Blend"; | |
| private Material _splatmaskMat; | |
| private Material _blendMat; | |
| private RenderTexture _mainTexture; | |
| private RenderTexture _inkTexture; | |
| private RenderTexture _tmpTexture; | |
| private CommandBuffer _commandBuffer; | |
| #endregion | |
| private List<Color> _paintedColors; | |
| private void Start() | |
| { | |
| _renderer = GetComponent<Renderer>(); | |
| _material = _renderer.material; | |
| if (_material.mainTexture == null) | |
| { | |
| _mainTexture = new RenderTexture(TexSize, TexSize, 0, RenderTextureFormat.ARGBFloat); | |
| _mainTexture.Create(); | |
| Graphics.Blit(CommonTextures.WhitePixel, _mainTexture); | |
| _material.mainTexture = _mainTexture; | |
| } | |
| Shader splatmaskShader = Shader.Find(SplatmaskShaderName); | |
| Shader blendShader = Shader.Find(BlendShaderName); | |
| _splatmaskMat = new Material(splatmaskShader); | |
| _blendMat = new Material(blendShader); | |
| _inkTexture = new RenderTexture(TexSize, TexSize, 0, RenderTextureFormat.ARGBFloat); | |
| _inkTexture.Create(); | |
| _tmpTexture = new RenderTexture(TexSize, TexSize, 0, RenderTextureFormat.ARGBFloat); | |
| _tmpTexture.Create(); | |
| _renderer.material.SetTexture("_PaintTex", _inkTexture); | |
| _commandBuffer = new CommandBuffer(); | |
| _paintedColors = new List<Color>(); | |
| } | |
| private void OnDestroy() | |
| { | |
| if (_mainTexture != null) | |
| _mainTexture.Release(); | |
| _inkTexture.Release(); | |
| _tmpTexture.Release(); | |
| } | |
| public Color GetMostColor() | |
| { | |
| if (_paintedColors.Count == 0) | |
| { | |
| Debug.LogWarning("No colors have been painted", gameObject); | |
| return Color.clear; | |
| } | |
| return Helpers.ColorMixing.ColorMixer.GetBlendedColor(_paintedColors); | |
| } | |
| // TODO: UV islands seamless | |
| public void Paint(Vector3 hitPosition, PaintParams paintParams) | |
| { | |
| _commandBuffer.Clear(); | |
| _splatmaskMat.SetVector(Shader.PropertyToID("_SplatPos"), hitPosition); | |
| _splatmaskMat.SetVector(Shader.PropertyToID("_InkColor"), paintParams.inkColor); | |
| _splatmaskMat.SetFloat(Shader.PropertyToID("_Radius"), paintParams.radius); | |
| _splatmaskMat.SetFloat(Shader.PropertyToID("_Strength"), paintParams.strength); | |
| _splatmaskMat.SetFloat(Shader.PropertyToID("_Hardness"), paintParams.hardness); | |
| _commandBuffer.SetRenderTarget(_tmpTexture); | |
| _commandBuffer.DrawRenderer(_renderer, _splatmaskMat); | |
| _commandBuffer.SetRenderTarget(_inkTexture); | |
| _commandBuffer.Blit(_tmpTexture, _inkTexture, _blendMat); | |
| Graphics.ExecuteCommandBuffer(_commandBuffer); | |
| _paintedColors.Add(paintParams.inkColor); | |
| } | |
| } | |
| } |
Основной шейдер выглядит следующим образом:
Данный шейдер "наносит" текстурку краски поверх базовой текстуры объекта. Для улучшения визуала также строится карта нормалей по текстуре краски.
Нанесение краски и смешивание производится на GPU, с помощью шейдера-маски для нанесения цвета, шейдера-смешивания и Blit для копирования временной текстуры в текстуру краски.
Также реализована механика покраски предметов при столкновении.
Если красящим объектом выступает голова манекена, её цвет рассчитывается как среднее значение всех нанесённых по ней ударов. Например, два красных и два синих удара дадут фиолетовый результат при покраске.
Код реализации
| using UnityEngine; | |
| namespace Scripts.Interactive.Painting | |
| { | |
| public sealed class CollisionPainter : MonoBehaviour | |
| { | |
| [SerializeField] | |
| private PaintParams _paintParams = PaintParams.Default; | |
| // Hardcode | |
| private const int PaintableLayer = 7; | |
| public void InitializePaint(PaintParams paintParams) | |
| { | |
| _paintParams = paintParams; | |
| } | |
| private void OnCollisionEnter(Collision collision) | |
| { | |
| if (collision.gameObject.layer == PaintableLayer && collision.collider.TryGetComponent<IPaintable>(out IPaintable paintable)) | |
| { | |
| paintable.Paint(collision.GetContact(0).point, _paintParams); | |
| } | |
| } | |
| private void OnCollisionStay(Collision collision) | |
| { | |
| if (collision.gameObject.layer == PaintableLayer && collision.collider.TryGetComponent<IPaintable>(out IPaintable paintable)) | |
| { | |
| paintable.Paint(collision.GetContact(0).point, _paintParams); | |
| } | |
| } | |
| } | |
| } |
Ниже показан пример:
collision_painting.mp4
Манекен при достижении им нулевого значения здоровья распадается на составные части, которые представлены в коде следующими классами:
Код реализации
HeadPunchDemo/Assets/Scripts/Mannequin/Mannequin.cs
Lines 1 to 99 in e995e40
| using Scripts.Interactive.Punching; | |
| using Scripts.Cinematic; | |
| using System; | |
| using UnityEngine; | |
| using Scripts.Interactive.Painting; | |
| using Scripts.Interactive.Debrisifying; | |
| namespace Scripts.Mannequin | |
| { | |
| public sealed class Mannequin : MonoBehaviour | |
| { | |
| [SerializeField] | |
| private float MaxHealth; | |
| [Header("Body Parts")] | |
| [SerializeField] | |
| private ManneqHead _head; | |
| [SerializeField] | |
| private ManneqBody _body; | |
| [SerializeField] | |
| private Paintable _headVisual; | |
| [SerializeField] | |
| private Paintable _bodyVisual; | |
| private float _health; | |
| public float HealthNormalized => Mathf.Clamp01(_health / MaxHealth); | |
| public float HeadEnduranceNormalized => _head.EnduranceNormalized; | |
| public float BodyEnduranceNormalized => _body.EnduranceNormalized; | |
| public event Action OnDamageReceived; | |
| private void Awake() | |
| { | |
| _health = MaxHealth; | |
| } | |
| private void OnEnable() | |
| { | |
| _head.OnDamaged += HandleDamage; | |
| _body.OnDamaged += HandleDamage; | |
| } | |
| private void OnDisable() | |
| { | |
| _head.OnDamaged -= HandleDamage; | |
| _body.OnDamaged -= HandleDamage; | |
| } | |
| private void HandleDamage(PunchData punchData) | |
| { | |
| _health -= punchData.Damage; | |
| OnDamageReceived?.Invoke(); | |
| if (_health <= 0) | |
| { | |
| Die(punchData); | |
| } | |
| } | |
| public void Die(PunchData punchData) | |
| { | |
| if (_headVisual.TryGetComponent<Debrisable>(out Debrisable headDeb)) | |
| { | |
| // If the final punch was at head punch it and make it bleed | |
| bool headCrit = punchData.HitObject == _head.gameObject; | |
| if (headCrit) | |
| { | |
| PaintParams trailsParams = new PaintParams() | |
| { | |
| inkColor = _headVisual.GetMostColor(), | |
| radius = 0.15f, | |
| hardness = 0.45f, | |
| strength = 0.45f, | |
| }; | |
| headDeb.BecomeDebrisWithKnockbackAndCollisionPainting(punchData, trailsParams); | |
| KillCamSampleService.PlayKillCam(_headVisual.gameObject); | |
| } | |
| // Otherwise just fall | |
| else | |
| { | |
| headDeb.BecomeDebris(); | |
| } | |
| } | |
| // The body always falls and becomes debris | |
| if (_bodyVisual.TryGetComponent<Debrisable>(out Debrisable bodyDeb)) | |
| bodyDeb.BecomeDebris(); | |
| Destroy(gameObject); | |
| } | |
| } | |
| } |
HeadPunchDemo/Assets/Scripts/Mannequin/ManneqBodyPart.cs
Lines 1 to 53 in e995e40
| using Scripts.Interactive.Punching; | |
| using System; | |
| using UnityEngine; | |
| namespace Scripts.Mannequin | |
| { | |
| [DisallowMultipleComponent] | |
| [RequireComponent(typeof(Rigidbody))] | |
| public abstract class ManneqBodyPart : MonoBehaviour, IPunchable | |
| { | |
| [SerializeField] | |
| private float _maxEndurance; | |
| public float MaxEndurance => _maxEndurance; | |
| public float Endurance { get; private set; } | |
| public float EnduranceNormalized => Mathf.Clamp01(Endurance / MaxEndurance); | |
| public bool Functioning { get; private set; } = true; | |
| public event Action<PunchData> OnDamaged; | |
| public Rigidbody Rigidbody { get; private set; } | |
| private void Awake() | |
| { | |
| Endurance = _maxEndurance; | |
| Rigidbody = GetComponent<Rigidbody>(); | |
| } | |
| public virtual void ReceivePunch(PunchData punchData) | |
| { | |
| if (Functioning) | |
| { | |
| Endurance -= punchData.Damage; | |
| OnEnduranceChanged(EnduranceNormalized); | |
| if (Endurance <= 0) | |
| Functioning = false; | |
| } | |
| HandleKnockback(punchData.KnockbackForce, ForceMode.Impulse); | |
| OnDamaged?.Invoke(punchData); | |
| } | |
| protected virtual void HandleKnockback(Vector3 knockbackForce, ForceMode forceMode = ForceMode.Impulse) | |
| { | |
| Rigidbody.AddForce(knockbackForce, forceMode); | |
| } | |
| protected virtual void OnEnduranceChanged(float enduranceNormalized) { } | |
| } | |
| } |
HeadPunchDemo/Assets/Scripts/Mannequin/ManneqHead.cs
Lines 1 to 17 in e995e40
| using Scripts.Ragdoll; | |
| using UnityEngine; | |
| namespace Scripts.Mannequin | |
| { | |
| public sealed class ManneqHead : ManneqBodyPart | |
| { | |
| [SerializeField] | |
| private HeadJointController _jointController; | |
| // Make head less stable | |
| protected override void OnEnduranceChanged(float enduranceNormalized) | |
| { | |
| _jointController.SetupJoint(EnduranceNormalized); | |
| } | |
| } | |
| } |
HeadPunchDemo/Assets/Scripts/Mannequin/ManneqBody.cs
Lines 1 to 23 in e995e40
| using UnityEngine; | |
| namespace Scripts.Mannequin | |
| { | |
| public sealed class ManneqBody : ManneqBodyPart | |
| { | |
| [SerializeField] | |
| private Vector2 _bodyAngleRange = new Vector2(10f, 0f); | |
| // Rotate body | |
| protected override void OnEnduranceChanged(float enduranceNormalized) | |
| { | |
| base.OnEnduranceChanged(enduranceNormalized); | |
| Transform rbTF = Rigidbody.transform; | |
| rbTF.localRotation = Quaternion.Euler( | |
| Mathf.Lerp(_bodyAngleRange.x, _bodyAngleRange.y, enduranceNormalized), | |
| rbTF.localRotation.eulerAngles.y, | |
| rbTF.localRotation.eulerAngles.z | |
| ); | |
| } | |
| } | |
| } |
Видео-демонстрация данной механики:
disassemble.mp4
Если добивающий удар наносится по голове, она отлетает от игрока, слегка поднимаясь вверх. В этот момент происходит переключение на вторую камеру, которая следует за ней до её исчезновения. К KillCam применены эффекты постобработки.
Для ожидания исчезновения головы используется CustomYieldInstruction:
Код реализации
HeadPunchDemo/Assets/Scripts/Misc/WaitUntilDestroyed.cs
Lines 1 to 16 in e995e40
| using UnityEngine; | |
| namespace Scripts.CustomYieldInstructions | |
| { | |
| public sealed class WaitUntilDestroyed : CustomYieldInstruction | |
| { | |
| private readonly GameObject _target; | |
| public override bool keepWaiting => _target != null; | |
| public WaitUntilDestroyed(GameObject target) | |
| { | |
| _target = target; | |
| } | |
| } | |
| } |
killcam.mp4
demo_gameplay.mp4
Из сторонних ассетов использованы Unity Starter Assets