본문 바로가기
Unity/스크립터블오브젝트

Scriptable Object - 응용: Destructible, Brain(AI) (7)

by PlaneK 2020. 6. 2.

Destructible

일정 데미지가 누적되면 파괴되는 개체가 있다. 파괴되는 연출을 다형화하기 위해 ScriptableObject로 정의해보자.

파괴 이벤트는 건물 파괴의 시각적 이펙트를 보여준 후 씬에서 해당 오브젝트를 Destroy한다. 이펙트가 보여지는 중 Destroy에 의해 끊기면 안되므로 코루틴을 사용한다.

 파괴에 대한 연출을 Scriptable Object로 정의하고 Inspector뷰에서 구성할 수 있도록 구현해보자.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
using UnityEngine;
 
public class Destructible : MonoBehaviour
{
    public float Health = 100;
    public float DamagePerTankCollision = 40;
 
    public DestructionSequence DestructionSequence;
 
    public void TakeDamage(float damage)
    {
        if (Health < 0return;
 
        Health -= damage;
 
        if (Health < 0 && DestructionSequence)
            StartCoroutine(DestructionSequence.SequenceCoroutine(this));
    }
 
    public void OnCollisionEnter(Collision collision)
    {
        if (collision.gameObject.CompareTag("Player"))
        {
            TakeDamage(DamagePerTankCollision);
        }
    }
}
 

이펙트 생성부터 Destory 까지 Step by Step으로 처리해야 한다. 따라서 파괴 시퀀스는 코루틴으로 구현됐다. 사용자는 StartCoroutine으로 해당 프로퍼티를 호출한다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
using UnityEngine;
using System.Collections;
using UnityEngine.Audio;
 
[CreateAssetMenu(menuName="Destruction/Controlled Demolition sequence")]
public class ControlledDemolition : DestructionSequence
{
    // 이펙트 지속 시간, 건물이 땅으로 꺼지는 속도.
    public float CollapseTime = 1f;
    // 건물 흔들림 조정 방식, Move vs Scale
    public float MovementScale = 0.2f;
    public bool MoveInsteadOfScale = false;
    public ParticleSystem DustParticles;
    public float DustSurroundFactor = 1.3f;
    public AudioEvent CollapseAudio;
    public AudioMixerGroup CollapseAudioGroup;
    public float CameraShakeStrength = 0;
    public override IEnumerator SequenceCoroutine(MonoBehaviour runner)
    {
        var transform = runner.transform;
 
        Vector3 basePosition = transform.position;
        Vector3 baseScale = transform.localScale;
 
        var localBounds = runner.GetComponentInChildren<MeshFilter>().sharedMesh.bounds;
 
        ParticleSystem particles = null;
        if (DustParticles)
        {
            particles = Instantiate(DustParticles);
            particles.transform.position = basePosition;
            particles.transform.rotation = transform.rotation;
 
            var shapeModule = particles.shape;
            shapeModule.scale = new Vector3(localBounds.size.x, 0, localBounds.size.z) * DustSurroundFactor;
        }
 
        if (CollapseAudio)
        {
            var audioPlayer = new GameObject("Collapse audio"typeof (AudioSource)).GetComponent<AudioSource>();
            audioPlayer.transform.position = basePosition;
            audioPlayer.outputAudioMixerGroup = CollapseAudioGroup;
            CollapseAudio.Play(audioPlayer);
            Destroy(audioPlayer.gameObject, audioPlayer.clip.length*audioPlayer.pitch);
        }
 
        foreach (var collider in runner.GetComponentsInChildren<Collider>())
            collider.enabled = false;
 
        if (CameraShakeStrength > 0)
            runner.StartCoroutine(DoCameraShake());
 
        float startTime = Time.time;
        while (Time.time < startTime + CollapseTime)
        {
            float progress = Mathf.Clamp01((Time.time - startTime) / CollapseTime);
 
            if (MoveInsteadOfScale)
            {
                transform.position = basePosition + new Vector3(Random.Range(-1f, 1f), 0, Random.Range(-1f, 1f))*MovementScale +
                                     Vector3.down*progress*localBounds.size.y;
            }
            else
            {
                transform.localScale = new Vector3(baseScale.x, Mathf.Lerp(baseScale.y, 0, progress), baseScale.z);
                transform.position = basePosition + new Vector3(Random.Range(-1f, 1f), 0, Random.Range(-1f, 1f))*MovementScale;
            }
            yield return null;
        }
 
        // 건물(runner)이 Destroy 되면
        Destroy(runner.gameObject);
        if (particles)
        {
            var emission = particles.emission;
            emission.enabled = false;
 
            // 이 후 이펙트(파티클)가 Destroy 된다.
            Destroy(particles.gameObject, particles.duration);
        }
    }
 
    private IEnumerator DoCameraShake()
    {
        var camera = Camera.main.transform;
        var basePos = camera.localPosition;
        float startTime = Time.time;
        while (Time.time < startTime + CollapseTime)
        {
            float progress = Mathf.Clamp01((Time.time - startTime) / CollapseTime);
            camera.localPosition = basePos + new Vector3(Random.Range(-1f, 1f), Random.Range(-1f, 1f), 0* CameraShakeStrength * (1f - progress);
            yield return null;
        }
        camera.localPosition = basePos;
    }
}
 

if 문으로 각 효과들을 그룹화 한 것을 볼 수 있다. 그룹 별로 어떤 효과인지 쉽게 파악 가능하다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
using UnityEngine;
using System.Collections;
 
[CreateAssetMenu(menuName="Destruction/Hide Behind Effect")]
public class HideBehindEffect : DestructionSequence
{
    public GameObject Effect;
    public float DestroyOriginalAfterTime = 1f;
 
    public override IEnumerator SequenceCoroutine(MonoBehaviour runner)
    {
        // 이펙트 생성
        var effect = Instantiate(Effect, runner.transform.position, runner.transform.rotation);
        yield return new WaitForSeconds(DestroyOriginalAfterTime);
        Destroy(runner.gameObject);
        Destroy(effect);
    }
}
 

간단한 버전이다. 이펙트가 진행되는 도중 Destroy에 의해 끊길 수 있다. Destroy가 호출되면 해당 프레임 동안은 개체가 계속 유지되기 때문에 그 밑의 코드도 진행될 수 있다. 하지만 yield return에 의해 프레임을 지연하면 개체는 사라졌기 때문에 다시 코드로 돌아가지 못한다.

 

Brain(AI)

TankBrain은 탱크의 움직임을 정의한다. TankBrain은 TankMovement와 TankShooting을 참조해서 움직임과 에임을 컨트롤한다. 

TankBrain은 리팩토링의 일환이다. 사용자 쪽에서 정의하던 탱크의 움직임을 다형화하기 위해 Scriptabl Object로 추출해낸 것이다.

 

위와 같이 디자이너는 각 Brain마다 Inspector뷰로 프로퍼티를 조정할 수 있다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
using UnityEngine;
 
[CreateAssetMenu(menuName="Brains/Player Controlled")]
public class PlayerControlledTank : TankBrain
{
 
    public int PlayerNumber;
    private string m_MovementAxisName;
    private string m_TurnAxisName;
    private string m_FireButton;
 
 
    public void OnEnable()
    {
        // 로컬에서 2명이 하나의 컨트롤러를 통해 플레이 할 때 필요함
        m_MovementAxisName = "Vertical" + PlayerNumber;
        m_TurnAxisName = "Horizontal" + PlayerNumber;
        m_FireButton = "Fire" + PlayerNumber;
    }
 
    public override void Think(TankThinker tank)
    {
        // Player가 직접 컨트롤하므로 인풋을 감지한다.
        var movement = tank.GetComponent<TankMovement>();
        movement.Steer(Input.GetAxis(m_MovementAxisName), Input.GetAxis(m_TurnAxisName));
        
        // Player가 직접 컨트롤하므로 인풋을 감지한다.
        var shooting = tank.GetComponent<TankShooting>();
        if (Input.GetButton(m_FireButton))
            shooting.BeginChargingShot();
        else
            shooting.FireChargedShot();
    }
}
 

플레이어의 brain은 인풋 감지만 정의됐다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
using UnityEngine;
using System.Collections;
using System.Linq;
 
[CreateAssetMenu(menuName="Brains/Simple sniper")]
public class SimpleSniper : TankBrain
{
 
    public float aimAngleThreshold = 2f;
    [MinMaxRange(00.05f)]
    public RangedFloat chargeTimePerDistance;
    [MinMaxRange(010)]
    public RangedFloat timeBetweenShots;
 
    public override void Think(TankThinker tank)
    {
        GameObject target = tank.Remember<GameObject>("target");
        var movement = tank.GetComponent<TankMovement>();
 
        if (!target)
        {
            // Find the nearest tank that isn't me
            target =
                GameObject
                    // "Player" 태그를 가진 개체 배열을 반환(본인 포함)
                    .FindGameObjectsWithTag("Player")
                    // tank와 go 간 거리를 기준으로 오름차순 정렬
                    .OrderBy(go => Vector3.Distance(go.transform.position, tank.transform.position))
                    // 첫 번째 개체 또는 Null 반환
                    .FirstOrDefault(go => go != tank.gameObject);
 
            tank.Remember<GameObject>("target");
        }
 
        if (!target)
        {
            // No targets left - drive in a victory circles
            movement.Steer(0.5f, 1f);
            return;
        }
 
        // aim at the target
        Vector3 desiredForward = (target.transform.position - tank.transform.position).normalized;
        if (Vector3.Angle(desiredForward, tank.transform.forward) > aimAngleThreshold)
        {
            bool clockwise = Vector3.Cross(desiredForward, tank.transform.forward).y > 0;
            movement.Steer(0f, clockwise ? -1 : 1);
        }
        else
        {
            // Stop
            movement.Steer(0f, 0f);
        }
 
        // Fire at the target
        var shooting = tank.GetComponent<TankShooting>();
        if (!shooting.IsCharging)
        {
            if (Time.time > tank.Remember<float>("nextShotAllowedAfter"))
            {
                float distanceToTarget = Vector3.Distance(target.transform.position, tank.transform.position);
                float timeToCharge = distanceToTarget*Random.Range(chargeTimePerDistance.minValue, chargeTimePerDistance.maxValue);
                tank.Remember("fireAt", Time.time + timeToCharge);
                shooting.BeginChargingShot();
            }
        }
        else
        {
            float fireAt = tank.Remember<float>("fireAt");
            if (Time.time > fireAt)
            {
                shooting.FireChargedShot();
                tank.Remember("nextShotAllowedAfter", Time.time + Random.Range(timeBetweenShots.minValue, timeBetweenShots.maxValue));
            }
        }
    }
}
 

LINQ함수로 다른 플레이어의 탱크를 수집한다. Random함수를 이용해서 Move와 Shoot을 수행한다.

LinQ API.

First() : "반환 값이 없으면 Exception 발생"
FirstOrDefault() : "반환 값이 없으면 기본값(0, false, Null 등) 반환"
SingleOrDefault() : "반환 값이 없으면 Null 반환" + "반환 값이 유일하지 않으면 Exception 발생"

 

댓글