본문 바로가기

Unity/작업방식

Unity: 리그 오브 레전드 스타일 체력바 만들기

오토 배틀러 장르 개인작 개발중에, 특성상 다양한 캐릭터가 한 화면에 등장하고 최대 체력과 현재 체력을 쉽고 빠르게 식별하기 위해 체력바를 가시성 좋게 고쳐야 할 필요성을 느꼈습니다. 

 

기존에 사용하던 캐릭터 체력바는 간단히 비율만 표기했었습니다.

 

캐릭터의 체력은 500에서 5000을 넘나드는데, 숫자로 표기하거나 추가적인 창을 띄워 보는것은 덕지덕지 불편할 수 있죠. 그렇다면 기존 게임들은 어떻게 해결하고 있을까요?

 

리그 오브 레전드는 한 칸당 100을 의미합니다.

전략적 팀 전투는 한 칸당 300을 의미하고요.

 

 

스타크래프트는 칸으로 나누었지만, 칸당 수치가 고정되어있지는 않습니다.

수치당 칸과 색으로 표기해서 가시성을 높였습니다.

 

제가 작업하고자 하는 부분은 셰이더로 체력 셀을 나눠주는 부분입니다.

이런 모양으로요.

Health Bar 는 4개의 이미지 컴포넌트로 구성되어있습니다.

 

가장 아래쪽에 쉴드, 그 위에 데미지를 받아 변경된 수치를 업데이트 해 줄 이펙트용 이미지, 실제 체력, 그리고 가장 위에서 몇 칸인지 나누어질 Separator입니다.

Separator를 제외하고는 단순하게 Image 컴포넌트만으로 구현되며, Separator는 커스텀 UI 셰이더로 칸막이를 그려줄겁니다.

 

 

셰이더에서는 몇 가지 수치를 프로퍼티로 받고요, 스크립트에서 업데이트 해 줄 겁니다.

Shader "ABS/UI/Health Separator"
{
    Properties
    {
        [PerRendererData] _MainTex ("Sprite Texture", 2D) = "white" {}
        _Color ("Tint", Color) = (0,0,0,1)
        
        _Steps ("Steps", Float) = 1
        _HSRatio ("Hp-Shield Ratio", Range(0,1)) = 1
        _Width ("Rect Width", Int) = 160
        _Thickness ("Thickness", Float) = 1
        
        [MaterialToggle] PixelSnap ("Pixel snap", Float) = 0
        [Toggle(CLOSE_BAR_ON)] CloseBar ("Close Bar", Float) = 1

        _StencilComp ("Stencil Comparison", Float) = 8
        _Stencil ("Stencil ID", Float) = 0
        _StencilOp ("Stencil Operation", Float) = 0
        _StencilWriteMask ("Stencil Write Mask", Float) = 255
        _StencilReadMask ("Stencil Read Mask", Float) = 255

        _ColorMask ("Color Mask", Float) = 15

        [Toggle(UNITY_UI_ALPHACLIP)] _UseUIAlphaClip ("Use Alpha Clip", Float) = 0
    }

    SubShader
    {
        Tags
        {
            "Queue"="Transparent"
            "IgnoreProjector"="True"
            "RenderType"="Transparent"
            "PreviewType"="Plane"
            "CanUseSpriteAtlas"="True"
        }

        Stencil
        {
            Ref [_Stencil]
            Comp [_StencilComp]
            Pass [_StencilOp]
            ReadMask [_StencilReadMask]
            WriteMask [_StencilWriteMask]
        }

        Cull Off
        Lighting Off
        ZWrite Off
        ZTest [unity_GUIZTestMode]
        Blend One OneMinusSrcAlpha
        ColorMask [_ColorMask]

        Pass
        {
            Name "Default"
        CGPROGRAM
            #pragma vertex vert
            #pragma fragment frag
            #pragma target 2.0

            #pragma multi_compile _ PIXELSNAP_ON
            #pragma multi_compile _ CLOSE_BAR_ON

            #include "UnityCG.cginc"
            #include "UnityUI.cginc"

            #pragma multi_compile_local _ UNITY_UI_CLIP_RECT
            #pragma multi_compile_local _ UNITY_UI_ALPHACLIP

            struct appdata_t
            {
                float4 vertex   : POSITION;
                float4 color    : COLOR;
                float2 texcoord : TEXCOORD0;
                UNITY_VERTEX_INPUT_INSTANCE_ID
            };

            struct v2f
            {
                float4 vertex   : SV_POSITION;
                fixed4 color    : COLOR;
                float2 texcoord  : TEXCOORD0;
                float4 worldPosition : TEXCOORD1;
                half4  mask : TEXCOORD2;
                UNITY_VERTEX_OUTPUT_STEREO
            };

            sampler2D _MainTex;
        
            fixed4 _Color;
            fixed4 _TextureSampleAdd;
            float4 _ClipRect;
            float4 _MainTex_ST;
            float _UIMaskSoftnessX;
            float _UIMaskSoftnessY;

            half _Steps;
            half _HSRatio;
            half _Thickness;
            half _Width;

            v2f vert(appdata_t v)
            {
                v2f OUT;
                
                UNITY_SETUP_INSTANCE_ID(v);
                UNITY_INITIALIZE_VERTEX_OUTPUT_STEREO(OUT);
                
                float4 vPosition = UnityObjectToClipPos(v.vertex);
                OUT.worldPosition = v.vertex;

                // 픽셀 스냅 기능 추가함
                #ifdef PIXELSNAP_ON
                OUT.vertex = UnityPixelSnap(vPosition);
                #else
                OUT.vertex = vPosition;
                #endif
                
                float2 pixelSize = vPosition.w;
                pixelSize /= float2(1, 1) * abs(mul((float2x2)UNITY_MATRIX_P, _ScreenParams.xy));
                
                float4 clampedRect = clamp(_ClipRect, -2e10, 2e10);
                float2 maskUV = (v.vertex.xy - clampedRect.xy) / (clampedRect.zw - clampedRect.xy);

                OUT.texcoord = TRANSFORM_TEX(v.texcoord.xy, _MainTex);
                OUT.mask = half4(v.vertex.xy * 2 - clampedRect.xy - clampedRect.zw, 0.25 / (0.25 * half2(_UIMaskSoftnessX, _UIMaskSoftnessY) + abs(pixelSize.xy)));
                
                OUT.color = v.color * _Color;
                return OUT;
            }

            fixed4 frag(v2f IN) : SV_Target
            {
                half4 c = IN.color * (tex2D(_MainTex, IN.texcoord) + _TextureSampleAdd);

                // 그리지 않음
                if (IN.texcoord.x > _HSRatio)
                {
                    c.a = 0;
                }
                else
                {
                    #ifdef CLOSE_BAR_ON
                    const half width = _Width * _HSRatio - _Thickness;
                    #else
                    const half width = _Width * _HSRatio;
                    #endif
                    
                    const half x = IN.texcoord.x * _Width;
                    const half step = _Steps + 0.000000001;
                    const half cell = width / step;

                    // uv.x로 가로 위치를 구하고 한 칸 길이로 나눈 나머지가 두께보다 적으면 색칠.
                    // 각 셀의 왼쪽에 바가 그려진다.
                    if (x % cell > cell - _Thickness)
                    {
                        c = _Color;
                    }
                    else
                    {
                        c.a = 0;
                    }
                }
                
                c.rgb *= c.a;

                #ifdef UNITY_UI_CLIP_RECT
                half2 m = saturate((_ClipRect.zw - _ClipRect.xy - abs(IN.mask.xy)) * IN.mask.zw);
                c.a *= m.x * m.y;
                #endif
                
                #ifdef UNITY_UI_ALPHACLIP
                clip (c.a - 0.001);
                #endif

                return c;
            }
        ENDCG
        }
    }
}

 

using System.Collections;
using UnityEngine;
using UnityEngine.UI;

namespace ACF.Tests
{
	// [ExecuteAlways]
	[RequireComponent(typeof(Image))]
	public class HealthBarTest : MonoBehaviour
	{
		private const string STEP = "_Steps";
		private const string RATIO = "_HSRatio";
		private const string WIDTH = "_Width";
		private const string THICKNESS = "_Thickness";
		
		private static readonly int floatSteps = Shader.PropertyToID(STEP);
		private static readonly int floatRatio = Shader.PropertyToID(RATIO);
		private static readonly int floatWidth = Shader.PropertyToID(WIDTH);
		private static readonly int floatThickness = Shader.PropertyToID(THICKNESS);
		
		[Range(0, 2800f)] public float Hp = 1000f;
		[Range(0, 2800f)] public float MaxHp = 1000f;
		[Range(0, 920f)] public float Sp = 0f;
		[Range(0, 10f)] public float speed = 3f;
		
		public float hpShieldRatio;
		public float RectWidth = 100f;
		[Range(0, 5f)]public float Thickness = 2f;
		
		public Image hp;
		public Image damaged;
		public Image sp;
		public Image separator;

		[ContextMenu("Create Material")]
		private void CreateMaterial()
		{
			// if (separator.material == null)
			{
				separator.material = new Material(Shader.Find("ABS/UI/Health Separator"));
			}
		}

		private IEnumerator Start()
		{
			yield return new WaitForSeconds(2.0f);

			Hp = 1500;
			MaxHp = 1500;
			Sp = 400;

			while (Sp > 0)
			{
				Sp -= 280 * Time.deltaTime;
				yield return null;
			}

			Sp = 0;

			yield return new WaitForSeconds(2f);

			for (int i = 0; i < 8; i++)
			{
				Hp -= 120;
				yield return new WaitForSeconds(1f);
			}
			
			for (int i = 0; i < 8; i++)
			{
				MaxHp += 200;
				Hp = MaxHp;
				
				yield return new WaitForSeconds(1f);
			}
			
#if UNITY_EDITOR
			UnityEditor.EditorApplication.isPlaying = false;
#endif
		}
		

		private void Update()
		{
			if (MaxHp < Hp)
			{
				MaxHp = Hp;
			}

			float step;

			// 쉴드가 존재 할 때
			if (Sp > 0)
			{
				// 현재체력 + 쉴드 > 최대 체력
				if (Hp + Sp > MaxHp)
				{
					hpShieldRatio = Hp / (Hp + Sp);
					sp.fillAmount = 1f;
					step = (Hp) / 300f;
					hp.fillAmount = Hp / (Hp + Sp);
				}
				else
				{
					sp.fillAmount = (Hp + Sp) / MaxHp;
					hpShieldRatio = Hp / MaxHp;
					step = Hp / 300f;
				
					hp.fillAmount = Hp / MaxHp;
				}
			}
			else
			{
				sp.fillAmount = 0f;
				step = MaxHp / 300f;
				hpShieldRatio = 1f;
				
				hp.fillAmount = Hp / MaxHp;
			}
			
			// sp.fillAmount = 1 - hpShieldRatio;
			
			damaged.fillAmount = Mathf.Lerp(damaged.fillAmount, hp.fillAmount, Time.deltaTime * speed);
			
			separator.material.SetFloat(floatSteps, step);
			separator.material.SetFloat(floatRatio, hpShieldRatio);
			separator.material.SetFloat(floatWidth, RectWidth);
			separator.material.SetFloat(floatThickness, Thickness);
		}
	}
}

 

적용 결과

체력을 카로셀 기물들은 1/1로 설정해두어 제대로 셀이 보이지 않네요.

체력 550

 

 

hpbar.unitypackage
0.01MB