Skip to content

A small demo of an immersive and colorful punching system.

Notifications You must be signed in to change notification settings

antonworkgit/HeadPunchDemo

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

7 Commits
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

preview

Head Punch Demo

Данный проект разработан в рамках технического задания

Требования:

  • Механика избиения головы
  • Видимый урон
  • Иммерсивность, креатив

Что сделано

1. Механика ударов и оружие

В качестве сущности, которой можно наносить удары используется модель "манекена", которая состоит из:

  • Головы, которая сопротивляется ударам и возвращается в исходное положение. Однако, с получением урона голова ослабевает и перестает так же активно сопротивляться ударам
  • Торса, который при ослабевании просто наклоняется

При этом, урон с конечностей также передается в основу - сам манекен. При снижении здоровья манекена до нуля он распадается.

Если нанести добивающий удар по голове, она отлетит и включится отдельная камера с постобработкой, аналог Kill Cam

В проекте представлены 3 вида оружия:

  1. Бита, наносящая удары сбоку
  2. Автопанчер
  3. Перчатки
Код реализации Joint
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);
}
}
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);
}
}
Код реализации оружия
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);
}
}
}
}
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);
}
}
}
}
}
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;
}
}
}
}
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

2. Механика покраски

Для нанесения видимого урона реализована механика покраски. Для этого используются шейдеры.

Код реализации
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);
}
}
}

Основной шейдер выглядит следующим образом:

shader

Данный шейдер "наносит" текстурку краски поверх базовой текстуры объекта. Для улучшения визуала также строится карта нормалей по текстуре краски.

Нанесение краски и смешивание производится на 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

3. Механика "смерти"

Манекен при достижении им нулевого значения здоровья распадается на составные части, которые представлены в коде следующими классами:

Код реализации
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);
}
}
}
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) { }
}
}
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);
}
}
}
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

4. Механика KillCam

Если добивающий удар наносится по голове, она отлетает от игрока, слегка поднимаясь вверх. В этот момент происходит переключение на вторую камеру, которая следует за ней до её исчезновения. К KillCam применены эффекты постобработки.

Для ожидания исчезновения головы используется CustomYieldInstruction:

Код реализации
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

About

A small demo of an immersive and colorful punching system.

Resources

Stars

Watchers

Forks