주말동안 보스 스킬 구현과 각종 작업들을 마쳐서 1번 게임을 완성했다.

private IEnumerator DoBossAttack()
    {
        while(true)
        {
            for (int i = 0; i < 5; i++)
            {
                // 죽었을 때는 스킬이 시전되지 않고, 적 소환하지 않음 (최소 1초 대기)

                while (!shootingGameManager.GetIsAlivePlane())
                {
                    yield return new WaitForSeconds(1);
                }

                if (i < 4)
                {
                    StartCoroutine(shootingGameManager.UseBossSkill(i));
                }
                else
                {
                    StartCoroutine(shootingGameManager.NextPhase());
                }

                yield return new WaitForSeconds(4);
            }
        }
    }

 

EnemyContorller.cs에서 DoBossAttack() 함수를 반복하여 보스의 패턴을 만들었다.

 

아군이 죽어있을 때는 최소 1초간 정지하도록 하고, 이외에는 4초마다 패턴을 반복하도록 했다.

미도리->->유즈->아리스 순으로 스킬을 사용하고, 적을 소환하도록 했다. 이 5개를 한 사이클로 계속 반복하도록 하였다.

적은 1/2/3/4번 적이 반복하여 나오도록 했다.

 

public IEnumerator UseBossSkill(int skillNum)
    {
        if (skillNum == 0)
        {
            _audioManager.PlaySFX("MidoriSkill");
            _bossMidoriHalo.GetComponent<Image>().DOFade(1, 1);

            yield return new WaitForSeconds(1);

            _bossMidoriHalo.GetComponent<Image>().DOFade(0, 1);

            for(int i=0; i<10; ++i)
            {
                ShootEnemyBulletToMidoriPlane(_bossEnemy);

                yield return new WaitForSeconds(0.2f);
            }
        }

처음 구현한 것은 미도리 스킬이었다. 적의 공격이 아군의 비행기를 향해 발사하도록 만들었기 때문에 공격을 10번 하는 것으로 쉽게 구현했다.

 

else if (skillNum == 1)
        {
            _audioManager.PlaySFX("MidoriSkill");
            _bossMomoiHalo.GetComponent<Image>().DOFade(1, 1);

            yield return new WaitForSeconds(1f);

            _audioManager.PlaySFX("MomoiSkill");
            _bossMomoiHalo.GetComponent<Image>().DOFade(0, 1);

            for(int i = 0; i < 11; i += 2)
            {
                StartCoroutine(ShootEnemyBulletToMidoriPlaneCoroutine(_bossEnemy, new Vector2(-400 + (80 * i), YDownEnd)));
            }

            yield return new WaitForSeconds(0.5f);

            for (int i = 1; i < 11; i += 2)
            {
                StartCoroutine(ShootEnemyBulletToMidoriPlaneCoroutine(_bossEnemy, new Vector2(-400 + (80 * i), YDownEnd)));
            }

            yield return new WaitForSeconds(0.5f);

            for (int i = 0; i < 11; i += 2)
            {
                StartCoroutine(ShootEnemyBulletToMidoriPlaneCoroutine(_bossEnemy, new Vector2(-400 + (80 * i), YDownEnd)));
            }
        }

모모이의 스킬은 기존 함수를 조금 수정해야했다. 원래 유도로 공격하는 기능이었기 때문에 따로 방향을 받지 않았는데, 방향을 받는 것으로 수정했다. -400 ~ 400까지 80단위로 나누어 0/2/4/6/8/10 방향, 1/3/5/7/9 방향으로 번갈아 6발 / 5발 / 6발씩 발사하도록 구현했다.

else if (skillNum == 2)
        {
            _audioManager.PlaySFX("MidoriSkill");

            _bossYuzuHalo.transform.SetParent(_midoriPlane.transform);
            _bossYuzuHalo.GetComponent<RectTransform>().anchoredPosition = Vector2.zero;

            _bossYuzuHalo.GetComponent<Image>().DOFade(1, 1);

            yield return new WaitForSeconds(1f);

            _bossYuzuHalo.transform.SetParent(_haloParent.transform);

            yield return new WaitForSeconds(1f);

            StartCoroutine(ShootEnemyBulletToMidoriPlaneCoroutine(_bossEnemy, _bossYuzuHalo.GetComponent<RectTransform>().anchoredPosition, "YuzuGrenade"));

            yield return new WaitForSeconds(_yuzuBulletDuration);

            _bossYuzuHalo.GetComponent<Image>().DOFade(0, 1);

            // 플레이어가 날아오는 도중에 총알을 맞았을 경우 작동하지 않도록 함
            if(_yuzuGrenade.GetComponent<Image>().color != Color.clear)
            {
                _audioManager.PlaySFX("8bitBomb");
                _yuzuGrenade.SetActive(false);

                for (int i = -2; i < 3; ++i)
                {
                    for (int j = -2; j < 3; ++j)
                    {
                        if ((i >= -1 && i <= 1) && (j >= -1 && j <= 1)) continue;

                        // 각도를 균일하게 하기 위해 값을 크게 함
                        Vector2 targetPosition = new Vector2(_bossYuzuHalo.GetComponent<RectTransform>().anchoredPosition.x + (-10000 * i),
                            _bossYuzuHalo.GetComponent<RectTransform>().anchoredPosition.y + (-10000 * j));

                        StartCoroutine(ShootEnemyBulletToMidoriPlaneCoroutine(_bossYuzuHalo, targetPosition, "YuzuBullet"));
                    }
                }
            }
        }

유즈 스킬을 구현할 때 조금 애로사항이 있었다. 게임 상에서는 유탄을 날려서 원형 범위에 폭발하는 스킬을 사용하는데, 곡선으로 날아가는 것도 문제고 피하기가 너무 쉬웠다. 친구가 날아간 자리에서 폭발해서 사방으로 총알이 나아가면 어떻겠느냐는 의견을 내주어서 해당 의견을 채용했다.

// 적의 총알 객체를 발사하는 함수
    public IEnumerator ShootEnemyBulletToMidoriPlaneCoroutine(GameObject enemy, Vector2 direction, string bulletName = "Normal")
    {
        GameObject bullet = GetPrefab("EnemyBullet");
        bullet.GetComponent<RectTransform>().localScale = new Vector3(1, 1, 1);

        bullet.SetActive(true);
        bullet.GetComponent<Image>().color = Color.white;

        bullet.GetComponent<RectTransform>().anchoredPosition = new Vector2(enemy.GetComponent<RectTransform>().anchoredPosition.x,
            enemy.GetComponent<RectTransform>().anchoredPosition.y - 50);

        // 적들의 통상 공격
        if (bulletName == "Normal")
        {
            direction = ExtendBulletDirection(bullet.GetComponent<RectTransform>().anchoredPosition, direction);
        }
        // 유즈 유탄 공격
        else if (bulletName == "YuzuGrenade")
        {
            bullet.GetComponent<RectTransform>().localScale = new Vector3(6f, 6f, 6f);
            _yuzuGrenade = bullet;

            // 유탄이 헤일로 위치까지 날아가는 시간을 저장
            _yuzuBulletDuration = Vector2.Distance(bullet.GetComponent<RectTransform>().anchoredPosition, _bossYuzuHalo.GetComponent<RectTransform>().anchoredPosition) / 600 * EnemyBulletSpeed;

            // 유탄의 경로를 연장, UseBossSkill 부분에서 _yuzuBulletDuration이 경과한 이후 체크하기 위함
            direction = ExtendBulletDirection(bullet.GetComponent<RectTransform>().anchoredPosition, direction);
        }
        else if (bulletName == "YuzuBullet")
        {
            // 유즈 헤일로의 중심에서 총알이 나가는 것이기 때문에 y값을 제자리로 돌려줌
            bullet.GetComponent<RectTransform>().anchoredPosition = new Vector2(enemy.GetComponent<RectTransform>().anchoredPosition.x,
            enemy.GetComponent<RectTransform>().anchoredPosition.y);

            direction = ExtendBulletDirection(bullet.GetComponent<RectTransform>().anchoredPosition, direction);
        }

        LookRotation2D(bullet, direction);

        float bulletDuration = Vector2.Distance(bullet.GetComponent<RectTransform>().anchoredPosition, direction) / 600 * EnemyBulletSpeed;

        bullet.GetComponent<RectTransform>().DOLocalMove(direction, bulletDuration).SetEase(Ease.Linear);

        yield return new WaitForSeconds(bulletDuration);
        bullet.GetComponent<Image>().color = Color.clear;
        bullet.SetActive(false);
        _enemyBulletQueue.Enqueue(bullet);
    }

이를 위해서 함수를 좀 수정했다. 유탄이 날아가는 시간을 따로 저장하거나, 총알이 중앙 기준 y값 -50에서 나가기 때문에 해당 부분도 맞춰주는 등의 작업이 필요했다.

else if (skillNum == 3)
        {
            _audioManager.PlaySFX("MidoriSkill");

            _bossArisHalo.transform.SetParent(_midoriPlane.transform);
            _bossArisHalo.GetComponent<RectTransform>().anchoredPosition = Vector2.zero;

            _bossArisLazer.SetActive(true);
            StartCoroutine(_bossArisLazer.GetComponent<LazerController>().ChasePlane());

            _bossArisHalo.GetComponent<Image>().DOFade(1, 1);

            yield return new WaitForSeconds(1f);

            _bossArisHalo.transform.SetParent(_haloParent.transform);
            _bossArisHalo.GetComponent<Image>().DOFade(0, 1);
            _bossArisLazer.GetComponent<LazerController>().SetCanMoveLazer(false);

            yield return new WaitForSeconds(0.5f);

            _audioManager.PlaySFX("ArisSkill");
            StartCoroutine(_bossArisLazer.GetComponent<LazerController>().ShootLazer());

            yield return new WaitForSeconds(1f);

            _bossArisLazer.SetActive(false);
        }

아리스 스킬을 구현하는 것이 가장 어려웠다. 처음에는 보스와 플레이어를 레이저로 잇고, 잠시 후 레이저의 위치가 고정되며 그 뒤 레이저가 넓어지며 공격하는 스킬을 구상했다.

처음에는 선 모양 스프라이트를 넣어서 각도나 길이를 조정할까 생각했는데 아무리 생각해도 어려울 것 같아 다른 방법을 찾아보았다. 그러다가 LineRenderer를 발견했다. 처음 써보는 것이라 조금 헤맸다. 먼저 나는 Canvas를 사용하고 있기 때문에 Use World Space 옵션을 꺼야 했다. 또, 화면에 보이지 않아 Position.z를 -1로 설정해줘야 보였다.

public IEnumerator ChasePlane()
    {
        _canMoveLazer = true;

        _lineRenderer.startWidth = 0.01f;
        _lineRenderer.endWidth = 0.01f;

        _lineRenderer.startColor = Color.red;
        _lineRenderer.endColor = Color.red;

        while (_canMoveLazer)
        {
            // 보스의 눈에서 레이저가 나가도록 위치 설정
            _startPosition = new Vector2(_shootingGameManager._bossEnemy.GetComponent<RectTransform>().anchoredPosition.x + 28,
            _shootingGameManager._bossEnemy.GetComponent<RectTransform>().anchoredPosition.y - 2);

            _targetPosition = ExtendDirection(_startPosition,
                _shootingGameManager._midoriPlane.GetComponent<RectTransform>().anchoredPosition);

            _lineRenderer.SetPosition(0, _startPosition);
            _lineRenderer.SetPosition(1, _targetPosition);

            yield return null;
        }

        while (!_canMoveLazer)
        {
            _startPosition = new Vector2(_shootingGameManager._bossEnemy.GetComponent<RectTransform>().anchoredPosition.x + 28,
            _shootingGameManager._bossEnemy.GetComponent<RectTransform>().anchoredPosition.y - 2);

            _lineRenderer.SetPosition(0, _startPosition);

            SetColliderPosition();

            yield return null;
        }
    }

그리고 처음에는 보스와 플레이어 사이를 잇는 레이저였기 때문에 보스의 Child로 두었는데, 이렇게 하니 레이저가 정상적으로 그어지지 않아서 LineRenderer가 포함된 GameObject를 바깥에 두고, 보스의 GameObject와 플레이어의 비행기 GameObject를 직접 연결해줘야 제대로 그려졌다.

처음에는 보스와 플레이어를 따라오고, 시간이 지나서 _canMoveLazer 변수가 false가 되면 보스쪽 좌표만 움직인다.

public IEnumerator ShootLazer()
    {
        
        Color lazerColor = Color.white;

        // ColorUtility.TryParseHtmlString("#D1B2FF", out lazerColor);

        _lineRenderer.startColor = lazerColor;
        _lineRenderer.endColor = lazerColor;
        
        _shootingGameManager.SetIsShootingLazer(true);

        while (_lineRenderer.startWidth < 0.3f)
        {
            _lineRenderer.startWidth += 0.01f;
            _lineRenderer.endWidth += 0.01f;

            yield return new WaitForSeconds(0.01f);
        }

        yield return new WaitForSeconds(0.5f);

        while (_lineRenderer.startWidth > 0f)
        {
            _lineRenderer.startWidth -= 0.01f;
            _lineRenderer.endWidth -= 0.01f;

            yield return new WaitForSeconds(0.01f);
        }

        _lineRenderer.startColor = Color.red;
        _lineRenderer.endColor = Color.red;

        _shootingGameManager.SetIsShootingLazer(false);
    }

이후에는 레이저를 두껍게, 얇게 하여 발사하는 모션을 만들었다.

public void SetColliderPosition()
    {
        Vector2 position;

        _linePositionList.Clear();

        for (int i = 0; i < _lineRenderer.positionCount; i++)
        {
            position = _lineRenderer.GetPosition(i);
            _linePositionList.Add(position);
        }

        _edgeCollider.SetPoints(_linePositionList);
    }

레이저에 닿았을 때 죽게 하기 위해 Collider 작업도 필요했는데, 인터넷에서 찾아보니 MeshCollider를 활용하라고 되어있었다. LineRenderer가 BakeMesh라고 하는 함수를 제공하여 선에 알맞은 Mesh를 만들어주는 것 같았는데, 2D라 그런지 제대로 Mesh가 만들어지지 않았다. 이를 해결하기 위해서 여러 방법을 찾아보았으나, 애초에 MeshCollider는 3D에서 사용하기 때문에 RigidBody2D를 사용하는 나에게는 알맞지 않은 방법이었다.

 

때문에 EdgeCollider를 사용하기로 했다. LineRenderer의 시작점과 끝점을 이어주기만 하면 간단하게 선 모양 Collider가 완성된다. 두께를 반영하지 못하는 점이 아쉬웠는데, 때문에 레이저를 조금 얇게 만들고, 대신 레이저가 금방 나오도록 했다.

// 적이 스폰되는 번호에 따라 위치와 동작 부여
    private void SpawnBezierCurveEnemy(string enemyName, int spawnNum, float speed)
    {
        GameObject enemy;

        if (spawnNum == 0)
        {
            enemy = SpawnEnemy(enemyName, new Vector2(-300, 600));

            StartCoroutine(enemy.GetComponent<EnemyController>().DoBezierCurves2(enemy.GetComponent<RectTransform>().anchoredPosition,
                    new Vector2(-400, 0), new Vector2(500, 600), new Vector2(300, -600), speed));
        }
        else if (spawnNum == 1)
        {
            enemy = SpawnEnemy(enemyName, new Vector2(-150, 600));

            StartCoroutine(enemy.GetComponent<EnemyController>().DoBezierCurves2(enemy.GetComponent<RectTransform>().anchoredPosition,
                    new Vector2(800, 0), new Vector2(-800, 0), new Vector2(150, -600), speed));
        }
        else if (spawnNum == 2)
        {
            enemy = SpawnEnemy(enemyName, new Vector2(150, 600));

            StartCoroutine(enemy.GetComponent<EnemyController>().DoBezierCurves2(enemy.GetComponent<RectTransform>().anchoredPosition,
                    new Vector2(-800, 0), new Vector2(800, 0), new Vector2(-150, -600), speed));
        }
        else if (spawnNum == 3)
        {
            enemy = SpawnEnemy(enemyName, new Vector2(300, 600));

            StartCoroutine(enemy.GetComponent<EnemyController>().DoBezierCurves2(enemy.GetComponent<RectTransform>().anchoredPosition,
                    new Vector2(400, 0), new Vector2(-500, 600), new Vector2(-300, -600), speed));
        }
    }

    public IEnumerator NextPhase()
    {
        yield return new WaitForSeconds(3);

        if (_phaseNum == 0)
        {
            for (int i = 0; i < 5; ++i)
            {
                ++_leftEnemy;
                SpawnStraightEnemy("PinkEnemy", i);
            }
        }
        else if (_phaseNum == 1)
        {
            for (int i = 0; i < 5; ++i)
            {
                ++_leftEnemy;
                SpawnStraightEnemy("GreenEnemy", i);
            }
        }
        else if (_phaseNum == 2)
        {
            for(int i=0; i<4; ++i)
            {
                ++_leftEnemy;
                SpawnBezierCurveEnemy("YellowEnemy", i, YellowEnemySpeed);
            }
        }
        else if (_phaseNum == 3)
        {
            for (int i = 0; i < 4; ++i)
            {
                ++_leftEnemy;
                SpawnBezierCurveEnemy("PurpleEnemy", i, PurpleEnemySpeed);
            }
        }
        else if (_phaseNum == 4)
        {
            _leftEnemy = 9;

            for (int i = 0; i < 5; ++i)
            {
                SpawnStraightEnemy("GreenEnemy", i);
            }

            yield return new WaitForSeconds(3);

            for (int i = 0; i < 4; ++i)
            {
                SpawnBezierCurveEnemy("PurpleEnemy", i, PurpleEnemySpeed);
            }
        }
        else if (_phaseNum == 5)
        {
            _leftEnemy = 9;

            for (int i = 0; i < 5; ++i)
            {
                SpawnStraightEnemy("GreenEnemy", i);
            }

            yield return new WaitForSeconds(3);

            for (int i = 0; i < 4; ++i)
            {
                SpawnBezierCurveEnemy("YellowEnemy", i, YellowEnemySpeed);
            }
        }
        else if (_phaseNum == 6)
        {
            _leftEnemy = 9;

            for (int i = 0; i < 5; ++i)
            {
                SpawnStraightEnemy("PinkEnemy", i);
            }

            yield return new WaitForSeconds(3);

            for (int i = 0; i < 4; ++i)
            {
                SpawnBezierCurveEnemy("PurpleEnemy", i, PurpleEnemySpeed);
            }            
        }
        else if (_phaseNum == 7)
        {
            _leftEnemy = 9;

            for (int i = 0; i < 5; ++i)
            {
                SpawnStraightEnemy("PinkEnemy", i);
            }

            yield return new WaitForSeconds(3);

            for (int i = 0; i < 4; ++i)
            {
                SpawnBezierCurveEnemy("YellowEnemy", i, YellowEnemySpeed);
            }
        }
        else if (_phaseNum == 8)
        {
            // 보스 등장
            StartCoroutine(_audioManager.FadeOutMusic());

            _isSpawnedBoss = true;

            _bossEnemy.transform.SetParent(_aliveEnemyParent.transform);
            _bossEnemy.SetActive(true);

            yield return new WaitForSeconds(1f);

            _audioManager.PlayBGM("FinalFancyBattle");

            _bossHPBar.transform.GetChild(0).GetComponent<Image>().DOFade(1, 1);
            _bossHPBar.transform.GetChild(1).GetChild(0).GetComponent<Image>().DOFade(1, 1);

        }
        else
        {
            // 보스 등장 이후 반복
            if (_phaseNum % 4 == 1)
            {
                for (int i = 0; i < 5; ++i)
                {
                    ++_leftEnemy;
                    SpawnStraightEnemy("PinkEnemy", i);
                }
            }
            else if (_phaseNum % 4 == 2)
            {
                for (int i = 0; i < 5; ++i)
                {
                    ++_leftEnemy;
                    SpawnStraightEnemy("GreenEnemy", i);
                }
            }
            else if (_phaseNum % 4 == 3)
            {
                for (int i = 0; i < 4; ++i)
                {
                    ++_leftEnemy;
                    SpawnBezierCurveEnemy("YellowEnemy", i, YellowEnemySpeed);
                }
            }
            else if (_phaseNum % 4 == 0)
            {
                for (int i = 0; i < 4; ++i)
                {
                    ++_leftEnemy;
                    SpawnBezierCurveEnemy("PurpleEnemy", i, PurpleEnemySpeed);
                }
            }
        }

        _phaseNum++;
    }

스테이지 구성은 [핑크색 적 -> 초록색 적 -> 노란색 적 -> 보라색 적 -> 초록색 + 보라색 적 -> 초록색 + 노란색 적 -> 핑크색 + 노란색 적 -> 핑크색 + 보라색 적] 순으로 총 8스테이지가 진행되고, 보스가 나온 뒤에 [핑크색 적 -> 초록색 적 -> 노란색 적 -> 보라색 적]  순으로 등장한다.

 

최고 점수는 보스가 쫄 소환을 총 4번 했을 때 다 잡은 점수인 49,000점으로 설정했다. 해당 적들을 전부 잡고 핑크색 적을 1번이라도 때리면 최고 점수를 달성할 수 있다.

 

난이도는 잘 못하는 사람도 맞출 수 있도록 적당하게 설정했다.

 

이외에도 Slider를 활용하여 보스의 체력바를 추가하고, 공격/스킬 부분에 조작키인 Z와 X를 표시했다.

 

https://youtu.be/I9hMt2r_Z6Y

보스가 나올 때 배경음악은 이 음악을 사용했다.

 

 

오늘까지 완성된 부분은 다음과 같다.

앞에 있는 쫄 파트는 이전 영상과 같아서 보스 부분만 녹화했다.

 

이렇게 1번 게임을 드디어 완성했다. 아직 게임 오버 부분이 빠져있긴 한데, 스토리와 게임을 연동하면서 추가하게 될 것 같다. 아마 내일은 스토리 부분을 이어서 작성하고, 게임 시작 부분과 연결할 것 같다. 아마 재시작이 필요할테니 게임 재시작을 위한 작업도 할 것 같다. 이후에는 2장 스토리를 작성하고, 2장 게임인 리듬게임의 기획과 개발도 해야할 것 같다. 이번에는 슈팅 게임처럼 개발하면서 기획하는 주먹구구식이 아니라 기획을 마쳐놓고, 어떤 작업들이 필요할지 생각하고 계획을 세워서 개발하면 더 좋을 것 같다.

주말에 가족들과 교보문고에 갔다 왔는데, 이것저것 둘러보다가 게임 개발 관련 코너에 머무르면서 책을 봤다. 그러던 와중에 지금 나에게 도움이 될만한 두 개의 책을 발견했다. 유니티로는 보통 모바일 게임을 많이 만들기 때문에 최적화 관련해서 문제가 생기는 경우가 많다. 그래서 최적화에 관심이 많기 때문에 최적화 관련 책을 구입했다. 또, 디자인 패턴에 대한 지식도 부족하여 관련 책도 구입했다. 디자인 패턴 이외에도 게임을 개발할 때 도움이 될만한 지식들이 많이 담겨있는 것 같다. 사실 이런 정보나 지식들은 인터넷에서 찾아보면 있긴 하겠지만, 내가 원하는 정보를 찾는 것도 힘들고, 올바른 정보를 얻는 것도 어렵다. 거기다가 전후 흐름에 맞추어 상세히 기술되어 있으니 인터넷의 정보는 아직까지 책을 따라오기 어려운 부분이 없지 않은 것 같다. 해당 책에서 지식을 얻고, 게임에 적용하면서 내 것으로 익혀가면 좋을 것 같다. 우선은 각종 매니저에 싱글톤을 적용해서 코드를 약간 깔끔하게 정리하면 좋을 것 같다.

'갤러그 (Gallaga)' 플레이 영상 on Make a GIF

갤러그에서 가장 대단하고 세련됐다고 생각되는 부분은 적의 움직임이었다. 아름답게 곡선을 그리고 차례대로 배치되는 적들을 보면 갤러그가 오랫동안 사람들에게 사랑받은 이유가 무엇인지 알 수 있었다. 보통은 직선 운동이나, 키 입력을 받아서 이동하는 부분만 구현해서 곡선 운동을 어떻게 구현하는지 찾아보았다. 그와 관련해서 찾은 것이 베지에 곡선이었다.

 

여러 블로그를 참고했지만 바로 머리에 들어오지는 않았다.

 

유니티 - 베지어 곡선(Bezier Curve) | Rito15

 

유니티 - 베지어 곡선(Bezier Curve)

베지어 곡선 점과 점 사이의 선형 보간(Lerp, Linear interpolation)을 이용해 그려내는 곡선

rito15.github.io

 

내 기준 가장 설명이 깔끔했던 블로그는 여기다.

 

위 그림은 2차 베지어 곡선의 그림이다. 출발지와 목적지, 조절점 총 3가지의 Vector가 필요하다. 좌측 하단의 점을 점1, 상단 중앙의 점을 점2, 우측 하단의 점을 점3라고 하면, Vector2.Lerp 함수를 사용해서 점1과 점2의 선형 보간을 구하고, 점2와 점3의 선형 보간을 구한다. 이게 왼쪽/오른쪽 빨간색 직선상에서 움직이는 파란색 점이다.

시간을 누적해가며 다시 이 두 점의 선형 보간을 구하면 위와 같이 아름다운 곡선이 그려지는 원리였다.

조절점이 2개인 3차 베지어 곡선도 원리는 동일하다. 2차 베지어 곡선 2개가 중첩된 느낌이다.

 

 

[Unity] 베지어 곡선(Bezier Curves) 구현해보기 — Night's Devlog (tistory.com)

 

[Unity] 베지어 곡선(Bezier Curves) 구현해보기

게임을 개발하다보면 곡선을 그려야 할 때가 자주 있다. 곡선을 그리는 방법이 다양하게 있는데 그 중 베지어 곡선(Bezier Curves)에 대해 알아보자 1. 베지어 곡선이란 점과 점 사이의 선형 보간을

night-devlog.tistory.com

코드는 이 블로그에서 작성한 코드가 가장 직관적이라고 생각하여 참조하여 작성했다.

public IEnumerator DoBezierCurves(Vector2 startPosition, Vector2 controlPosition, Vector2 targetPosition)
    {
        float time = 0;

        while(true)
        {
            time += Time.deltaTime / 10;

            if(gameObject.GetComponent<RectTransform>().anchoredPosition == targetPosition)
            {
                yield break;
            }

            Vector2 position1 = Vector2.Lerp(startPosition, controlPosition, time);
            Vector2 position2 = Vector2.Lerp(controlPosition, targetPosition, time);

            gameObject.GetComponent<RectTransform>().anchoredPosition = Vector2.Lerp(position1, position2, time);

            yield return null;
        }
    }

처음에는 2차 베지어 곡선을 구현했다. 그런데 움직임이 약간 단순해보여서 코드를 수정하여 3차 베지어 곡선으로 수정했다.

public IEnumerator DoBezierCurves2(Vector2 startPosition, Vector2 controlPosition1, Vector2 controlPosition2, Vector2 targetPosition, float speed)
    {
        float time = 0;

        while (true)
        {
            time += Time.deltaTime / 10 * speed;

            if (!gameObject.activeSelf) yield break;

            if (gameObject.GetComponent<RectTransform>().anchoredPosition == targetPosition)
            {
                gameObject.GetComponent<RectTransform>().anchoredPosition = startPosition;
                StartCoroutine(DoBezierCurves2(startPosition, controlPosition1, controlPosition2, targetPosition, speed));
                yield break;
            }

            Vector2 position1 = Vector2.Lerp(startPosition, controlPosition1, time);
            Vector2 position2 = Vector2.Lerp(controlPosition1, controlPosition2, time);
            Vector2 position3 = Vector2.Lerp(controlPosition2, targetPosition, time);

            Vector2 position4 = Vector2.Lerp(position1, position2, time);
            Vector2 position5 = Vector2.Lerp(position2, position3, time);

            gameObject.GetComponent<RectTransform>().anchoredPosition = Vector2.Lerp(position4, position5, time);

            yield return null;
        }
    }

이 함수는 EnemyController.cs에 탑재하여 적이 끝까지 이동하면 초기 지점으로 이동하여 다시 똑같이 움직이도록 하였다.

 

// 기즈모
    
    [SerializeField] Transform _target, _p1, _p2, _p3, _p4;
    [SerializeField] public float _gizmoDetail;

    List<Vector2> _gizmoPoints = new List<Vector2>();

    private void OnDrawGizmos()
    {
        _gizmoPoints.Clear();

        for (int i=0; i<_gizmoDetail; i++)
        {
            float t = (i / _gizmoDetail);

            Vector2 p5 = Vector2.Lerp(_p1.position, _p2.position, t);
            Vector2 p6 = Vector2.Lerp(_p2.position, _p3.position, t);
            Vector2 p7 = Vector2.Lerp(_p3.position, _p4.position, t);

            Vector2 p8 = Vector2.Lerp(p5, p6, t);
            Vector2 p9 = Vector2.Lerp(p6, p7, t);

            _gizmoPoints.Add(Vector2.Lerp(p8, p9, t));
        }

        for (int i=0; i<_gizmoPoints.Count - 1; i++)
        {
            Gizmos.DrawLine(_gizmoPoints[i], _gizmoPoints[i + 1]);
        }
    }

기즈모를 그리는 것에 관해서도 나와있었다. 베지어 곡선을 그리는 것과 비슷하게 코드를 작성하고, 그림을 그리면 되었다.

이렇게 그림을 그리고 _p1~_p4까지 transform을 움직이면 베지어 곡선이 어떤 형태로 그려지는지 볼 수 있다. 굉장히 편리한 기능이다.

 

// 적이 스폰되는 번호에 따라 위치와 동작 부여
    private void SpawnBezierCurveEnemy(string enemyName, int spawnNum, float speed)
    {
        GameObject enemy;

        if (spawnNum == 0)
        {
            enemy = SpawnEnemy(enemyName, new Vector2(-300, 600));

            StartCoroutine(enemy.GetComponent<EnemyController>().DoBezierCurves2(enemy.GetComponent<RectTransform>().anchoredPosition,
                    new Vector2(-400, 0), new Vector2(500, 600), new Vector2(300, -700), speed));
        }
        else if (spawnNum == 1)
        {
            enemy = SpawnEnemy(enemyName, new Vector2(-150, 600));

            StartCoroutine(enemy.GetComponent<EnemyController>().DoBezierCurves2(enemy.GetComponent<RectTransform>().anchoredPosition,
                    new Vector2(800, 0), new Vector2(-800, 0), new Vector2(150, -700), speed));
        }
        else if (spawnNum == 2)
        {
            enemy = SpawnEnemy(enemyName, new Vector2(150, 600));

            StartCoroutine(enemy.GetComponent<EnemyController>().DoBezierCurves2(enemy.GetComponent<RectTransform>().anchoredPosition,
                    new Vector2(-800, 0), new Vector2(800, 0), new Vector2(-150, -700), speed));
        }
        else if (spawnNum == 3)
        {
            enemy = SpawnEnemy(enemyName, new Vector2(300, 600));

            StartCoroutine(enemy.GetComponent<EnemyController>().DoBezierCurves2(enemy.GetComponent<RectTransform>().anchoredPosition,
                    new Vector2(400, 0), new Vector2(-500, 600), new Vector2(-300, -700), speed));
        }
    }

적을 소환하고 움직임을 주는 부분은 ShootingGameManager.cs에서 작동하도록 하였다. 적이 나타나는 위치를 고정하고, 양쪽이 대충으로 똑같으면 똑같은 지점을 지나서 조금 단순해보이는 느낌이 들어서 바깥쪽 두 개가 서로 대칭하게, 안쪽 두 개가 서로 대칭하게끔 구현하였다. 갤러그는 플레이어 위치에 따라 적이 움직이는데, 갤러그의 경우는 플레이어가 위 아래로는 움직이지 못하고 좌우로만 움직일 수 있기 때문에 적이 항상 아래로 사라지는데, 나는 위/아래 움직임도 추가했기 때문에 적이 오른쪽이나 왼쪽으로 사라질 가능성이 있는 문제가 생겼다. 적의 움직임을 이런식으로 제한하거나, 적이 플레이어를 추격하기 위해서는 위/아래 움직임을 제한할 필요가 있을 것 같다. 개인적으로는 위/아래 움직임이 막히면 조작이 불편한 느낌이 있고, 지금 적의 움직임도 나쁘지 않은 것 같아 아마 지금 이대로 갈 것 같다.

 

private IEnumerator NextPhase()
    {
        yield return new WaitForSeconds(3);

        if (_phaseNum == 0)
        {
            for (int i = 0; i < 5; i++)
            {
                ++_leftEnemy;
                SpawnStraightEnemy("PinkEnemy", i);
            }
        }
        else if (_phaseNum == 1)
        {
            for (int i = 0; i < 5; i++)
            {
                ++_leftEnemy;
                SpawnStraightEnemy("GreenEnemy", i);
            }
        }
        else if (_phaseNum == 2)
        {
            for(int i=0; i<4; i++)
            {
                ++_leftEnemy;
                SpawnBezierCurveEnemy("YellowEnemy", i, YellowEnemySpeed);
            }
        }
        else if (_phaseNum == 3)
        {
            for (int i = 0; i < 4; i++)
            {
                ++_leftEnemy;
                SpawnBezierCurveEnemy("PurpleEnemy", i, PurpleEnemySpeed);
            }
        }
        else if (_phaseNum == 4)
        {

        }

        _phaseNum++;
    }

적 밸런스와 스테이지 구성도 약간 했다. 핑크색 적은 체력이 높은 대신에(3) 속도가 느리고, 초록색 적은 체력이 낮은 대신에(2) 속도가 빠르다(약 20%). 노란색과 보라색도 체력2/속도1, 체력1/속도2 로 설계했다.

지금은 노란색과 보라색에만 곡선 움직임을 적용했는데, 핑크색과 초록색 적에도 곡선 움직임을 적용하게 된다면 공격 딜레이로 밸런스를 맞추면 좋을 것 같다.

현재 스테이지 구성은 각 적의 체력이나 이동속도, 공격속도 등을 알 수 있게 해주는 4개의 초기 스테이지로 구성되어있다.

 

한 번에 다양한 적이 다양한 움직임으로 나타나다가 보스가 등장하고, 이후 적 소환을 반복하면 될 것 같다.

 

이외에는 최고 점수 갱신시 효과 추가, 적에게 피격당했을 때 애니메이션을 추가했다.

 

 

 

아! 그리고 버그를 해결하며 새로 알게 된 것들도 있다. Queue를 사용할 때와 Coroutine을 사용할 때 주의할 점이다.

 

Queue를 사용할 때, Enqueue를 두 번 하게되면, 동일한 개체가 2번 들어가서 의도한 것과 다른 결과가 나올 수 있다. 총알이 발사되고 일정시간 후, 적과 충돌했을때 Enqueue를 하게 되니 적과 충돌한 이후에 시간이 지나면 Enqueue가 한 번 더 호출되어 동일한 총알이 2번 들어가서 총알이 정상적으로 발사되지 않는 문제가 있었다. 큐를 사용할 때는 중복하여 Enqueue가 되지 않도록 조심해야겠다.

 

또, Coroutine에 대한 것도 추가로 알게 되었다. 어제는 Coroutine을 호출한 객체가 disabled되면 호출된 Coroutine이 존재하는 스크립트가 살아있어도 Coroutine이 종료되는 것을 알게 되었다. 그런데, Coroutine을 호출한 객체가 살아있으면, 호출된 Coroutine이 존재하는 스크립트의 GameObject가 disabled되어도 코루틴이 계속 실행되는 것이었다! 아마도 코루틴을 실행하기 위해 스크립트를 살려두는 것 같았다. 이 부분도 Coroutine을 사용할 때 조심해야겠다.

그림으로 표현하면 대충 이런 느낌일 것 같다.

 

 

 

오늘까지 구현한 부분은 다음과 같다!

오늘은 디테일이 조금 더해진 것 같다. 어제까지만 해도 적 이동이 너무 단조로워서 아쉬웠는데, 베지어 곡선을 활용하면서 움직임이 조금 다채로워진 것 같아서 마음에 든다. 처음에는 약간 개념을 이해하기 어려웠는데, 이해하고 나니 그렇게 어려운 것은 아니었다. 찾아보니 이곳저곳에서 종종 사용되는 개념인 것 같다. 새로운 것을 배운 것 같아 기쁘다.

 

슈팅 게임에서 남은 목표는 다음과 같다.

1. 스테이지 설계하기

2. 보스 스킬, 패턴, 체력바 등 구현

 

 

이제 개발 마무리 단계인 것 같다. 처음에는 슈팅 게임을 조금 단순하게 보고 있었는데, 직접 만들어보니 전혀 그렇지 않았다. 생각보다 신경쓸 것이 꽤나 많았다. 구현하면서 새로 배운 개념들도 있고 이것저것 디테일을 추가해가면서 완성도를 높이는 작업도 중요한 것 같다. 간단한 것이라도 끝까지 한 번 완성해보라는 이유를 알 것 같다. 부지런히 작업해서 이번 주 중으로 마무리를 하면 좋을 것 같다.

 

public IEnumerator HitByEnemy(GameObject midoriPlane, GameObject enemy)
    {
        if (!_isAlivePlane) yield break;

        _isCanMove = false;
        _isAlivePlane = false;
        

        midoriPlane.GetComponent<Image>().color = Color.clear;
        StartCoroutine(ShowExplosion(midoriPlane));
        audioManager.PlaySFX("EnemyDestroy");

        if (enemy.CompareTag("EnemyBullet"))
        {
            enemy.SetActive(false);
        }

        StartCoroutine(MidoriPlaneRevive());
    }

 

먼저 HitByEnemyBullet과 HitByEnemy 함수를 통합했다. 다행히도 적 총알은 바로 없애도 문제가 생기지 않아 총알이면 바로 사라지도록 했다.

private IEnumerator ShootingGameTimer()
    {
        while(true)
        {
            if (_skill1Cooltime > 0)
            {
                skill1CooltimeText.text = _skill1Cooltime.ToString("F1");
                _skill1Cooltime -= 0.1f;
            }
            else if (_skill1Cooltime <= 0 && !_isReadySkill1)
            {
                _isReadySkill1 = true;
                skill1CooltimeText.text = "";
                skill1Background.GetComponent<Image>().color = Color.clear;
            }

            if (_skill2Cooltime > 0)
            {
                skill2CooltimeText.text = _skill2Cooltime.ToString("F1");
                _skill2Cooltime -= 0.1f;
            }
            else if (_skill2Cooltime <= 0 && !_isReadySkill2)
            {
                _isReadySkill2 = true;
                skill2CooltimeText.text = "";
                skill2Background.GetComponent<Image>().color = Color.clear;
            }

            yield return new WaitForSeconds(0.1f);
        }
    }

효율적인 작동을 위해 bool 타입의 변수 _isReadySkill1, _isReadySkill2를 추가해서 Timer에서 공격, 스킬이 준비될 때마다 한 번씩만 동작하도록 제한을 걸어줬다.

// Z키를 눌러 총알을 발사
        if (Input.GetKeyDown(KeyCode.Z))
        {
            // 공격은 기본적으로 연타하기 때문에 경고 표시하지 않도록 함
            if (_skill1Cooltime <= 0)
            {
                _isReadySkill1 = false;
                skill1Background.GetComponent<Image>().color = new Color(0, 0, 0, 0.8f);
                _skill1Cooltime = s_skill1CoolTime;
                StartCoroutine(ShootMidoriBullet(new Vector2(midoriPlane.GetComponent<RectTransform>().anchoredPosition.x, 570)));
            }
        }

총알에도 재사용 대기시간을 걸어줬다. 이런 변수들은 쉬운 변경을 위해서 상수로 선언해주었다. 0.2초는 너무 공격이 빠르고, 0.3초 정도가 적당해서 0.3초로 설정했다. 공격은 보통 연타로 누르기 때문에 누를 때마다 경고가 뜨면 정신없을 것 같아 경고를 따로 띄우지 않도록 하였다.

private IEnumerator MidoriPlaneRevive()
    {
        yield return new WaitForSeconds(1);
        if(_life <= 0)
        {
            // 게임 오버
            yield break;
        }
        else
        {
            --_life;

            if (_life == 1) lifeParent.transform.GetChild(1).gameObject.SetActive(false);
            else if (_life == 0) lifeParent.transform.GetChild(0).gameObject.SetActive(false);
        }

        midoriPlane.GetComponent<RectTransform>().anchoredPosition = new Vector2(0, -600);
        midoriPlane.GetComponent<Image>().color = Color.white;
        midoriPlane.GetComponent<RectTransform>().DOLocalMoveY(-500, 2);

        yield return new WaitForSeconds(2);

        _isCanMove = true;

        // 1초간 무적 적용
        yield return new WaitForSeconds(1);

        _isAlivePlane = true;
    }

부활 기능도 추가하였다. 목숨이 다 닳으면 게임 오버 함수를 호출하도록 했다.

부활 후, 화면 하단 중앙에서 화면으로 2초에 걸쳐 등장하도록 했다.

적이 부활 장소에 있으면 불합리하게 사망할 수 있기 때문에 등장한 후, 1초간 무적을 적용했다.

 

그러다보니 기존에는 OnTriggetEnter2D를 사용했는데, 무적 시간 때 적과 겹치면 무적이 풀려도 죽지 않는 문제가 있어 OnTriggerStay2D로 교체했다.

 

// 적의 총알 객체를 발사하는 함수
    public IEnumerator ShootEnemyBulletCoroutine(GameObject enemy)
    {
        GameObject bullet = GetPrefab("EnemyBullet");

        bullet.SetActive(true);
        bullet.GetComponent<Image>().color = Color.white;

        bullet.GetComponent<RectTransform>().anchoredPosition = new Vector2(enemy.GetComponent<RectTransform>().anchoredPosition.x,
            enemy.GetComponent<RectTransform>().anchoredPosition.y - 50);

        Vector2 direction = ExtendBulletDirection(bullet.GetComponent<RectTransform>().anchoredPosition, midoriPlane.GetComponent<RectTransform>().anchoredPosition);
        LookRotation2D(bullet, direction);

        float bulletDuration = Vector2.Distance(bullet.GetComponent<RectTransform>().anchoredPosition, direction) / 600 * s_enemyBulletSpeed;
        bullet.GetComponent<RectTransform>().DOLocalMove(direction, bulletDuration).SetEase(Ease.Linear);

        yield return new WaitForSeconds(bulletDuration);
        bullet.GetComponent<Image>().color = Color.clear;
        bullet.SetActive(false);
        _enemyBulletQueue.Enqueue(bullet);
    }

적이 총알을 발사하는 기능도 추가했다.

아군이 총알을 발사하는 기능과 유사했지만 약간 차이가 있어 별도의 함수를 두었다.

private IEnumerator EnemyAttack()
    {
        while(true)
        {
            if (!ShootingGameManager.s_instance.GetIsAlivePlane())
            {
                _isReadyToAttack = false;
                yield return new WaitForSeconds(1);
            }
            else if (_isReadyToAttack == true)
            {
                ShootingGameManager.s_instance.ShootEnemyBullet(gameObject);
                yield return new WaitForSeconds(_attackDelay);
            }
            // 적 스폰 직후, 아군 부활 직후에 총알을 발사하지 않도록 최소 대기 시간 설정
            else
            {
                _isReadyToAttack = true;
                yield return new WaitForSeconds(1);
            }
        }
    }

위 함수를 적 개체마다 달려있는 EnemyController.cs에서 호출하는데, 아군이 죽었을 때는 공격을 하지 않고, 부활한 직후에도 최소 1초간 공격을 하지 않도록 했다.

여기서도 코루틴을 실행하면 재호출이 불가능해질테니 ShootingHameManager 스크립트 내에서 호출할 수 있도록 ShootEnemyBullet에서 ShootEnemyBulletCoroutine을 호출하도록 했다.

private void AddScore(int score)
    {
        _score += score;
        scoreText.text = _score.ToString();

        if(_score > s_highScore) highScoreText.text = _score.ToString();
    }

점수 기능도 구현했다. 적을 맞추거나 적을 처치하면 점수가 오르고, 최고 점수를 갱신하면 최고 점수 또한 갱신하도록 했다. 최고 점수를 갱신하면 노란색 글자로 알림을 띄워주는 것도 좋을 것 같다.

    // 총알의 목적지를 화면 밖까지 연장
    private Vector2 ExtendBulletDirection(Vector2 bulletPosition, Vector2 targetPosition)
    {
        Vector2 direction = targetPosition - bulletPosition;

        // 0으로 나누지 않도록 예외 처리
        if(direction.x == 0)
        {
            direction.x = bulletPosition.x;
            direction.y = (direction.y < 0) ? s_yDownEnd : s_yUpEnd;

            return direction;
        }
        else if(direction.y == 0)
        {
            direction.x = (direction.x < 0) ? s_xLeftEnd : s_xRIghtEnd;
            direction.y = bulletPosition.y;

            return direction;
        }

        float xToEnd, yToEnd, xToEndRate, yToEndRate;

        // x, y축 기준 화면 밖으로 이동하기 위해 필요한 거리
        xToEnd = (direction.x < 0) ? s_xLeftEnd - bulletPosition.x : s_xRIghtEnd - bulletPosition.x;
        yToEnd = (direction.y < 0) ? s_yDownEnd - bulletPosition.y : s_yUpEnd - bulletPosition.y;
        
        // 화면 밖까지 나가려면 얼마나 이동해야 하는지 비율
        xToEndRate = xToEnd / direction.x;
        yToEndRate = yToEnd / direction.y;

        // 비율에 따라 거리 연장
        if (xToEndRate > yToEndRate)
        {
            direction.x *= yToEndRate;
            direction.x += bulletPosition.x;

            direction.y = (direction.y < 0) ? s_yDownEnd : s_yUpEnd;
        }
        else
        {
            direction.x = (direction.x < 0) ? s_xLeftEnd : s_xRIghtEnd;

            direction.y *= xToEndRate;
            direction.y += bulletPosition.y;
        }

        return direction;
    }

여기에 추가로 총알의 이동 거리를 연장하는 함수도 추가했다. 기존에는 아군이 스킬을 쓰면 적 위치까지만 이동하고, 적이 공격을 하면 아군이 위치한 곳까지만 총알이 이동했는데, 그 거리를 화면 밖까지 이동하도록 연장하는 함수이다.

단순히 거리를 연장해도 되긴 했는데, y축은 상관없었으나 x축 기준 화면 밖으로 나가버리면 UI에 표시되는 문제가 있었다. UI 뒤에 검은색 배경을 깔아서 가릴 수도 있지만, 왼쪽은 그렇게 해버리면 캐릭터가 가려져서 화면 밖까지 나가는 거리만큼만 연장했다. 조금 번거롭긴 하지만 이러면 총알도 금방 회수되어 효율도 올라갈 것 같다.

 

 

슈팅 게임에서 남은 목표는 아래와 같다.

 

1. 스테이지 설계하기

2. 최고 점수 갱신시 효과 추가

3. 적 종류별 밸런스 구현

4. 적에게 공격당했을 때 미도리 애니메이션 추가

5.. 보스 스킬 / 패턴 / 체력바 등 구현

 

 

드디어 슈팅 게임의 전반적인 기능 구현이 끝났다! 몇몇 간단한 기능을 제외하면 스테이지 설계만 하면 될 것 같다.

개발하다보면 추가되는 기능도 몇몇 있을 것 같은데, 많진 않을 것 같다. 개발 착수 이후 딱 1주일 정도 걸린 것 같다. 개발에 걸린 실제 시간을 생각해보면 부지런히 개발했으면 3일 정도면 완료했을 것 같다. 개발을 진행하면서 기획하다보니 목표가 명확하지 않았고, 이에 따라 개발 기간이 늘어난 것 같다. 앞으로는 게임 개발에 필요한 기능들을 우선적으로 기획하여 목표를 명확히 정하고, 소요 시간을 예상하여 목표를 세우는 것이 필요할 것 같다.

스킬 아이콘을 담을 스킬 테두리를 추가했다. 이전에 게임 영역을 나누는 Bar에 적용된 효과를 그대로 적용했다. 효과는 포토샵으로 은색을 표현하는 자료를 검색해서 적용한 것이다.

 

다음으로는 스킬 관련 오류를 수정했다. 적을 처치할 떄 가끔 적이 정상적으로 사망 처리가 되지 않았는데, 이유는 IEnumerator 사용 방식에 있었다.

문제를 찾기 위해 디버깅을 하니 yield return new WaitForSeconds(1); 이후 부분이 실행이 되지 않는 문제가 있었다.

이에 관해 구글링을 하다가 아래 글의 PraetorBlue라는 유저의 답변에서 도움을 받았다.

 

Coroutine stops working after yield return - Unity Forum

 

Coroutine stops working after yield return

Hi there everyone!So, let me tell you about what I'm trying to achieve.Basically, I have a raycast-based interact script that recognizes objects that...

forum.unity.com

 

  • The MonoBehaviour that started the coroutine was disabled
  • The GameObject that the MonoBehaviour is attached to was deactivated
  • Either of the above was destroyed
  • Timescale was modified (if using a WaitForSeconds or similar)
  • A WaitUntil condition was not met

나한테 해당되는 내용은 1번이었다. 총알이 적과 닿으면 Active 상태를 false로 한 후에 yield return을 사용했는데, 이 경우에 코루틴을 호출한 MonoBehaviour가 disabled되어 그 지점에서 이어서 실행되지 않는 듯 하였다.

private void OnTriggerEnter2D(Collider2D other)
    {
        if(other.CompareTag("ShootingEnemy"))
        {
            StartCoroutine(ShootingGameManager.s_instance.HitByMidoriBullet(gameObject, other.gameObject));
        }
    }

코루틴이 실행되면 그 코루틴을 실행하고 있는 객체가 살아있으니 계속 실행될 것이라고 생각했는데, 코루틴을 실행하는 객체가 살아있어도 이를 호출한 객체가 사라지면 코루틴의 작동도 중단되는 듯 하였다. 하긴 이게 맞는 것 같다. 예를 들어 타이머를 외부 스크립트에서 가져와 사용한다고 치면, 호출한 객체가 사라졌을 때 계속 도는 등의 일이 반복된다면 메모리 낭비가 굉장히 심할 것이다.

 

public IEnumerator HitByMidoriBullet(GameObject midoriBullet, GameObject enemy)
    {
        // 이미 총알이 공격한 상태라면 공격이 작동하지 않도록 함
        if (!midoriBullet.GetComponent<BulletController>().GetCanAttack()) yield break;

        // 총알이 적중하면 투명하게 이미지를 바꾸고, 적중 상태를 변경 (Active를 false로 하면 yield return 이후 호출이 불가능해짐)
        midoriBullet.GetComponent<Image>().color = Color.clear;
        midoriBullet.GetComponent<BulletController>().SetCanAttack(false);

        EnemyController enemyController = enemy.GetComponent<EnemyController>();

        if(enemyController.GetEnemyHP() > 1)
        {
            enemyController.SetEnemyHP(enemyController.GetEnemyHP() - 1);
            audioManager.PlaySFX("EnemyHit");
        }
        else
        {
            StartCoroutine(storyManager.MidoriHappy());
            audioManager.PlaySFX("EnemyDestroy");

            enemy.SetActive(false);

            StartCoroutine(ShowExplosion(enemy));

            // 스킬 사용 중 적의 Parent가 바뀌어 오류 생기는 것을 방지
            if (_isActivatingSkill) yield return new WaitForSeconds(1);

            enemy.transform.SetParent(_deadEnemyParent.transform);
        }
    }

때문에 적에게 총알이 적중하면 color를 투명하게 변경하여 눈에 보이지 않게 변경하였다. 이 경우, 날아가면서 뒤에 있는 적도 맞을 수 있기 때문에 해당 총알이 이미 적에게 적중했다면 canAttack이라는 변수를 false로 변경하여 하나의 총알이 한 명의 적만 공격할 수 있도록 제한하였다. 이렇게 수정하니 제대로 작동되었다.

 

적이 폭발할 때의 이미지는 아래 주소를 통해 받았다. 감사하게도 무료로 배포하고 있었다.

이 폭발 이미지는 Sprite가 변경되는 것이기 때문에 Animation을 사용했다.

 

private IEnumerator ShowExplosion(GameObject target)
    {
        GameObject explosion = GetFrefab("Explosion");
        explosion.GetComponent<RectTransform>().anchoredPosition = target.GetComponent<RectTransform>().anchoredPosition;
        explosion.SetActive(true);
        explosion.GetComponent<Animator>().Play("Explosion");

        yield return new WaitForSeconds(0.5f);

        _explosionQueue.Enqueue(explosion);
    }

 

다른 것들과 동일하게 오브젝트 풀링 기법을 적용했고, target의 위치로 폭발 이미지를 이동시켜서 애니메이션을 재생하는 식으로 구현했다. 애니메이션 재생 시간이 0.5초라 0.5초 이후에 다시 사용할 수 있도록 설계하였다.

 

public IEnumerator HitByEnemy(GameObject midoriPlane, GameObject enemy)
    {
        _isAlivePlane = false;

        midoriPlane.GetComponent<Image>().color = Color.clear;
        StartCoroutine(ShowExplosion(midoriPlane));
        audioManager.PlaySFX("EnemyDestroy");

        yield return null;
    }

    public IEnumerator HitByEnemyBullet(GameObject midoriPlane, GameObject enemyBullet)
    {
        _isAlivePlane = false;

        midoriPlane.GetComponent<Image>().color = Color.clear;
        StartCoroutine(ShowExplosion(midoriPlane));
        audioManager.PlaySFX("EnemyDestroy");

        yield return null;
    }

적에게 닿으면 아군 기체가 폭발하는 기능은 총알이 닿으면 적이 죽는 것과 동일하게 구현했다. 적은 닿으면 사라지지 않고, 적의 총알은 닿으면 사라질 것이기 때문에 따로 함수를 만들었는데, 하나의 함수로 통합하고 Tag를 확인해서 해당 부분을 추가하면 될 것 같다.

 

 

마지막으로는 UI를 추가하여 우측 하단에 공격, 스킬 아이콘을 표시하고 쿨타임을 가시화했으며, 스킬 쿨타임 중 사용시 유저에게 좌측 상단의 텍스트와 경고음을 통해 알려주게끔 하였다.

private IEnumerator SkillTimer()
    {
        while(true)
        {
            if (_skillCooltime > 0)
            {
                _skillCooltime -= 0.1f;
                skill2CooltimeText.GetComponent<TMP_Text>().text = _skillCooltime.ToString("F1");
            }
            else
            {
                skill2CooltimeText.GetComponent<TMP_Text>().text = "";
                skill2Background.GetComponent<Image>().color = Color.clear;
            }

            yield return new WaitForSeconds(0.1f);
        }
    }

스킬 쿨타임은 IEnumerator를 활용했는데, Update는 프레임마다 호출하기 때문에 효율이 떨어질 것 같아 0.1초마다 쿨타임을 갱신하는 식으로 구현하였다. 다만, else 부분이 약간 비효율적인 것 같다. bool 변수를 하나 추가해서 한 번만 작동하도록 수정해야겠다.

 

오늘까지 완성된 부분은 다음과 같다.

 

* 아직 적이 총알을 날리는 기능이 구현되어 있지 않아 맵 오른쪽 중단에 적 총알을 배치하여 적 총알에 맞아 죽는 모습도 녹화되어 있음

 

배경음악도 변경했는데 이전 것들보다 어울리는 것 같다. 아래 영상의 BGM을 사용했다.

https://youtu.be/z3QKoLpo4_g

 

내일 목표는 다음과 같다.

1. HitByEnemy, HitByEnemyBullet 함수 하나로 통합

2. SkillTimer 함수 효율적인 사용을 할 수 있도록 수정

3. 기본 공격에도 쿨타임 적용하기 (0.2~0.3초 정도?)

4. 죽었을 때 부활하는 기능 구현

5. 적이 플레이어를 향해 총알 발사하는 기능 구현

6. 점수 기능 구현

 

 

아마 이 정도만 구현하면 슈팅 게임의 전반적인 기능들이 전부 구현될 것 같다. 아, 보스 스킬을 구현하는 부분이 있기는 하겠다. 근데 우선 보스 스테이지까지 진행하는 부분을 먼저 만들어야겠다. 처음엔 되게 막막하고 조잡했는데, 확실히 이제는 게임 느낌이 나는 것 같다. 그리고 적들에게 각각 다른 능력치를 적용하면 좋을 것 같다. 어떤 적은 속도가 빠른 대신 체력이 낮다든지 하는 것이다. 만들다보니 '이런 기능이 있으면 좋겠다' 싶은 생각이 들어서 추가한 부분도 있는데, 백지 상태에서 기획하는 것이 상당히 힘들구나 하는 것을 새로이 깨닫게 되었다.

이번에 추가한 이미지는 이 2종이다. 위는 아리스의 헤일로 이미지, 아래는 게임 화면을 나타낼 Bar 모양 Sprite다.

 

이외에는 기획했던 것처럼 오른쪽엔 점수와 남은 목숨을, 왼쪽에는 미도리의 일러스트를 추가했다.

왼쪽의 일러스트는 스토리에서 사용한 것을 가져와서 적을 처치하면 웃으며 점프하는 부분까지 추가했다.

 

 

또, 오브젝트 풀링 기법을 적용하여 적을 소환하는 기능을 추가했는데, 헤일로 이미지에도 같은 기술을 적용하다보니 구현이 비슷한 부분들이 많아 하나의 함수로 통합했다.

private GameObject GetFrefab(string name)
    {
        if(name == "MidoriHalo")
        {
            if (_midoriHaloQueue.Count > 0)
            {
                return _midoriHaloQueue.Dequeue();
            }
            else
            {
                return CreateNewMidoriHalo();
            }
        }
        else if(name == "MidoriBullet")
        {
            if (_midoriBulletQueue.Count > 0)
            {
                return _midoriBulletQueue.Dequeue();
            }
            else
            {
                return CreateNewMidoriBullet();
            }
        }
        if (name == "PinkEnemy")
        {
            if (_pinkEnemyQueue.Count > 0)
            {
                return _pinkEnemyQueue.Dequeue();
            }
            else
            {
                return CreateNewEnemy(name);
            }
        }
        else if (name == "GreenEnemy")
        {
            if (_greenEnemyQueue.Count > 0)
            {
                return _greenEnemyQueue.Dequeue();
            }
            else
            {
                return CreateNewEnemy(name);
            }
        }
        else if (name == "PurpleEnemy")
        {
            if (_purpleEnemyQueue.Count > 0)
            {
                return _purpleEnemyQueue.Dequeue();
            }
            else
            {
                return CreateNewEnemy(name);
            }
        }
        else if (name == "YellowEnemy")
        {
            if (_yellowEnemyQueue.Count > 0)
            {
                return _yellowEnemyQueue.Dequeue();
            }
            else
            {
                return CreateNewEnemy(name);
            }
        }
        else
        {
            Debug.Log("GetFrefab 이름 오류");
            return null;
        }
    }

 

추가로 미도리의 스킬을 구현했다.

private IEnumerator MidoriSkill()
    {
        _isActivatingSkill = true;

        int aliveEnemyCount = _aliveEnemyParent.transform.childCount;

        List<float> distanceList = new List<float>();
        float distance;

        int[] minIndexArray = new int[5];
        GameObject[] midoriHaloArray = new GameObject[5];

        // 살아있는 적 개체들의 거리를 List에 저장
        for(int i=0; i<aliveEnemyCount; i++)
        {
            GameObject enemy = _aliveEnemyParent.transform.GetChild(i).gameObject;

            distance = Vector2.Distance(enemy.GetComponent<RectTransform>().anchoredPosition, midoriAirplane.GetComponent<RectTransform>().anchoredPosition);

            distanceList.Add(distance);
        }


        audioManager.PlaySFX("MidoriSkill");
        // 가장 거리가 가까운 적 5명의 index를 저장
        for(int i=0; i<5; i++)
        {
            minIndexArray[i] = distanceList.IndexOf(distanceList.Min());
            distanceList[minIndexArray[i]] = distanceList.Max() + 1; // 제일 가까운 적에게 최댓값 + 1 을 덧씌워서 2, 3번째로 가까운 적을 차례로 찾도록 함
            Debug.Log(minIndexArray[i]);
        }

        // 가장 거리가 가까운 적 최대 5명에게 헤일로를 씌움
        for(int i=0; i< distanceList.Count; i++)
        {
            midoriHaloArray[i] = GetFrefab("MidoriHalo");
            midoriHaloArray[i].SetActive(true);
            midoriHaloArray[i].transform.SetParent(_aliveEnemyParent.transform.GetChild(minIndexArray[i]));
            midoriHaloArray[i].GetComponent<RectTransform>().anchoredPosition = new Vector2(0, 0);
            midoriHaloArray[i].GetComponent<Image>().DOFade(1, 1);
        }

        yield return new WaitForSeconds(1);
        
        // 가장 거리가 가까운 적 5명에게 총알을 발사하며 씌워진 헤일로 없애기
        for(int i=0; i<5; i++)
        {
            // 씌운 헤일로가 있을 때만 동작
            if (midoriHaloArray[i] != null)
            {
                midoriHaloArray[i].GetComponent<Image>().DOFade(0, 1);
                midoriHaloArray[i].transform.SetParent(_aliveEnemyParent.transform.GetChild(minIndexArray[i]));
            }

            StartCoroutine(ShootMidoriBullet(_aliveEnemyParent.transform.GetChild(minIndexArray[i]).GetComponent<RectTransform>().anchoredPosition));

            yield return new WaitForSeconds(0.2f);
        }

        // 헤일로 반환
        for(int i=0; i< distanceList.Count; i++)
        {
            midoriHaloArray[i].transform.SetParent(haloParent.transform);
            _midoriHaloQueue.Enqueue(midoriHaloArray[i]);
        }

        _isActivatingSkill = false;
    }

 

 

 

스킬을 사용하면 소리와 함께 가장 가까운 적 5명을 스캔하고, 스캔한 적에게 미도리의 헤일로 이미지를 띄운다.

이후 헤일로가 사라지며 총알을 1발씩 총 5발 발사한다.

5명보다 적은 경우, 가까운 적 순서로 추가 총알을 발사한다. / 예) 적이 세 명인 경우 거리 순으로 1, 2, 3, 1, 2

 

이를 위해 총알이 적 방향으로 날아갈 때 적이 위치한 방향을 향하도록 해야했는데, 이런 기능을 하는 함수가 있긴 했으나 3D를 기준으로 되어있어서 2D에서도 정상적으로 작동시키기 위해 작업이 필요했다.

// mainObject가 direct을 바라보도록 회전
    private void LookRotation2D(GameObject mainObject, Vector2 direction)
    {
        Vector3 vectorToTarget = direction - mainObject.GetComponent<RectTransform>().anchoredPosition;
        vectorToTarget.z = 0;
        float angle = Mathf.Atan2(vectorToTarget.y, vectorToTarget.x) * Mathf.Rad2Deg - 90;
        mainObject.transform.rotation = Quaternion.AngleAxis(angle, Vector3.forward);
    }

그래서 이 함수를 만들어서 사용했다. 검색해서 찾은 내용을 내 상황에 맞게 수정했는데, 회전할 객체를 기준으로 목표까지의 Vector 값을 가져와서 Atan2 함수를 이용해 해당 위치까지의 각도를 라디안 단위로 계산하고, 여기에 Mathf.Rad2Deg를 곱해서 각도로 변환해주는 것 같다. -90은 안 하는 경우도 많던데 나의 경우는 해야 제대로 작동되었다.

 

 

그리고 코드의 명명 규칙을 만들어서 가독성을 좋게 하기로 했다.

멤버 변수는 변수 이름 앞에 _를, 상수는 이름 앞에 s_를 붙이는 등을 적용했다.

그리고 Button의 이름이 뒤죽박죽이라 이름 뒤에 Btn을 붙이는 등으로 통일했다.

C#에 관해서는 마이크로소프트의 가이드를 참조했으나, 유니티에서 변수나 함수 등의 이름을 짓는 규칙에 대해서도 찾아봤으나 이렇다 싶은 좋은 자료는 찾아보지 못했다.

그래도 내 나름대로의 규칙을 만들어 적용하고 깔끔하게 코드를 작성하는 습관을 들이는 것이 좋을 것 같았고, 이렇게 하면서 포트폴리오 등에서도 언급할 수 있을 것 같았다.

 

오늘까지 완성한 부분은 다음과 같다.

점점 이것저것 하나씩 추가되다보니 조금씩 게임다운 느낌이 나는 것 같다.

 

내일 목표는 아래와 같다.

1. 스킬로 적을 처치할 때 일부 경우에서 적이 정상적으로 사망 처리가 되지 않는 버그 수정하기

2. 적이 죽을 때 폭발하는 이미지 등 찾기

3. 공격, 스킬 아이콘 표시하고 쿨타임 가시화하기

4. 스킬 쿨타임 중 사용시 유저에게 알려주는 기능 추가(텍스트 혹은 경고음 등으로)

5. 적에게 닿으면 아군 비행기 폭발하는 기능 구현

 

 

오늘 작성한 것은 총 3일치의 개발 일지인데, 블로그에 글은 안올렸지만 매일 꾸준히 깃에 커밋은 했다. 또, 매일 도트 리소스를 찍는 것을 목표로 하긴 했으나 지금 당장 그릴만한 리소스가 없어서 이 목표는 당분간 보류해야겠다. 그리고 주말 동안 이것저것 많이 찾아보고 고민도 많이 했다.

고민한 내용을 간략히 적자면, 나는 게임 개발하는 것이 즐겁고, 앞으로도 계속 이 일을 하고 싶다. 혼자서 게임을 만드는 것은 어렵기 때문에 함께 게임을 만들 수 있고 경력과 경험을 쌓을 수 있는 회사에 들어갈 필요가 있다. 이를 위해 여러 기술들을 적용하고, 코드 퀄리티에 신경쓰면서 이런 것들을 기록하고 어필하여 매력적인 포트폴리오를 만들어야 한다. 또한, 3D 게임을 다뤄본 적이 거의 없다시피 하기 때문에 이런 것도 다룰 수 있는 기회가 필요하다. 이를 위해 강의를 하나 샀다. 가격은 좀 비싸긴 하지만 지금의 나에게 딱 필요한 강의라고 생각해서 구입했다. 현재 만드는 게임을 완성하고 이를 포트폴리오로 한 번 도전해보고, 안되면 3D 게임도 만들어서 도전해보고, 그래도 안 된다면... 인디게임을 만들면서 출시도 해보고 경험을 쌓아나가야겠다. 그러면서 많은 기술들을 활용해보고, 지식들도 쌓이면 음... 매력적인 개발자가 되지 않을까? 아마 기회가 있을 것이다. 그러기 위해서라도 열심히 해야겠다.

오늘 추가한 도트는 이 5가지로, 배경에 사용할 별 모양이다. 배경을 어떻게 구성하면 좋을까 생각할수록 점점 고민만 깊어지는 것 같아서 막무가내로 찍어보았다. 수정을 거치다보니 그럭저럭 봐줄만하게 나왔다. 잘해야 한다는 부담감과, 익숙하지 않은 것을 해야 하는 두려움이 겹쳐지다보니 괜히 걱정만 느는 것 같다. 확실히 나한테 있어 일러스트레이터는 소중한 존재다. 향후 함께 일하는 일러스트레이터들에게 아무리 감사를 표해도 모자라지 않을 것 같다.

 

그 외에는 어제 목표로 했던 함수 수정과 Collider를 활용하여 총알 적중 기능, 배경 화면을 구현했다.

public IEnumerator ShootMidoriBullet(Vector2 direction)
    {
        GameObject bullet = GetMidoriBullet();

        bullet.SetActive(true);
        bullet.GetComponent<RectTransform>().anchoredPosition = new Vector2(midoriAirplane.GetComponent<RectTransform>().anchoredPosition.x, midoriAirplane.GetComponent<RectTransform>().anchoredPosition.y + 50);

        float bulletDuration = Vector2.Distance(bullet.GetComponent<RectTransform>().anchoredPosition, direction) / 1200;
        bullet.GetComponent<RectTransform>().DOLocalMove(direction, bulletDuration).SetEase(Ease.Linear);

        yield return new WaitForSeconds(bulletDuration);

        midoriBulletQueue.Enqueue(bullet);
    }

어제 계획했던 대로 목표 지점인 Vector2를 매개변수로 가지게끔 했다. 발사되는 위치와 목표 지점 사이의 거리를 구해서 속도를 일정하게 조절했다.

void Update()
    {
        // 상하좌우 이동, 화면 밖으로 벗어나지 않도록 움직임에 제한을 둠
        float moveSpeed = 500f * Time.deltaTime;

        if (Input.GetKey(KeyCode.UpArrow))
        {
            if (!(midoriAirplane.GetComponent<RectTransform>().anchoredPosition.y >= 500))
            {
                midoriAirplane.GetComponent<RectTransform>().anchoredPosition = new Vector2(midoriAirplane.GetComponent<RectTransform>().anchoredPosition.x, midoriAirplane.GetComponent<RectTransform>().anchoredPosition.y + moveSpeed);
            }
        }
        if (Input.GetKey(KeyCode.DownArrow))
        {
            if (!(midoriAirplane.GetComponent<RectTransform>().anchoredPosition.y <= -500))
            {
                midoriAirplane.GetComponent<RectTransform>().anchoredPosition = new Vector2(midoriAirplane.GetComponent<RectTransform>().anchoredPosition.x, midoriAirplane.GetComponent<RectTransform>().anchoredPosition.y - moveSpeed);
            }            
        }
        if (Input.GetKey(KeyCode.LeftArrow))
        {
            if (!(midoriAirplane.GetComponent<RectTransform>().anchoredPosition.x <= -500))
            {
                midoriAirplane.GetComponent<RectTransform>().anchoredPosition = new Vector2(midoriAirplane.GetComponent<RectTransform>().anchoredPosition.x - moveSpeed, midoriAirplane.GetComponent<RectTransform>().anchoredPosition.y);
            }
        }
        if (Input.GetKey(KeyCode.RightArrow))
        {
            if (!(midoriAirplane.GetComponent<RectTransform>().anchoredPosition.x >= 500))
            {
                midoriAirplane.GetComponent<RectTransform>().anchoredPosition = new Vector2(midoriAirplane.GetComponent<RectTransform>().anchoredPosition.x + moveSpeed, midoriAirplane.GetComponent<RectTransform>().anchoredPosition.y);
            }
        }
        // Z키를 눌러 총알을 발사
        if (Input.GetKeyDown(KeyCode.Z))
        {
            audioManager.PlaySFX("Shoot");
            StartCoroutine(ShootMidoriBullet(new Vector2(midoriAirplane.GetComponent<RectTransform>().anchoredPosition.x, 1200)));
        }
        if (Input.GetKey(KeyCode.X))
        {

        }
    }

Update 함수에서 플레이어가 이동하는 부분도 고쳤다. Collider를 추가하다보니 프레임이 많이 떨어졌는지 움직임이 엄청나게 느려졌다. 처음엔 왜 느려졌지? 하다가 Time.deltaTime의 존재를 깨닫고 수정했다.

using System.Collections;
using System.Collections.Generic;
using UnityEngine;

public class BulletController : MonoBehaviour
{
    private void OnTriggerEnter2D(Collider2D other)
    {
        if(other.CompareTag("Enemy_Shooting"))
        {
            ShootingGameManager.instance.HitMidoriBullet(gameObject, other.gameObject);
        }
    }
}

이후에는 총알에 Collider를 추가해서 적중 기능을 추가했다. 아군 총알과 아군 비행기에만 스크립트를 달아주면 될 것 같아서 그렇게 하기로 했다. 적 비행기랑 아군 총알 중에 어떤 것이 많이 소환될 지도 생각해봤다. 적 비행기가 나오는 패턴에 달려있겠지만, 플레이어가 제때 적들을 처리하지 못하면 많이 쌓일 것 같아서 총알에 다는 것이 조금 더 효율적이지 않을까 생각해서 총알에 스크립트를 달았다.

 

Collider는 Collider 2D를 사용했는데, 처음에 총알은 Polygon Collider로 이미지와 동일한 모양으로 만들었다. 그런데 만들기도 번거롭고 성능에 영향을 줄 것 같아서 조금 디테일을 줄였다. 비행기는 그나마 중요할 것 같아서 Polygon으로, 적 개체는 Box/Capsule Collider로 만들었다. 처음에는 OnTriggerEnter를 사용해서 왜 작동을 안하나 한참 찾아보고 고민을 했는데 알고보니 2D Collider를 사용할 때 쓰는 OnTriggerEnter2D가 따로 있었다.

 

아무튼, 적중하면 ShootingGameManager에 정보를 넘겨서 처리하게끔 했다. 이 과정에서 매 스크립트마다 찾기는 번거로울 것 같아서 instance를 활용했다. 싱글톤이라고 하는데... 아마 저 스크립트는 저것 하나만 사용할 것이라고 한정지어서 찾기 편하게끔 해주는 것이 아닐까 싶다. 정확히 알게 위해서 추후에 조금 더 찾아봐야겠다.

public void HitMidoriBullet(GameObject midoriBullet, GameObject enemy)
    {
        midoriBullet.SetActive(false);
        EnemyController enemyController = enemy.GetComponent<EnemyController>();

        if(enemyController.GetEnemyHP() > 1)
        {
            enemyController.SetEnemyHP(enemyController.GetEnemyHP() - 1);
            audioManager.PlaySFX("EnemyHit");
        }
        else
        {
            audioManager.PlaySFX("EnemyDestroy");
            enemy.SetActive(false);
        }
    }

 

피격 처리 함수 내용은 이렇다. 총알의 Active 상태를 false로 하고, 적의 체력을 1씩 깎아 0이 되면 적 개체를 없애버리는 식이다. 적 개체에도 오브젝트 풀링 기법을 적용할 계획이기 때문에 이 부분은 내일 수정할 것 같다. 그리고 적이 맞으면 피격음과 파괴되면 파괴되는 효과음을 넣었다. 피격음은 몰라도 파괴음은 약간 애매한 느낌이 있어서, 폭발하는 애니메이션을 넣을 예정이기 때문에 그와 걸맞는 효과음을 찾아서 넣어야겠다.

using System.Collections;
using System.Collections.Generic;
using UnityEngine;

public class EnemyController : MonoBehaviour
{
    private int enemyHP = 5;

    public int GetEnemyHP()
    {
        return enemyHP;
    }

    public void SetEnemyHP(int hp)
    {
        enemyHP = hp;
    }
}

적의 체력 정보를 저장할 간단한 스크립트도 만들었다. 처음엔 EnemyManager라고 지었는데, 여러 개가 만들어질 것이다보니 Manager보단 Controller라는 이름이 맞을 것 같아서 변경했다. 이름을 어떻게 지으면 좋을지도 약간 고민이다. 지금도 함수 이름이 동사, 명사, 동명사 등 썩 일정하지가 못해서 나중에 프로그램이 커지면 헷갈릴 위험이 있을 것 같았다. 이런 건 찾아봐도 딱히 규칙같은 게 없는 것 같았다. 아마 조직마다 다르게 정해놓고 쓰지 않을까 싶다.

using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using DG.Tweening;
using UnityEngine.UI;

public class StarColorController : MonoBehaviour
{
    [SerializeField] public float startDelayTime;

    private void Start()
    {
        gameObject.GetComponent<Image>().color = new Color(1, 1, 1, 0);
        Invoke(nameof(StarTwinkle), startDelayTime);
        Invoke(nameof(StarFallLoop), StarFallStart());
    }

    private void StarTwinkle()
    {
        Sequence starTwinkle = DOTween.Sequence();

        starTwinkle.Append(gameObject.GetComponent<Image>().DOFade(1, 1)).Append(gameObject.GetComponent<Image>().DOFade(0, 1)).SetLoops(-1, LoopType.Yoyo);
    }

    private float StarFallStart()
    {
        Sequence starFall = DOTween.Sequence();

        float spendTime = (gameObject.GetComponent<RectTransform>().anchoredPosition.y + 600) / 300;

        starFall.Append(gameObject.GetComponent<RectTransform>().DOLocalMoveY(-600, spendTime).SetEase(Ease.Linear))
            .Append(gameObject.GetComponent<RectTransform>().DOLocalMoveY(600, 0f));

        return spendTime;
    }

    private void StarFallLoop()
    {
        Sequence starFall = DOTween.Sequence();

        float spendTime = (600 + 600) / 300;

        starFall.Append(gameObject.GetComponent<RectTransform>().DOLocalMoveY(-600, spendTime).SetEase(Ease.Linear))
            .Append(gameObject.GetComponent<RectTransform>().DOLocalMoveY(600, 0f)).SetLoops(-1);
    }
}

이외에는 배경에서 별이 반짝이면서 움직이는 효과를 추가했다. 목표로 하진 않았으니 내일 할까 싶다가 왠지 의욕이 붙어서 오늘 구현했다. 예전에 직장에서 일을 할 때 비슷한 효과를 넣은 적이 있는데, 당시에는 개수가 적어 일일히 처리하는 것이 빠르겠다 싶어서 각각의 개체에 애니메이션을 따로 달아주었는데, 오히려 그게 시간이 더 오래 걸렸다. 이번에는 개수도 많다보니 스크립트로 알아서 처리하게끔 만들었다. 처음에 불투명도를 0으로 해서 안보이게 한 후, 별마다 다른 시작 딜레이를 주고, 1초마다 깜빡이게끔 해서 각기 다른 타이밍에 깜빡거리도록 했다. 그리고 현재 위치를 고려해서 별을 밑으로 내려오게 하고, 이후에는 위에서 아래로 내려오는 것을 반복하도록 했다. 근데 그러다보니 Sequence가 너무 많아져서 DOTween의 기능을 너무 많이 사용하게 되었다. 기본적으로 얼만큼 사용할 것인지 크기가 정해져 있나본데, 그 크기가 부족해서 확장했다고, 스크립트에 해당 내용을 넣어서 확장하라는 경고가 떴다.

DOTween.SetTweensCapacity(500, 125);

 

비효율적인건가? 싶은 생각도 들었다. 하지만 그래봤자 애니메이션으로 변경 정도만 가능할 것 같았다. 이외에는 배경을 외부에서 만들어서 재생하는 것. 아마 이 많은 별들을 따로 처리하는 것보다는 외부 프로그램에서 만든 이미지 혹은 동영상을 재생하는 것이 효율적일 것 같았다. 하지만 그런 걸 나 혼자 만들 자신이 없어서 지금은 이대로 가기로 했다.

 

그래서 오늘까지 완성된 부분은 다음과 같다.

 

슬슬 조금씩 게임같은 느낌이 나는 것 같다. 그래도 아직 갈 길이 먼 것 같기도 하다. 적 움직임, 스폰, 스킬 구현, 보스 구현, UI 구현 등등... 움직일 때 효과도 넣으면 좋을 것 같은데 어떻게 할지 아직 잘 모르겠다. 내일도 필요한 부분들을 구현해나가면서 고민해봐야겠다.

 

내일의 목표는 이렇다.

1. 아리스 도트 헤일로 찍기

2. 어울리는 배경음악 찾아서 넣기

3. 배경화면 속도 조금 늦추기

4. 적 개체에 오브젝트 풀링 기법 적용하기

5. 오브젝트 풀링 기법을 활용해서 스킬 구현하기(가능하다면)

 

 

오늘은 굉장히 설렁설렁 한 것 같은데, 배경 화면 움직임을 구현하면서 조금 분량이 채워진 것 같다. 이전처럼 엄청난 열정을 가지고 하거나 이런 것은 아닌 것 같다. 그래도 오늘까지 재개한 후 8일 동안은 꾸준히 해와서 다행이다. 앞으로도 꾸준히 매일 이런 작업들을 쌓아나가면 분명 많이 성장할 수 있을 것이라고 생각한다. 그러면서도 과연 이게 맞는 길일까 하는 고민이나 걱정이 들기도 한다. 내일은 그런 생각들을 정리해보는 것도 좋을 것 같다. 생각이 정해지고 목표가 정해지면 그만큼 의욕도 생길 것이다.

 

오늘 추가한 이미지는 이 두 개이다. 배경이 흰색이라 잘 안보이는데 흰색 기둥 끝자락에 초록색/빨간색 삼각형이 달려있다. 일단 임시로 만든 총알이다.

 

이후에는 슈팅 게임 개발에 착수했다. 먼저 키 입력을 구현했다.

void Update()
    {
        // 상하좌우 이동, 화면 밖으로 벗어나지 않도록 움직임에 제한을 둠
        if (Input.GetKey(KeyCode.UpArrow))
        {
            if (!(midoriAirplane.GetComponent<RectTransform>().anchoredPosition.y >= 500))
            {
                midoriAirplane.GetComponent<RectTransform>().anchoredPosition = new Vector2(midoriAirplane.GetComponent<RectTransform>().anchoredPosition.x, midoriAirplane.GetComponent<RectTransform>().anchoredPosition.y + 0.2f);
            }
        }
        if (Input.GetKey(KeyCode.DownArrow))
        {
            if (!(midoriAirplane.GetComponent<RectTransform>().anchoredPosition.y <= -500))
            {
                midoriAirplane.GetComponent<RectTransform>().anchoredPosition = new Vector2(midoriAirplane.GetComponent<RectTransform>().anchoredPosition.x, midoriAirplane.GetComponent<RectTransform>().anchoredPosition.y - 0.2f);
            }            
        }
        if (Input.GetKey(KeyCode.LeftArrow))
        {
            if (!(midoriAirplane.GetComponent<RectTransform>().anchoredPosition.x <= -500))
            {
                midoriAirplane.GetComponent<RectTransform>().anchoredPosition = new Vector2(midoriAirplane.GetComponent<RectTransform>().anchoredPosition.x - 0.2f, midoriAirplane.GetComponent<RectTransform>().anchoredPosition.y);
            }
        }
        if (Input.GetKey(KeyCode.RightArrow))
        {
            if (!(midoriAirplane.GetComponent<RectTransform>().anchoredPosition.x >= 500))
            {
                midoriAirplane.GetComponent<RectTransform>().anchoredPosition = new Vector2(midoriAirplane.GetComponent<RectTransform>().anchoredPosition.x + 0.2f, midoriAirplane.GetComponent<RectTransform>().anchoredPosition.y);
            }
        }
        // Z키를 눌러 총알을 발사
        if (Input.GetKeyDown(KeyCode.Z))
        {
            audioManager.PlaySFX("Shoot");
            StartCoroutine(ShootMidoriBullet());
        }
        if (Input.GetKey(KeyCode.X))
        {

        }
    }

코드 블럭은 오랜만에 넣어보는 것 같다. 얼마간은 이전과 비슷한 코드의 반복이어서 그랬던 것 같다. 오랜만에 코드 관련해서 쓸 내용이 있어서 기쁘다.

 

기존에는 좌, 우 움직임만 넣기로 계획했는데 막상 그렇게 해보니 조작이 영 불편했다. 갤러그는 하도 옛날 게임이라 뭐 그럴 수도 있지 싶은데 일반적인 슈팅 게임을 즐기는 유저가 접하면 불편함을 느낄 것 같아서 상하 움직임도 추가했다. 우선 화면의 크기나 위치를 확정지은 것이 아니라 일정 범위 내에서만 움직일 수 있도록 했다.

 

이후에는 공격을 구현했다. 오브젝트를 생성하고 파괴하는 작업에는 많은 비용이 들기 때문에, 이미 생성된 오브젝트를 재활용하여 생성과 파괴를 최소화하는 오브젝트 풀링 기법을 활용했다. 이전부터 말은 많이 들었는데 구현을 해본 적은 없었다. 이번에 해볼 수 있게 되었다.

 

[Unity3D] Programming - 오브젝트 풀링 기법 구현하기 :: 베르의 프로그래밍 노트 (tistory.com)

 

[Unity3D] Programming - 오브젝트 풀링 기법 구현하기

Programming - 오브젝트 풀링 기법 작성 기준 버전 :: 2019.2 프로그래밍에서 오브젝트를 생성하거나 파괴하는 작업은 꽤나 무거운 작업으로 분류된다. 오브젝트 생성은 메모리를 새로 할당하고 리소

wergia.tistory.com

기본적으로 해당 사이트의 코드를 참조했는데, 약간의 수정을 거쳤다.

 

public class ShootingGameManager : MonoBehaviour
{
    [SerializeField] AudioManager audioManager;
    [SerializeField] GameObject midoriAirplane;
    [SerializeField] GameObject midoriBullet; // Prefabs
    [SerializeField] GameObject bulletParent;

    private Queue<GameObject> midoriBulletQueue = new Queue<GameObject>();

    // 미도리의 총알 객체를 얻어오는 함수
    private GameObject GetMidoriBullet()
    {
        if(midoriBulletQueue.Count > 0)
        {
            return midoriBulletQueue.Dequeue();
        }
        else
        {
            return CreateNewMidoriBullet();
        }
    }

    // 미도리의 총알 객체를 발사하는 함수
    public IEnumerator ShootMidoriBullet()
    {
        GameObject bullet = GetMidoriBullet();
        bullet.gameObject.SetActive(true);
        bullet.GetComponent<RectTransform>().anchoredPosition = new Vector2(midoriAirplane.GetComponent<RectTransform>().anchoredPosition.x, midoriAirplane.GetComponent<RectTransform>().anchoredPosition.y + 50);
        bullet.GetComponent<RectTransform>().DOLocalMoveY(1200, 1).SetEase(Ease.Linear);

        yield return new WaitForSeconds(1);

        midoriBulletQueue.Enqueue(bullet);
    }

    // 새로운 미도리의 총알을 만드는 함수
    private GameObject CreateNewMidoriBullet()
    {
        GameObject bullet = Instantiate(midoriBullet);
        bullet.gameObject.SetActive(false);
        bullet.transform.SetParent(bulletParent.transform);
        bullet.GetComponent<RectTransform>().localScale = new Vector3(1, 1, 1);
        return bullet;
    }

링크 내에서는 객체마다 따로 스크립트를 두어 Update 함수 내에서 총알이 움직이게끔 했는데, Update 함수가 너무 많이 사용되는 것은 아닐까 싶어서 DOTween의 기능을 활용했다. 총알마다 따로 스크립트를 두지 않고 하나의 스크립트 내에서 발사될 때 목적지를 정하고, 1초 후에 반환되도록 코드를 짰다. 근데 지금 보니 문제가 있다. 총알을 발사하는 위치에 따라 총알의 속도가 다르다는 것이다. 내일 이 부분을 보완해야겠다. 그리고, 스킬을 사용할 때도 동일한 총알이 발사될테니, 목적 지점을 저장한 Vector2를 매개변수로 받아서 실행하도록 변경해야겠다.

이외에는 사용될 총알의 수를 예상해서 처음에 만들어 놓는 작업도 필요할 것 같다. 아직 공격속도를 정하지 않아서 이 부분은 조금 나중에 작업할 것 같다.

그리고 효과음도 추가했다. 이전에 사이트를 적어놔서 다행히 쉽게 찾았다.

 

그래서 오늘 완성된 부분은 다음과 같다.

 

내일 목표는 다음과 같다.

 

1. 배경화면 간단하게라도 작업해보기

2. ShootMidoriBullet 함수 수정 (Vector2 매개변수 추가, 총알 속도 항상 일정하도록 변경)

3. Collider 작업하여 총알, 적, 아군 충돌에 따른 기능 구현하기

 

 

적이 한 방에 죽으면 그만큼 적을 많이, 자주 등장시켜야 하기 때문에 한 방에는 안 죽게끔 할 것 같다. 그 외에 적들의 행동 패턴이나 공격 패턴 등을 생각해봐야겠다. 비행선이 움직일 때 약간의 불꽃 모션이 나오면 좋을 것 같기도 하다. 기획은 완료되지 않았지만 필수적인 기능들을 추가하면서 계속 고민해봐야겠다.

 

오늘 추가한 도트는 이 2종이다. 모모이꺼는 미도리꺼랑 똑같이 생겨서 색만 변경했다.

 

이외에는 어제 계획했던 물음표 애니메이션을 추가하고, 배경을 넣고 20개 정도의 대사를 추가해서 1번 게임(슈팅 게임) 진행 전까지 스토리 진행을 마쳤다. 크게 달라진 부분이 있는 것은 아니라 오늘은 녹화는 하지 않았다.

 

 

스토리 상에서 잠깐 지나가듯 1번 게임을 보여줘야 하는 부분이 나왔기 때문에 1번 게임을 먼저 만드는 것이 순서상 맞겠다고 생각을 했다. 그런데 막상 개발하려고 생각하니 조금 막막했다. 우선 총알을 추가해서 총알과 기체들에 콜라이더를 넣고, 충돌 판정을 통해 적을 공격하고 공격받는 것을 구현하는 기본적인 슈팅 게임의 구조까지는 문제될 것이 없었으나, 적이 몇 명 등장하고, 어떻게 움직이는지, 배경 화면은 어떻게 구성할 것인지 등의 요소가 문제였다. 그냥 단순히 갤러그 비스무리하게라고 생각했지만 모든 게임은 어떤 방식으로 진행되는지 명확하게 정해져 있다.

갤러그를 분석해보니 적들이 일정한 패턴으로 곡선을 그리며 나타나거나, 매 순간 적 둘이 곡선 비행을 하며 플레이어의 방향으로 내려오면서 총알을 발사하는 등의 행동을 취했다. 이외에는 특수한 적이 우주선을 나포해가고, 이를 다시 되찾아서 2대의 비행기로 동시에 공격하는 등의 특수한 기믹도 존재했다. 얼핏 보기에 갤러그는 옛날 게임이라 단순해보이지만 게임 내부의 모든 것들은 철저하게 설계된 것들이었다. 아, 이래서 기획자가 있는거구나 하는 생각이 절실하게 들었다.

 

이후에는 다른 게임을 참고할 겸 찾아보았다. 이전에 게임개발부 팬 게임 중에 슈팅 게임을 봤던 것 같아서 해당 내용을 찾아보았다. 캐릭터도 게임개발부 4명 전부 고를 수 있었고, 각자 블루 아카이브 내 스킬을 모티브로 한 듯한 공격이나 스킬들이 인상적이었다. 나도 원래는 유우카를 보스로 할까 하다가 도트를 찍기 힘들 것 같아서 그만뒀는데, 이 팬게임의 스테이지 1 보스가 유우카인 것을 보고 사람들 생각하는 게 비슷하구나 싶은 생각이 들었다. 하기사 게임개발부 스토리에서 유우카가 그런 이미지로 나오니까 당연한 것인가 싶기도 하다. 아, z가 공격에 x가 스킬인 것도 같았다. 이거야 근데 뭐... 다른 게임에서도 비슷하긴 하니까... 근데 이렇게 놓고 보니 뭐가 이렇게 비슷한 부분이 많은가 싶었다.

 

이외에는 관련 내용이 언급된 글들이 있어서 한국, 일본 가이드를 다시 확인해봤다.

㈜넥슨코리아(이하 넥슨)는 창작자에게 자사 게임을 활용한 유저 창작 콘텐츠(User Generated Contents, 이하 UGC) 제작 시 주의사항을 안내하고자 본 게임 IP 사용 가이드를 마련하였습니다.
넥슨은 본 가이드가 많은 창작자의 열정적인 창작 활동에 도움을 주고, 이를 통해 게임의 즐거움이 새로운 형태로 공유되기를 기원합니다.
① 게임IP(Intellectual Property)란 게임의 세계관, 스토리, 캐릭터, 몬스터, 이미지, 배경음악, 영상 등 게임을 구성하는 일체의 저작물을 말합니다.② UGC는 게임굿즈, 게임영상(게임 플레이 영상, 팬비디오 등), 팬아트, 팬소설 등 창작자 여러분이 넥슨의 게임IP에 새로운 창작성을 부가하여 제작한 2차 창작물을 말합니다. 넥슨은 게임에 다양한 즐거움을 더해주는 창작자 여러분 및 UGC를 존중합니다.다만, 게임IP를 활용하여 게임을 제작하고 배포 및 서비스하는 것은 원칙적으로 허용되지 않습니다.③ UGC를 공개할 때는 반드시 그 출처(넥슨 및 해당 게임명)을 명시해 주시기 바랍니다. 특히, 게임 음원은 관련 게임의 유튜브 채널(없을 경우 관련 해당 게임명) 또는 네코드뮤직 유튜브 채널을 링크형태로 기재해 주시기 바랍니다.④ UGC를 영리적인 용도로 사용하는 것은 허용되지 않습니다.1. UGC 제작 시 제작에 대한 대가로 제작비, 협찬금 등 타인에게 금전적 보상을 요구하거나 받아서는 안 됩니다.2. UGC를 유료로 판매하거나 공개, 배포, 사용허락 등에 대한 대가를 받을 수 없습니다.3. 경제적 이익이 제공되거나 UGC가 영리적으로 사용될 수 있는 공모전에 UGC를 출품할 수 없습니다.⑤ 단, 아래 경우는 예외적으로 허용됩니다1. 누구나 무료로 볼 수 있는 콘텐츠 플랫폼(유튜브, 트위치, 아프리카TV 등)에 UGC를 게재하여 얻을 수 있는 광고 수익 및 후원금 수익은 금지하지 않습니다. 이에는 게임 음원을 그대로 사용하여 제작한 UGC도 포함됩니다.2. 게임IP를 활용하여 비영리 목적의 디지털 콘텐츠(게임, 어플리케이션 등) 제작을 희망할 경우 고객센터(http://help.nexon.com) 개별 문의주시길 바랍니다.3. 네코제, 마비노기 판타지파티, 사이퍼즈 한데이, 던파 플레이마켓 등 넥슨이 공식적으로 개최하는 행사에서는 게임굿즈를 판매할 수 있습니다. 보다 구체적인 내용은 개별 게임 내 행사 참여 홈페이지를 통해 확인하실 수 있습니다.⑥ 창작자는 UGC 제작 시 저작권법 및 관련 법령을 준수하고 선량한 풍속 기타 사회질서를 위반하지 않아야 합니다.1. 타인의 콘텐츠를 창작자 여러분의 UGC에 사용하고 싶다면 반드시 당해 콘텐츠 제작자에게 사용 허락을 받아야 합니다.2. UGC에는 아래와 같은 내용이나 표현이 포함되어서는 안 됩니다.- 법률을 위반하거나 지식재산권, 초상권 등 제3자의 권리를 침해하는 내용- 특정한 정치적 견해나 입장을 나타내는 내용- 특정 집단, 종교, 인종, 성별 등을 차별, 비하하거나 혐오를 조장하는 내용- 음란하거나 타인에게 성적 수치심을 유발시킬 수 있는 내용- 잔인하거나 타인에게 혐오감을 유발시킬 수 있는 폭력적이고 자극적인 내용- 넥슨이 추구하는 가치나 내부 정책에 위반되는 내용- 선량한 풍속 기타 사회질서에 위반되는 내용- 모든 연령층이 볼 수 없는 내용⑦ 넥슨의 공식 콘텐츠로 오인될 수 있는 내용으로 UGC를 제작하거나 배포해서는 안 됩니다.⑧ 본 게임 IP 사용 가이드 및 법률을 위반하는 UGC에 대해서 넥슨은 즉시 게임IP 사용 허락을 취소하고 해당 UGC의 게재, 배포 및 전송 중단 등을 요구할 수 있습니다.⑨ 본 가이드는 창작자 여러분의 원활한 창작 활동을 돕기 위해 UGC 제작 시의 기본적인 주의사항을 안내하는 것으로 사전 고지 없이 변경될 수 있으며, 개별 사안에 따라 별도의 정책이 적용될 수 있습니다.
지금 이 순간에도 수많은 게임팬이 창작자 여러분의 UGC를 함께 즐기고 있습니다. 여러분의 열정에 감사드리며, 넥슨은 UGC로 게임을 새롭게 표현하고 즐기는 창작자 여러분을 진심으로 응원합니다.
본 가이드와 UGC에 대해 더 궁금하신 부분은 고객센터(http://help.nexon.com) 문의주시길 바랍니다.
더불어 UGC 로 인한 저작권, 명예훼손, 초상권 등 권리 침해 시 신고 방법은 넥슨 권리침해신고 안내(http://help.nexon.com/Violation/Violationguide)에서 확인하실 수 있습니다.
감사합니다.

여기서 문제가 될 만한 부분은 "게임IP를 활용하여 게임을 제작하고 배포 및 서비스하는 것은 원칙적으로 허용되지 않습니다." 이 부분이다. 다만, "2. 게임IP를 활용하여 비영리 목적의 디지털 콘텐츠(게임, 어플리케이션 등) 제작을 희망할 경우 고객센터(http://help.nexon.com) 개별 문의주시길 바랍니다." 이런 내용이 있고, 넥슨 게임 IP를 활용한 팬게임들이 이미 많이 있기 때문에 괜찮지 않을까? 라고 생각하고 있다. 또, 배포/서비스를 하지 않고 혼자 만드는 것 자체는 아마 큰 문제가 되지는 않을 것이라고 생각한다.

 

이후는 일본 내 블루 아카이브 가이드라인이다.

「ブルーアーカイブ」二次創作・ゲーム実況配信及び動画投稿に関するガイドライン 株式会社Yostar (以下、当社)は、提供するゲームアプリ(以下、当社ゲーム)をより多くの方々に知っていただき、親しんでいただけるようコンテンツの二次創作利用に関するガイドラインを制定いたしました。   当ガイドラインに添う形であれば、利用についての連絡は不要です。ガイドラインに関してご質問などございましたら、【cs@bluearchive.jp】にお問い合わせよりご連絡ください。   ★二次創作を行う者について★ 個人または法人格のない団体は、後述するような当社が定義する「非営利目的」の場合に限り、「ブルーアーカイブ」を題材とした二次創作物(同人誌・同人グッズ・デジタル作品など…)の制作・配布・頒布を自由に行っていただいて問題ありません。 法人格のある企業・団体による二次創作活動を行いたい方、または本ガイドラインの判断基準を超える二次創作活動を行いたい方は、事前に当社にお問合せを頂くようお願いいたします。   ★非営利/営利目的とみなす判断基準★ 個人または法人格のない団体は、非営利目的かつ日本国内での発表・流通の場合に限り、自由に二次創作物を制作していただいて問題ありません。 また、趣味の範囲で利用し、継続的な創作活動のための、原材料費や光熱費諸経費、ツール類など制作にかかった費用程度の対価・利益を得る場合においても非営利目的の範囲内とします。   ★個人または法人格のない団体が行うことができる非営利利用の例★ ・イラスト、フィギュア、人形その他の立体工作物の作成、展示、配布 ・同人誌や二次創作デジタルコンテンツの作成、展示、配布、配信 ・クリエイターファンコミュニティサービスにおける、「ブルーアーカイブ」二次創作作品の掲載 ・コスプレ衣装の作成、展示、配布 ・コスプレ写真やコスプレ動画の配布、展示、上映、ウェブサイト上での公開   ★二次創作活動の禁止事項について★ ・当社ゲームのイメージを逸脱する、当社ゲームのイメージを損なう内容 ・公序良俗に反する内容 ・直接的にコンテンツの素材(イラスト、動画、音声、楽曲等)をコピー、スキャン、サンプリング、トレース等で使用するなど、創作性が無いまたは低いもの ・当社、もしくはゲーム公式と詐称しての活動 ・他者の権利を侵害する、または侵害のおそれがある内容 ・弊社の公式製品かのような誤解を招くおそれがある内容   ★ゲーム実況配信及び動画投稿について★ 「ブルーアーカイブ」を利用した、ゲーム実況の配信及び動画投稿は、個人や法人など利用者の所属や営利・非営利など収益に関わらず、以下の事項に抵触しないかぎり、事前の連絡なく自由に行っていただけます。ご不明な点等ございましたら【info@yostar.co.jp】にお問い合わせよりご連絡ください。    [ゲーム実況に関する禁止事項]  ・当社ゲームのイメージを逸脱する、当社ゲームのイメージを損なう内容  ・当社、もしくはゲーム公式と詐称しての活動  ・公序良俗に反する内容  ・他者の権利を侵害する、または侵害のおそれがある内容  ・政治、宗教、特定の信条の宣伝など、ゲーム実況を見せる以外の目的で利用すること   ガイドラインの内容は、予告なく変更させていただく場合があります。 本ガイドラインの改正によって生じるいかなる損害についても、当社は一切の責任を負いません。予めご了承ください。

=>Papago 번역

「블루 아카이브」2차 창작·게임 실황 전달 및 동영상 투고에 관한 가이드라인

주식회사 Yostar(이하, 당사)는, 제공하는 게임 앱(이하, 당사 게임)을 보다 많은 분들이 알아 주시고, 친숙해지도록 컨텐츠의 2차 창작 이용에 관한 가이드 라인을 제정했습니다.

해당 가이드라인에 따른 형태라면 이용에 대한 연락이 필요 없습니다.가이드라인에 관해서 질문등이 있으시면, cs@bluearchive.jp 로 문의해 연락해 주세요.

★ 2 차 창작을 하는 자에 대하여 ★
개인 또는 법인격이 없는 단체는 후술하는 바와 같은 당사가 정의하는 「비영리 목적」의 경우에 한하여, 「블루 아카이브」를 소재로 한 2차 창작물(동인지·동인 굿즈·디지털 작품 등…)의 제작·배포·반포를 자유롭게 실시해도 문제 없습니다.
법인격이 있는 기업·단체에 의한 2차 창작활동을 하고자 하거나 본 가이드라인의 판단기준을 초과하는 2차 창작활동을 하고자 하는 분은 사전에 당사에 문의하시기 바랍니다.

★비영리/영리목적으로 간주하는 판단기준 ★
개인 또는 법인격이 없는 단체는, 비영리 목적이면서 일본내에서의 발표·유통의 경우에 한해, 자유롭게 2차 창작물을 제작해도 문제 없습니다.
또, 취미의 범위에서 이용해, 계속적인 창작 활동을 위한, 원재료비나 광열비 제경비, 툴류 등 제작에 들어간 비용 정도의 대가·이익을 얻는 경우에 있어서도 비영리 목적의 범위내로 합니다.

★ 개인 또는 법인격이 없는 단체가 할 수 있는 비영리 이용의 예 ★
·일러스트, 피규어, 인형, 기타 입체공작물 작성, 전시, 배포
동인지나 2차 창작 디지털 컨텐츠의 작성, 전시, 배포, 전달
크리에이터 팬 커뮤니티 서비스의 '블루 아카이브' 2차 창작 작품 게재
코스프레 의상 작성, 전시, 배포
코스프레 사진이나 코스프레 동영상 배포, 전시, 상영, 웹사이트 공개

★ 2차 창작활동 금지사항에 대하여 ★
당사 게임의 이미지를 벗어나는, 당사 게임의 이미지를 손상시키는 내용
·공서양속에 반하는 내용
·직접적으로 컨텐츠의 소재(일러스트, 동영상, 음성, 악곡 등)를 카피, 스캔, 샘플링, 트레이스 등으로 사용하는 등 창작성이 없거나 낮은 것
당사 혹은 게임 공식으로 사칭하는 활동
타인의 권리를 침해하거나 침해의 우려가 있는 내용
당사의 공식 제품인 것처럼 오해를 살 우려가 있는 내용

★ 게임 실황 전달 및 동영상 투고에 대해서 ★
「블루 아카이브」를 이용한 게임 실황의 전달 및 동영상 투고는 개인이나 법인 등 이용자의 소속이나 영리·비영리 등 수익에 관계없이, 이하의 사항에 저촉되지 않는 한, 사전의 연락 없이 자유롭게 실시하실 수 있습니다.궁금하신 점이 있으시면 info@yostar.co.jp 로 문의하여 연락주십시오.

 [게임 실황 금지사항]
 당사 게임의 이미지를 벗어나는, 당사 게임의 이미지를 손상시키는 내용
 당사 혹은 게임 공식으로 사칭하는 활동
 ·공서양속에 반하는 내용
 타인의 권리를 침해하거나 침해의 우려가 있는 내용
 정치, 종교, 특정 신조의 선전 등 게임 실황을 보여주는 것 이외의 목적으로 이용할 것

가이드라인 내용은 예고 없이 변경될 수 있습니다.
본 가이드라인 개정으로 인해 발생하는 어떠한 손해에 대해서도 당사는 일체의 책임을 지지 않습니다.미리 양해 부탁드립니다.

여기서 약간 걸리는 내용은 "·직접적으로 컨텐츠의 소재(일러스트, 동영상, 음성, 악곡 등)를 카피, 스캔, 샘플링, 트레이스 등으로 사용하는 등 창작성이 없거나 낮은 것" 이 부분이다. 스토리 부분에서 캐릭터 일러스트나 배경, 악곡 등 게임에 등장하는 것을 그대로 가져다 썼기 때문이다. 게임 부분이야 몰라도 스토리 부분은 이 부분에 딱 맞는 설명이다. 물론 스토리 부분이 전부가 아니기는 하지만, 이 부분은 저 내용에 해당되니 가이드라인에 위반되는 것인지 고민이 들었다. 그러면 일러스트나 배경 등을 교체해야 하나? 생각해보면 게임 내 UI도 그대로 가져다썼으니 이 부분도 교체를 해야하나? 하는 생각 등이 들었다.

 

아무튼, 배포에는 상당히 좀 무리가 있어보이는데? 싶은 생각이 좀 들었다. 해봐야 1, 2, 3번 게임을 각각 쪼갠 것 정도가 가능하지 않으련지. 그렇게 생각하니 머리가 좀 아팠다. 스토리가 빠져버리면 맥이 확 없어질 것 같아서이다. 현재까지는 그 부분에 시간을 가장 많이 들이기도 했고 말이다. 사실 꼭 배포를 해야하는 것은 아니지만 열심히 만들었는데 다른 사람들의 반응을 볼 수 없다면 꽤나 마음이 아플 것 같다고도 생각했다. 그냥 접고 2번째 목표인 상용화용 게임을 만드는 것이 맞는가에 대한 생각이 들었다. 머리가 많이 아팠다. 원래는 오후에 나갔다 올 생각이었지만 그냥 한숨 잤다. 자고 일어나니 생각이 좀 차분해졌다. 우선 만드는 것 자체는 문제가 없을 것이다. 그리고 남이 만들어놓은 것을 거의 따라 만드는 것조차 제대로 못하면 어떻게 내가 새로운 것을 만들어내겠는가? 라는 생각이 들었다. 반드시 마무리를 하겠다고 목표도 잡아놨기 때문에 어떻게든 마무리를 짓는 것이 맞다고 생각했다. 배포야 뭐... 안하면 그만이다. 만드는 것 자체만으로 배우는 것이 많을 것이다. 지금도 꽤 이것저것 느낀 점들이 많다. 정 아쉬우면 UI를 전반적으로 뜯어고치면 될 것이다. 기능은 구현해놨으니, UI 이미지를 찾고 구성만 조금 변경하면 될 것이다. 여차하면 여기서 만든 게임의 기능을 다른 게임을 제작할 때 활용할 수도 있을 것이다.

 

그렇게 생각하고나니 지금 할 일은 1번 게임의 기획을 명확하게 하는 것이었다. 아... 적들을 어떻게 등장시키지? 어떻게 배치하지? 몇 명을 등장시키지? 스테이지 구성은? 느낌적인 느낌이 아니라 정확하고 자세하게 생각해야겠다. 어쩌면 시간이 좀 걸릴지도 모르겠다. 하나도 제대로 하기 힘든데 3개의 게임을 넣는다는 생각은 어리석었을지도 모르겠다. 아무튼, 음... 계획했던대로 개발은 작은 부분 정도만이라도 고쳐나가고, 당분간은 명확한 기획에 힘을 쏟아야겠다.

 

내일의 목표는 이렇다.

 

1. 아리스 헤일로 도트 이미지 추가

2. 1번 게임(슈팅 게임)에 대한 기획을 최대한 명확하게 세우기

3. 진행 관련해서 사소한 부분 하나라도 수정하기

+ Recent posts