Game Dev/Unity

Unity 6 Challenge 후기 겸 프로젝트 정리

Septentrions 2025. 1. 7. 00:37

https://play.unity.com/en/games/9ddf6f5a-cd6f-49e0-9d91-287a875e62e1/kimchi-shooting-unity-6-challenge

 

Kimchi Shooting (Unity 6 Challenge) on Unity Play

Kimchi Shooting (Kimchi Run Unity 6 Challenge) Source Code (Github) : https://github.com/noble-shake/Kimchi-Run-based-Shooting/tree/main All my Source Code Can be edited/deployed as free.

play.unity.com

 

유니티에서 유튜버 노마드 코더와 함께

무료 에셋을 이용해서 게임을 만들어보는 이벤트에 참가하게 되었다.

딱히 경품을 노리고 참가하는건 아니였지만, 휴가 내고 집에서 쉬면서 그냥 무언갈 집중해서 만들어보고 싶어서 참가했다.

 

 앞으로 만들 프로젝트와도 연계되기 떄문에 장르는 탄막 슈팅으로 정했다.

생각보다 기획한대로 착착 진행한 것 같다.

 

프로젝트 개발 순서는 다음과 같다.

1. 상점 스크롤링과 UI, 맵 구성, 에셋 준비

2. 캐릭터 조작

3. 아이템, 탄막, 적 유닛 오브젝트 풀링

4. 적 유닛 구현 (모듈화 방식)

5. 보스 구현

6. 패턴 시스템 및 레벨 디자인

7. WebGL 배포


1. 상점 스크롤링과 UI, 맵 구성, 에셋 준비

상점 스크롤링은 일종의 속도감(?)을 표현하기 위해 구현하였다.

디테일을 사람들이 알아챘는지 모르겠지만..

레벨 진행에 맞춰 상점 생성 주기 (Latency), 이동 속도를 변경해주었다.

 

그외에는 게임에 사용할 에셋들을 준비했다.

얼마전에 험블번들에서 산 에셋에 들어있던 탄막 에셋, UI 에셋 외에는

노마드 코더가 제공한 무료 에셋으로 화면 구성을 하였다.

어려운 부분은 딱히 없었다.

이떄까지만 해도 완벽병에 걸려 3일이나 걸릴줄은 몰랐으나..


2. 캐릭터 조작

간소하게 만든 플레이어블 캐릭터 스크립트

사실 캐릭터 조작에는 크게 신경을 쓰지 않았다.

공간 제약, 움직임, 발사, 충돌 처리가 전부이다.

체력, 파워 시스템 모두 게임 매니저가 관리하며 캐릭터에게는 최소한의 책임을 가지도록 했다.

수많은 탄막들과 아이템에 대한 처리를 플레이어 하나의 인스턴스한테 주는건 처리 지연만 늘리기 떄문이다.

심지어 파워 업 관련 스크립트도 플레이어블 탄막이 가지고 있다.


3. 아이템, 탄막, 적 유닛 오브젝트 풀링

 

풀링은 탄막 슈팅에서 없어서는 안될 시스템이다.

규모가 그렇게 큰 프로젝트는 아니였지만, 혹시 몰라서 풀링이 꽉 찰 문제까지 고려해서 구현하였다.

 

OnPoolInit()은 게임 실행 시, 미리 풀링해두는 함수이다.

실제 프로젝트에서는 한 프레임안에 만들어두는건 좋은 행동은 아니지만..

토이 프로젝트 잖아!! 과감하게 256개 바로 생성하도록 박아두었다.

 

GetItem()은 실제 적 유닛이 파괴 되었을 때, 호출 되는 함수이다.

아이템에 대한 차이를 크게 두지 않았기 때문에, 랜덤으로 스프라이트를 받도록 했다.

모든 Get 함수는 풀링을 전부 사용 했을 때를 대비하여 생성 코드도 추가하였다.

밸런스만 잘 맞추면 생성할 필요는 없을테지만..

 

ItemManager.cs

    private void OnPoolInit()
    {
        ItemPool = new List<ItemScript>();

        for (int i = 0; i < 256; i++)
        {
            ItemScript io = Instantiate<ItemScript>(ItemPrefab, ItemLair);
            io.gameObject.SetActive(false);
            ItemPool.Add(io);
        }
    }
    
        public ItemScript GetItem(bool golden=false)
    {

        Sprite TargetSprite;
        foreach (ItemScript e in ItemPool)
        {
            if (e.gameObject.activeSelf == false)
            {
                e.gameObject.SetActive(true);

                if (golden == false)
                {
                    ItemNormal toss = TossItem();
                    TargetSprite = Items[(int)toss];
                    e.itemType = (ItemType)((int)toss);
                    e.SetSprite = TargetSprite;
                    e.transform.SetParent(null);
                    return e;
                }
                else
                {
                    TargetSprite = GoldenItem;
                    e.itemType = ItemType.GoldenItem;
                    e.SetSprite = TargetSprite;
                    e.transform.SetParent(null);
                    return e;
                }
            }
        }

        ItemScript io = Instantiate<ItemScript>(ItemPrefab, ItemLair);
        io.gameObject.SetActive(false);
        ItemPool.Add(io);

        if (golden == false)
        {
            ItemNormal toss = TossItem();
            TargetSprite = Items[(int)toss];
            io.itemType = (ItemType)((int)toss);
            io.SetSprite = TargetSprite;
            io.transform.SetParent(null);
            return io;
        }
        else
        {
            TargetSprite = GoldenItem;
            io.itemType = ItemType.GoldenItem;
            io.SetSprite = TargetSprite;
            io.transform.SetParent(null);
            return io;
        }

 

 

아이템 스크립트는 별거 없다.

빈 오브젝트에 Sprite Renderer, RigidBody, Collider 컴포넌트들을 박아두었다.

김치 관련 Sprite 들은 ItemManager가 들고 있고, 풀링하는 시점에 랜덤으로 Sprite를 교체하는 방식이다.

RigidBody는 아이템이 튀어오르는 효과를 주기위한 용도이다.

Collider는 Trigger Enter를 사용하였다.

플레이어와 닿게 되면, 플레이어에게 빠르게 날라가도록 했다. (티가 잘 안난다..)

 

ItemScript.cs

using System.Collections;
using Unity.Burst.CompilerServices;
using UnityEngine;

public class ItemScript : MonoBehaviour
{
    [SerializeField] public ItemType itemType;
    [SerializeField] SpriteRenderer spriteRenderer;
    bool CollectionTrigger;
    float curTime;
    bool popCheck;

    public Sprite SetSprite { get { return spriteRenderer.sprite; } set { if (spriteRenderer == null) { spriteRenderer = GetComponent<SpriteRenderer>(); } spriteRenderer.sprite = value; } }

    private void Start()
    {
        // InitEffect();
        //CollectionTrigger = false;
        //GetComponent<Rigidbody2D>().gravityScale = 0.35f;
    }

    private void OnEnable()
    {

    }

    public void InitEffect(float alignX = 0f, float alignY = 3f)
    {
        GetComponent<Rigidbody2D>().AddForce(new Vector2(alignX, alignY), ForceMode2D.Impulse);
    }

    IEnumerator ItemCollect;

    private void OnTriggerEnter2D(Collider2D collision)
    {
        if (collision.CompareTag("player"))
        {
            //if (CollectionTrigger == true) return;
            //CollectionTrigger = true;
            if (ItemCollect == null)
            {
                ItemCollect = ItemCollection();
                StartCoroutine(ItemCollect);
            }
            // StartCoroutine(ItemCollection());
        }
    }

    IEnumerator ItemCollection()
    {
        while (true)
        {
            transform.position = Vector2.MoveTowards(transform.position, PlayerScript.Instance.transform.position, 10f);

            if (Vector2.Distance(transform.position, PlayerScript.Instance.transform.position) < 0.001f)
            {
                break;
            }

            yield return null;
        }

        if (itemType != ItemType.GoldenItem)
        {
            GameManager.Instance.AddPower(0.1f);
            GameManager.Instance.AddScore(100);
        }
        else
        {
            GameManager.Instance.AddPower(3f);
            GameManager.Instance.AddScore(1000);
        }
        ItemCollect = null;
        gameObject.SetActive(false);

    }
}

 

 

탄막 스크립트는 아주 단순하다.

정면으로 이동만 할 뿐이며, 스피드나 파워 업 체크만 있을 뿐이다.

 

Bullet.cs

using UnityEngine;

public class BulletScriptPlayer : BulletScript
{
    [SerializeField] float FireSpeed;
    float curTime;
    public bool PowerUp;


    void Start()
    {
        
    }

    // Update is called once per frame
    void Update()
    {
        curTime += Time.deltaTime;
        if (PowerUp == false)
        {
            transform.position += new Vector3(0f, FireSpeed * (Mathf.Clamp(curTime, 0.05f, 1f)) * Time.deltaTime, 0f);
        }
        else
        {
            transform.position += new Vector3(0f, FireSpeed * 1.25f * Time.deltaTime, 0f);
        }


        if (transform.position.y > 10f) Destroy(gameObject);
    }

    private void OnTriggerEnter2D(Collider2D collision)
    {
        if (collision.CompareTag(TagManger.GetTag(tags.Enemy)))
        { 
            
            Destroy(gameObject);
        }
    }

}

 


4. 적 유닛 구현 (모듈화 방식)

이번 프로젝트는 사실 상, 적 유닛 관련 스크립트가 알파이자 오메가이다.

탄막 슈팅 관련 스크립트는 여러번 짜봤는데..

이번 프로젝트에서 사용된 모듈화 방식이 매우 효율적이란 것을 깨달았다.

 

EnemyScript는 일종의 껍데기이다.

SpriteRenderer, Animator, Collider 관련 스크립트가 전부이다.

대신에 플레이어와는 달리 CommandAtk, CommandMV 를 가지고 있는데..

적 유닛의 행동 제어를 모듈로 딸깍 바꾸기만 하면, 다채로운 움직임을 보여줄 수 있다.

 

Command 스크립트들은 인터페이스이다.

해당 인터페이스를 EnemyScript가 인스턴스로 만들어서

일정 시간마다 (Update), 특정한 패턴 (Shoot)을 보여주는 방식이다.

 

예를 들어,

대각선으로 이동하는 인터페이스 (LeftDown.cs) 와 플레이어를 향한 탄막 발사 인터페이스(Trace.cs)

대각선으로 이동하는 인터페이스 (LeftDown.cs) 와 한 바퀴 탄막을 뿌리는 인터페이스 (CircleSpread.cs)

아래로 천천히 내려가는 인터페이스 (NormalDown.cs) 와 플레이어를 향한 탄막 발사 인터페이스(Trace.cs)

아래로 천천히 내려가는 인터페이스 (NormalDown.cs) 와 한 바퀴 탄막을 뿌리는 인터페이스 (CircleSpread.cs)

 

인터페이스, 스프라이트만 바꿔주면 수많은 종류의 몹 형태와 패턴을 만들 수가 있는거다.

해당 모듈 방식을 도입하니 보스전 마저 구현 난이도가 굉장히 내려간다..!

using System.Collections;
using Unity.Burst.CompilerServices;
using UnityEngine;

public class EnemyScript : MonoBehaviour
{
    [SerializeField] private MoveCommand commandMV;
    [SerializeField] private AttackCommand commandAtk;
    [SerializeField] private Transform ChildTrs;
    [SerializeField] SpriteRenderer spriteRenderer;
    [SerializeField] Animator anim;
    [SerializeField] int MaxHP = 5;
    [SerializeField] int CurHP;

    public Transform SetChild { get { return ChildTrs; } set { ChildTrs = value; spriteRenderer = ChildTrs.GetComponent<SpriteRenderer>(); spriteRenderer.color = Color.white; } }
    public MoveCommand SetMove { get { return commandMV; } set { commandMV = value; } }
    public AttackCommand SetAttack { get { return commandAtk; } set { commandAtk = value; } }

    public int SetHP { get { return MaxHP; } set { MaxHP = value; CurHP = value; } }

    public Sprite SetSprite { set { if (spriteRenderer == null) { spriteRenderer = GetComponentInChildren<SpriteRenderer>(); } spriteRenderer.sprite = value; spriteRenderer.color = Color.white; } }

    public Animator SetAnim { get { if (anim == null) { anim = GetComponentInChildren<Animator>(); } return anim; } }

    public void Shoot()
    {
        StartCoroutine(commandAtk.EnemyShoot());
    }

    private void Update()
    {
        if (commandAtk == null || commandMV == null) return;

        commandMV.EnemyMove();
        

        if (transform.position.y < -10f || transform.position.x < -8f || transform.position.x > 8f)
        {
            spriteRenderer.color = Color.white;
            transform.SetParent(EnemyManager.Instance.LairEnemy);
            if (Hit != null)
            {
                StopCoroutine(Hit);
                Hit = null;
            }

            Destroy(ChildTrs.gameObject);
            gameObject.SetActive(false);
        }
    }

    private void OnEnable()
    {
        CurHP = MaxHP;
    }

    private void enemyHit()
    {
        CurHP--;
        if (CurHP <= 0f)
        {
            spriteRenderer.color = Color.white;
            transform.SetParent(EnemyManager.Instance.LairEnemy);
            if (Hit != null)
            {
                StopCoroutine(Hit);
                Hit = null;
            }

            Destroy(ChildTrs.gameObject); 
            gameObject.SetActive(false);
            ItemScript io = ItemManager.Instance.GetItem(false);
            float posX = Random.Range(-0.1f, 0.1f);
            float posY = Random.Range(2.5f, 3.5f);
            io.transform.position = transform.position;
            io.InitEffect(posX, posY);
        }
    }

    private void OnTriggerEnter2D(Collider2D collision)
    {
        if (collision.CompareTag(TagManger.GetTag(tags.PlayerBullet)))
        {
            if (CurHP == 0) return;
            if (Hit == null)
            {
                Hit = HitEffect();
                StartCoroutine(Hit);
            }
            
            enemyHit();
        }
    }

    IEnumerator Hit;

    IEnumerator HitEffect()
    {

        spriteRenderer.color = Color.red;

        yield return new WaitForSeconds(0.11f);

        spriteRenderer.color = Color.white;
        Hit = null;
    }
}

 


5. 보스 구현

보스 관련 스크립트는 사실 상 적 유닛 구현의 연장선이다.

대신 단발성으로 나오고 사라지는 방식이 아니라, 여러 페이즈로 구분되는 패턴을 만들어야 한다.

패턴은 위에서 사용한 Command 방식으로 딸깍 구현만 하였다.

3가지의 패턴은 구현하는데 참 쉬웠다.

해당 스크립트만 건드리기만 해도 곧바로 디버깅이 가능했기 때문이다. (모듈화 최고!!@!!)

 

그리고, 보스전은 FSM를 이용해서 구현하였다.

코드이름은 BefaviourTree로 지었는데 방식은 FSM이다...구현하다가 방식을 바꿨다..

보스는 State에 의해서만 움직인다.

 

"Enum BossPhase"

Crated : 생성 되었을 때, 출현하는 연출

SpellReady : 다음 패턴, 체력 이벤트 상태

SpellBreak : 패턴이 끝난 뒤 이벤트 (아이템 떨구기, 보스가 죽어야 하는지, 다음 패턴으로 넘어가야 하는지 체크)

Pattern1, Pattern2, Pattern3 : 패턴 관련 이벤트

Wait : 단발성으로 진행되는 State가 끝날 떄까지 기다리는 상태

 

BehaviourTree() 함수는 Update에서 진행된다.

    private void BehaviourTree()
    {
        switch (bossbt)
        {
            case bossState.Created:
                StartCoroutine(CreatedBT());
                bossbt = bossState.Wait;
                break;
            case bossState.SpellReady:
                StartCoroutine(SpellReadyBT());
                bossbt = bossState.Wait;
                break;
            case bossState.SpellBreak:
                StartCoroutine(SpellBreakBT());
                bossbt = bossState.Wait;
                break;
            case bossState.Pattern1:
            case bossState.Pattern2:
            case bossState.Pattern3:
                bossTimer -= Time.deltaTime;
                if (bossTimer < 0f)
                {
                    bossTimer = 0f;
                    bossPattern = false;
                    bossbt = bossState.SpellBreak;
                    
                }
                GameManager.Instance.BossTimer.text = bossTimer.ToString();
                if (commandMV == null)
                {
                    return;
                }
                commandMV.EnemyMove();
                break;
            case bossState.Dead:
                StartCoroutine(bossDead());
                bossbt = bossState.Wait;
                break;
            case bossState.Wait:
                break;
        }
    }

 

사실 위에 함수만 보면, 나머지 구현은 너무 뻔하다.

패턴1, 패턴2, 패턴3 마다 Command만 살짝 바꾸기만 하면 끝일 뿐이다..!

 


6. 패턴 시스템 및 레벨 디자인

해당 부분은 직접 만든 "시퀀스 시스템"에 의해서 동작된다.

(코루틴, 타이밍) 페어로 이루어진 시스템을 순서대로 (시퀀스) 실행하는 시스템인데

내가 작업하는 모든 프로젝트에 투입되는 아주 꿀단지 같은 기능이다.

(내가 만들었지만, 자랑스럽다.)

 

간단하게 소개하면, 다음과 같다.

코루틴 집합 역할을 하는 Sequence Queue를 두고 Play 전에 미리 여러 기능의 모듈들을 등록.

동작의 끝을 체크하고 순서대로 동작하도록 했습니다.

Sequence Queue 에 들어갈 Module 들은 최대한 재사용 가능하도록 구현 했고,

 필요에 따라 커스터마이징 Module도 넣을 수 있도록 했습니다.

 

    // public void GenerateSequence(List<IEnumerator> methods, List<float> delays)
    public void Playing()
    {
        List<IEnumerator> methods = new List<IEnumerator>();
        List<float> timing = new List<float>();

        methods.Add(Phase1Wave1());
        timing.Add(8f);

        methods.Add(Phase1Wave2());
        timing.Add(2f);

        methods.Add(Phase2Wave1());
        timing.Add(6f);

        methods.Add(Phase2Wave2());
        timing.Add(12f);

        methods.Add(Phase2Wave3());
        timing.Add(6f);

        methods.Add(Phase2Wave4());
        timing.Add(8f);

        methods.Add(Phase3Wave1());
        timing.Add(12f);

        methods.Add(Phase4Boss());
        timing.Add(12f);

        Sequences.Instance.GenerateSequence(methods, timing);
    }

말이 레벨 디자인이지, 다음 처럼만 써놓으면 레벨 디자인이 끝이다.

 

코드도 사실 그렇게 어렵지 않다.

시퀀스 시스템은 아래 코드로 구현하였으며,

콜백 방식, 컴포지션(여러 시퀀스를 조합해서 사용) 등

필요에 의해 커스터마이징해서 사용중이다.

다이얼로그 시스템, 컷씬 자동화, 스테이지 디자인까지 넣을 수 있는, 정말 만능 시스템이다.

 

 

Sequence.cs

using System;
using System.Collections;
using System.Collections.Generic;
using System.Runtime.InteropServices;
using UnityEngine;

public enum SequenceMethods
{
    SeqObjectWalk,
    SeqDialPlay,
    SeqGenerate,
    SeqActive,
    SeqComposite,
    SeqCustom,
}

public class Sequences : MonoBehaviour
{
    public static Sequences Instance;

    [Header("Internal Setup")]
    [SerializeField] private bool queTrigger;
    public bool QueTrigger { get { return queTrigger; } set { queTrigger = value; } }

    protected Queue<IEnumerator> QueSeqeunce = new Queue<IEnumerator>();

    WaitForSecondsRealtime DelaySequencer = new WaitForSecondsRealtime(0f);
    bool SequenceProcessing;

    private void Awake()
    {
        if (Instance == null)
        {
            Instance = this;
        }
        else
        {
            Destroy(gameObject);
        }
    }

    protected IEnumerator SequencePlay()
    {
        while (QueSeqeunce.Count > 0)
        {
            queTrigger = true;
            yield return StartCoroutine(QueSeqeunce.Dequeue());
            yield return new WaitUntil(() => queTrigger == false);
        }
        SequenceProcessing = false;

    }

    public void GenerateSequence(List<IEnumerator> methods, List<float> delays)
    {
        SequenceProcessing = true;
        int count = methods.Count;

        for (int inum = 0; inum < count; inum++)
        {
            QueSeqeunce.Enqueue(Task(methods[inum], delays[inum]));
        }
        queTrigger = true;

        StartCoroutine(SequencePlay());
    }

    public IEnumerator Task(IEnumerator _method, float delay)
    {
        DelaySequencer.waitTime = delay;
        yield return DelaySequencer;
        yield return StartCoroutine(_method);
    }

    // EXAMPLE Sequencer
    #region Test Methods
    public IEnumerator SeqObjectWalk(GameObject _object, Vector3? _pos = null, float speed = 100f)
    {
        while (Vector3.Distance(_object.transform.position, _pos.Value) > 0.001f)
        {
            _object.transform.position = Vector3.MoveTowards(_object.transform.position, _pos.Value, speed * Time.deltaTime);
            yield return null;
        }
        queTrigger = false;
    }

    public IEnumerator SeqActive(GameObject _object, bool _active)
    {
        _object.SetActive(_active);
        yield return null;
        queTrigger = false;
    }
    #endregion

}

 


7.WebGL 배포

 

 

WebGL 개발을 이미하고 있는데

WebGL Publisher는 처음 사용해봤다.

실제 코드상에는 ScreenSize를 바꿔주는 코드를 넣었음에도, 반영이 안되는 건 조금 아쉬웠다..