Unity 디펜스 게임 - UI, 팀 협업 #008

주환서·2026년 4월 5일
post-thumbnail

1. 개요

  • 2D 디펜스 게임 제작
  • UI 배워보기
  • Unity Version Control을 이용하여 팀 협업

2. Unity Version Control

2.1. 역할 분리

  • 유니티 프로젝트에 팀원들 초대
  • 각각 어떤 기능을 구현 할건지 역할 분담

2.2. Check In

  • 기능 구현을 하면서 진행 상황을 계속 업데이트 하고 체크인

2.3. 브랜치

  • 작업 할 메인 브랜치 -> 자식으로 각자 브랜치를 생성해서 맡은 역할 구현
  • 회의 후에 메인 브렌치 혹은 임시 메인 브랜치에 병합
  • 에러 해결 후 반복

2.4. 병합

  • 각자 맡은 기능들을 구현 후 병합 -> 서로의 스크립트에 충돌 or 오브젝트 충돌 등등 해결
  • 병합 후에 에러 확인 -> 있다면 에러 확인 후 검토 및 수정

3. 구현 내용

3.1. 역할 분담

1일차

나 : GridMap 생성 시에 Enemy의 경로 생성 및 설정 구현
팀원1 : Enemy의 생성 및 삭제 로직과 생성된 경로를 받아 Enemy의 이동 구현
팀원2 : BossEnemy의 특별행동인 nomalEnemy를 잡아 던지는 로직 구현
팀원3 : Enemy와 Nexus의 에셋 찾기와 Enemy의 모션과 에니메이터 구현

2일차

  1. 머신건 - 나
  2. 미사일 - 팀원1
  3. 레이저 - 팀원2
  4. 타겟팅 - 팀원1
  5. 미사일, 이펙트 에셋 찾기 - 팀원3

3일차

  1. 2일차 타겟팅 작업 마무리 + 관리 기능 설계 - 팀원1
  2. Hp Bar - 팀원3
  3. 업그레이드, 인포 - 나
  4. 판매 - 팀원2

3.2. 기능 구현

1일차

  • 기본 시스템 구성

제공된 프로젝트를 기반으로 같은 프로젝트 내에서 작업합니다.

  • 경로 설정

적이 생성되면 Spawn 위치에서 Nexus 위치까지 이동해야 합니다.
타워와 몬스터는 서로 공격하지 않아도 됩니다.
Spawn 위치와 Nexus 위치를 설정하면 자동으로 이동 경로가 생성됩니다.

  • 적 생성 및 경로 이동 구현

적이 생성된 후, 설정된 경로를 따라 이동해야 합니다.
이동 시 자연스러운 애니메이션이 포함되어야 합니다.
적 몬스터는 현재 타일에서 자연스럽게 다음 타일로 이동하는 형식으로 구현되어야 합니다. (순간이동 X)

  • 보스 몬스터 구현

보스 몬스터는 생성 후 걸어다닙니다.
이동 중 일반 몬스터를 잡아서 앞으로 던지는 기능이 있어야 합니다. (애니메이션 포함)
이로 인해 일반 몬스터는 경로를 단축하여 더 빠르게 이동할 수 있습니다.

  • 적 생성 규칙

적은 1~2초 간격으로 한 마리씩 생성됩니다.

2일차

  • 공격 전략 패턴 구현 (Strategy Pattern)

    공격 방식별로 클래스를 분리하여 구현

공격 타입별 정리

  • 머신건 (Machine Gun)
    • 타입: 근거리
    • 특징: 단일 대상 공격
    • 구현: 이펙트 기반 처리 (Effect)
  • 미사일 (Missile)
    • 타입: 원거리
    • 특징: 광역 공격 (AoE)
    • 구현: 투사체 방식 (Projectile)
  • 레이저 (Laser)
    • 타입: 중/장거리
    • 특징: 관통 공격
    • 구현: LineRenderer 사용

적 타겟팅 전략 패턴 구현

“누굴 공격할지”를 결정하는 로직 분리

  • 타겟 선정 전략 설계
  • 기획 → 설계 → 구현까지 진행

3일차

UI를 통해 몬스터의 체력을 시각적으로 표시한다.

  • 체력 바(HP Bar) 등을 활용하여 플레이어가 직관적으로 상태를 파악할 수 있도록 구현한다.

타워(또는 유닛) 관리 기능을 구현한다.

  • 업그레이드 기능
  • 판매 기능
  • 상세 정보 확인 기능
  • 단, 판매 시 업그레이드가 적용된 경우, 누적된 업그레이드 비용을 포함하여 판매 금액을 계산한다.

4. 진행 과정

1일차

// 1. 경로 설정

using System.Collections.Generic;
using UnityEngine;

namespace SimpleTD.GameLogic
{
    public class PathGenerator : MonoBehaviour
    {
        [Header("몬스터 y축 높이(기본 설정은 타워 0.14f와 동일)")]
        [SerializeField] private float _yOffset = 0.14f; // 타워 높이, 몬스터 높이

        [Header("생성된 경로 데이터")]
        [SerializeField] private List<Vector3> _calculatedPathList = new List<Vector3>();


        public static PathGenerator Instance { get; private set; } // 싱글톤

        private void Awake()
        {
            if (Instance != null && Instance != this)
            {
                Destroy(gameObject);
                return;
            }
            Instance = this;
        }

        private void Start()
        {
            GenerateClockwisePath();
        }

        /// 10x7 그리드 외곽을 시계방향으로 도는 경로를 생성하고 리스트에 캐싱
        private void GenerateClockwisePath()
        {
            _calculatedPathList.Clear();

            // 코너 지점 설정 (좌측 하단 0,0 출발 -> 시계방향 -> 1,0 넥서스 도착)
            List<Vector2Int> corners = new List<Vector2Int>()
            {
                new Vector2Int(0, 0), // 시작점 (스폰)
                new Vector2Int(0, 6), // 위쪽으로 직진
                new Vector2Int(9, 6), // 우측으로 직진
                new Vector2Int(9, 0), // 아래쪽으로 직진
                new Vector2Int(1, 0)  // 좌측으로 직진 넥서스(1,0) 도착
            };

            // 코너 사이의 모든 타일 좌표를 계산하여 리스트에 추가
            for (int i = 0; i < corners.Count - 1; i++)
            {
                AddPathBetweenCorners(corners[i], corners[i + 1], i == 0);
            }

            Debug.Log("[PathGenerator] 시계방향 경로 생성 완료!");
        }

        /// 두 코너 사이의 직선 경로를 계산하여 월드 좌표로 변환 후 저장
        private void AddPathBetweenCorners(Vector2Int startCorner, Vector2Int endCorner, bool isFirstCorner)
        {
            int directionX = endCorner.x.CompareTo(startCorner.x);
            int directionZ = endCorner.y.CompareTo(startCorner.y);

            Vector2Int currentGridPosition = startCorner;

            if (isFirstCorner)
            {
                _calculatedPathList.Add(GridGenerator.Instance.GetWorldPosition(currentGridPosition.x, currentGridPosition.y, _yOffset));
            }

            // 목적지 코너에 도달할 때까지 한 칸씩 이동하며 좌표 저장
            while (currentGridPosition != endCorner)
            {
                currentGridPosition.x += directionX;
                currentGridPosition.y += directionZ;
                _calculatedPathList.Add(GridGenerator.Instance.GetWorldPosition(currentGridPosition.x, currentGridPosition.y, _yOffset));
            }
        }

        // 타워 건설 위치가 경로에 포함되는지 검사하는 함수
        public bool IsPathNode(Vector3 nodePosition)
        {
            foreach (Vector3 pathPos in _calculatedPathList)
            {
                // 경로 데이터는 Y축이 0.14f, 일반 Node 타일이랑 X와 Z축 좌표만 비교
                Vector2 pathXZ = new Vector2(pathPos.x, pathPos.z);
                Vector2 nodeXZ = new Vector2(nodePosition.x, nodePosition.z);

                // 두 좌표 간의 거리가 매우 가깝다면 같은 타일로 판정
                if (Vector2.Distance(pathXZ, nodeXZ) < 0.1f)
                {
                    return true; // 몬스터의 경로라 리턴(건설 불가)
                }
            }
            return false; // 경로 아님 (건설 가능)
        }

        // 2번, 3번 경로 데이터 원할 때 호출 전용 함수
        public List<Vector3> GetPathList()
        {
            return _calculatedPathList;
        }
    }
}
// 2. 적 생성 및 경로 이동
// 적 정보

namespace SimpleTD.GameLogic.Enemy
{
    public enum EnemyType
    {
        Normal,
        Boss
    }
}

-------------------------------------------------------------------------------

using SimpleTD.GameLogic.Tower;
using UnityEngine;

namespace SimpleTD.GameLogic.Enemy
{
    [CreateAssetMenu(fileName = "EnemyData", menuName = "Scriptable Objects/EnemyData")]
    public class EnemyData : ScriptableObject
    {
        [Header("기본 정보")]
        public EnemyType _type;
        public string _name;
        public float _moveSpeed;

        [Header("프리팹")]
        public GameObject _prefab;

        [Header("UI 정보")]
        public Sprite _icon;
    }
}
-------------------------------------------------------------------------------

// 2. 적 생성 및 경로 이동
// 적 정보

namespace SimpleTD.GameLogic.Enemy
{
    public enum EnemyType
    {
        Normal,
        Boss
    }
}

--------------------------

using SimpleTD.GameLogic.Tower;
using UnityEngine;

namespace SimpleTD.GameLogic.Enemy
{
    [CreateAssetMenu(fileName = "EnemyData", menuName = "Scriptable Objects/EnemyData")]
    public class EnemyData : ScriptableObject
    {
        [Header("기본 정보")]
        public EnemyType _type;
        public string _name;
        public float _moveSpeed;

        [Header("프리팹")]
        public GameObject _prefab;

        [Header("UI 정보")]
        public Sprite _icon;
    }
}

-------------------------------------------------------------------------------

// 2. 적 생성 및 경로 이동
// 적 스폰 및 이동

using SimpleTD.GameLogic;
using SimpleTD.GameLogic.Enemy;
using System.Collections;
using System.Collections.Generic;
using UnityEngine;

public class EnemySpawner : MonoBehaviour
{
    [Header("데이터")]
    [SerializeField] private EnemyData _normalData;
    [SerializeField] private EnemyData _bossData;

    [Header("스폰 설정")]
    [SerializeField] private int _spawnCount = 10;
    [SerializeField] private float _spawnInterval = 1f;

    [Header("패스 생성기")]
    [SerializeField] private PathGenerator _generator;

    private List<Vector3> _path;

    private void Start()
    {
        PathInit();
    }

    // 경로 저장 스폰할때 enemy한테 넘겨주기 위해서 ( 경로 생성시 호출 )
    public void PathInit()
    {
        List<Vector3> path = _generator.GetPathList();

        if (path == null || path.Count < 2)
        {
            Debug.LogWarning("경로가 부적합 합니다.");
            return;
        }

        _path = path;
    }

    // 웨이브 시작시 호출
    public void StartSWave()
    {
        if (Validate() == false)
            return;

        StartCoroutine(SpawnDelayRoutine());
    }

    // 딜레이를 두고 스폰
    IEnumerator SpawnDelayRoutine()
    {
        for (int i = 0; i < _spawnCount; i++)
        {
            // 가장 처음에는 보스를 소환
            if (i == 0)
            {
                SpawnEnemy(_bossData);
            }
            else
            {
                SpawnEnemy(_normalData);
            }

            yield return new WaitForSeconds(_spawnInterval);
        }
    }

    // data에 맞게 적 소환 후 경로 설정 및 기본값 설정
    private void SpawnEnemy(EnemyData data)
    {
        GameObject enemy = Instantiate(data._prefab, _path[0], Quaternion.identity);

        EnemyMovement movement = enemy.GetComponent<EnemyMovement>();
        movement.Initialize(_path, data);
    }

    // 예외처리
    private bool Validate()
    {
        if (_normalData == null || _bossData == null)
        {
            Debug.LogWarning("[EnemySpawner] EnemyData가 없습니다.");
            return false;
        }

        if (_normalData._prefab == null || _bossData._prefab == null)
        {
            Debug.LogWarning("[EnemySpawner] Enemy 프리팹이 없습니다.");
            return false;
        }

        return true;
    }

}

-------------------------------------

using SimpleTD.Core;
using System.Collections.Generic;
using UnityEngine;

namespace SimpleTD.GameLogic.Enemy
{
    public class EnemyMovement : MonoBehaviour
    {
        private List<Vector3> _path;
        private int _currentIndex;
        private float _moveSpeed;
        private float _rotateSpeed;
        private bool _isInitialized = false;

        //수정 사항 1번묶음
        private bool _isMovementLocked;

        private EnemyData _data;
        public EnemyType Type => _data._type;

        //수정 사항 1번묶음 끝
        public List<Vector3> Path => _path;
        public int CurrentIndex => _currentIndex;

        public void Initialize(List<Vector3> path, EnemyData data) 
        {
            _path = path;
            _data = data; // EnemyData를 받아서 초기화 수정사항 2번
            _moveSpeed = data._moveSpeed;
            _rotateSpeed = 10f;
            transform.position = _path[0];
            _currentIndex = 1;
            _isInitialized = true; 
        }

        private void Update()
        {
            if (_isInitialized == false) return;
            MoveOnPath();
        }

        private void MoveOnPath()
        {
            Vector3 targetPos = _path[_currentIndex];
            transform.position = Vector3.MoveTowards(transform.position, targetPos, _moveSpeed * Time.deltaTime);
            // 방향 잡기
            Vector3 direction = targetPos - transform.position;
            if (direction.sqrMagnitude > 0.0001f) 
            { 
                Quaternion targetRotation = Quaternion.LookRotation(direction);
                transform.rotation = Quaternion.Slerp( transform.rotation, targetRotation, _rotateSpeed * Time.deltaTime );
            } 
            
            // 거리 계산해서 다음 위치로 목표 바꾸거나 골인 판정
            if (Vector3.Distance(transform.position, targetPos) < 0.05f) 
            {
                _currentIndex++;
                if (_currentIndex >= _path.Count)
                {
                    Goal(); 
                } 
            }
        }

        private void Goal() {
            {
                Destroy(gameObject);

                //목숨을 깎는다던지 필요한 로직
            }
        }

        // 수정사항 3 이동 잠금
        public void SetMovementLock(bool isLocked)
        {
            _isMovementLocked = isLocked;
        }

        public void SetCurrentIndex(int index)
        {
            if (_path == null || _path.Count == 0)
                return;

            _currentIndex = index;
        }
    }
}
// 3. 보스몹 구현

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

namespace SimpleTD.GameLogic.Enemy
{
    [RequireComponent(typeof(EnemyMovement))]
    public class BossThrower : MonoBehaviour
    {
        [Header("탐색")]
        [SerializeField] private float _grabDistance = 3f;
        [SerializeField] private float _grabInterval = 5f;

        [Header("잡기")]
        [SerializeField] private Transform _grabPoint;
        [SerializeField] private float _pullDuration = 0.3f;
        [SerializeField] private float _holdDuration = 0.4f;

        [Header("던지기 연출")]
        [SerializeField] private int _throwTileOffset = 2;
        [SerializeField] private float _throwDuration = 0.35f;
        [SerializeField] private float _throwHeight = 1.0f;

        [SerializeField] private LayerMask _enemyLayer;

        private EnemyMovement _movement;
        private float _timer;


        private void Awake()
        {
            _movement = GetComponent<EnemyMovement>();
        }

        private void Update()
        {
            _timer += Time.deltaTime;

            if (_timer >= _grabInterval)
            {
                _timer = 0f;
                TryGrab();
            }
        }

        private void TryGrab()
        {
            EnemyMovement target = FindNearestNormalEnemy();
            if (target == null)
                return;

            StartCoroutine(GrabAndThrowRoutine(target));
        }

        private EnemyMovement FindNearestNormalEnemy()
        {
            Collider[] hits = Physics.OverlapSphere(transform.position, _grabDistance, _enemyLayer);

            EnemyMovement nearest = null;
            float nearestSqrDist = _grabDistance * _grabDistance;

            foreach (Collider hit in hits)
            {
                EnemyMovement enemy = hit.GetComponent<EnemyMovement>();
                if (enemy == null)
                    continue;

                if (enemy == _movement)
                    continue;

                if (enemy.Type != EnemyType.Normal)
                    continue;

                float sqrDist = (enemy.transform.position - transform.position).sqrMagnitude;

                if (sqrDist < nearestSqrDist)
                {
                    nearestSqrDist = sqrDist;
                    nearest = enemy;
                }
            }

            Debug.LogWarning("FindNearestNormalEnemy");
            return nearest;
        }

        private IEnumerator GrabAndThrowRoutine(EnemyMovement target)
        {
            target.SetMovementLock(true);

            Vector3 start = target.transform.position;
            float t = 0f;

            while (t < _pullDuration)
            {
                t += Time.deltaTime;
                float lerp = t / _pullDuration;

                target.transform.position = Vector3.Lerp(start, _grabPoint.position, lerp);
                yield return null;
            }

            target.transform.position = _grabPoint.position;

            yield return new WaitForSeconds(_holdDuration);

            yield return StartCoroutine(ThrowRoutine(target));
        }

        private IEnumerator ThrowRoutine(EnemyMovement target)
        {
            Debug.LogWarning("ThrowRoutine");

            int startIndex = target.CurrentIndex;
            int endIndex = Mathf.Min(startIndex + _throwTileOffset, target.Path.Count - 1);

            Vector3 startPos = target.transform.position;
            Vector3 endPos = target.Path[endIndex];

            float elapsed = 0f;

            while (elapsed < _throwDuration)
            {
                elapsed += Time.deltaTime;
                float t = Mathf.Clamp01(elapsed / _throwDuration);

                Vector3 pos = Vector3.Lerp(startPos, endPos, t);
                pos.y += Mathf.Sin(t * Mathf.PI) * _throwHeight;

                target.transform.position = pos;
                yield return null;
            }

            target.transform.position = endPos;

            target.SetCurrentIndex(endIndex);
            target.SetMovementLock(false);
        }
    }
}

2일차

using SimpleTD.GameLogic.Enemy;
using System;

namespace SimpleTD.GameLogic.Tower
{
    public interface IAttackStrategy
    {
        void Attack(EnemyBase target, TowerBase tower);
    }
}
// 머신건
using SimpleTD.GameLogic.Enemy;
using UnityEngine;

namespace SimpleTD.GameLogic.Tower
{
    public class MachineGunAttackStrategy : IAttackStrategy
    {
        public void Attack(EnemyBase target, TowerBase tower)
        {
            // 일단 단일 타겟 확보

            // 사거리 내에 적 없으면 공격 취소
            if (target == null) return;

            // 회전
            Vector3 direction = target.transform.position - tower.transform.position;
            direction.y = 0f;

            if (direction.sqrMagnitude > 0.001f)
            {
                //Debug.Log("돌리기");
                tower.transform.rotation = Quaternion.LookRotation(direction);
            }

            // 단일 대상 공격 (일단 투사체 없이 바로 적용)
            target.OnDamage(tower.Damage);

            // 이펙트
            PlayEffect(tower);
        }


        // 이펙트
        private void PlayEffect(TowerBase tower)
        {
            ParticleSystem muzzleFlash = tower.GetComponentInChildren<ParticleSystem>(true);
            if (muzzleFlash != null)
            {
                muzzleFlash.gameObject.SetActive(true);

                muzzleFlash.Stop();
                muzzleFlash.Play();
            }
        }
    }
}
// 미사일

using SimpleTD.GameLogic.Enemy;
using UnityEngine;

namespace SimpleTD.GameLogic.Tower
{
    public class MissileAttackStrategy : IAttackStrategy
    {
        public void Attack(EnemyBase target, TowerBase tower)
        {
            if (target == null || tower == null || tower.Data == null)
                return;
            Vector3 direction = target.transform.position - tower.transform.position;
            direction.y = 0f;

            if (direction.sqrMagnitude > 0.001f)
            {
                //Debug.Log("돌리기");
                tower.transform.rotation = Quaternion.LookRotation(direction);
            }

            // 미사일 발사체 Instantiate
            MissileAttackData missileData = tower.Data._attackData as MissileAttackData;
            if (missileData == null || missileData._missilePrefab == null)
            {
                Debug.Log("Missile Data or Prefab이 없음");
                return;
            }

            Vector3 MissilePos = tower.transform.position + new Vector3(0f, 0.1f, 0.2f);
            GameObject missileobj = Object.Instantiate(missileData._missilePrefab, MissilePos, Quaternion.identity);

            MissileController controller = missileobj.GetComponent<MissileController>();
            if (controller == null)
            {
                Debug.LogWarning("Missile Prefab에 MissileController가 없음");
                return;
            }

            controller.Initialize(
                target,
                tower.Damage,
                missileData._splashRange,
                missileData._arcHeight,
                missileData._travelTime,
                missileData._explodeEffectPrefab
            );

            // 발사 이펙트, 사운드
        }
    }
}
// 레이저

using SimpleTD.GameLogic.Enemy;
using System.Collections;
using UnityEngine;

namespace SimpleTD.GameLogic.Tower
{
    public class LaserAttackStrategy : IAttackStrategy
    {
        private Coroutine _laserEffect;

        public void Attack(EnemyBase target, TowerBase tower)
        {
            if (target == null) return;

            // 타겟 잡힐때 회전방향 초기화
            Vector3 rotateDirection = target.transform.position - tower.transform.position;
            rotateDirection.y = 0f;

            tower.transform.rotation = Quaternion.LookRotation(rotateDirection);

            // 지금 코루틴 실행되고 있으면 중지 시키기
            if (_laserEffect != null)
            {
                tower.StopCoroutine(_laserEffect);
            }

            _laserEffect = tower.StartCoroutine(LaserTraker(target, tower));
        }

        // 라인렌더러 시작 방향 잡기위해 FirePoint 찾기
        private Transform FindFirePoint(Transform root)
        {
            foreach (Transform child in root.GetComponentsInChildren<Transform>(true))
            {
                if (child.name == "FirePoint")
                {
                    return child;
                }
            }

            return null;
        }

        // 타겟 따라가면서 레이저 발사하는 코루틴
        IEnumerator LaserTraker(EnemyBase target, TowerBase tower)
        {
            // 라인 렌더러 잡고 켜주기
            LineRenderer lineRenderer = tower.GetComponent<LineRenderer>();
            if (lineRenderer == null)
                yield break;

            // 어느정도 시간동안 진행할지
            float duration = 3f;
            float elapsed = 0f;

            // 데미지를 초당으로 나눔
            float dps = tower.Damage / duration;
            float totalDamage = 0f;

            // 라인 렌더러 시작지점 잡기
            Transform firePoint = FindFirePoint(tower.transform);

            lineRenderer.enabled = true;
            lineRenderer.positionCount = 2;

            // 회전하면서 레이저 쏘기
            while (elapsed < duration)
            {
                if (target == null)
                    break;

                Vector3 laserStartPos = firePoint != null? firePoint.position : tower.transform.position;
                Vector3 targetPos = target.transform.position + Vector3.up * 0.5f;

                // 레이저 회전과 몸체 회전 분리
                Vector3 laserDirection = (targetPos - laserStartPos).normalized;
                Vector3 rotateDirection = (targetPos - tower.transform.position).normalized;
                rotateDirection.y = 0f;

                // 타워 회전
                if (rotateDirection.sqrMagnitude > 0.001f)
                {
                    tower.transform.rotation = Quaternion.LookRotation(rotateDirection);
                }

                // 레이저 끝 계산 및 hit 데미지 호출
                Vector3 laserEndPos = laserStartPos + laserDirection * tower.Data._range;

                if (Physics.Raycast(laserStartPos, laserDirection, out RaycastHit hit, tower.Data._range))
                {
                    EnemyBase enemy = hit.collider.GetComponent<EnemyBase>();

                    if (enemy != null && enemy == target)
                    {
                        // 프레임단위로 데미지 주고 총 데미지 계산
                        float damageThisFrame = dps * Time.deltaTime;
                        enemy.OnDamage(damageThisFrame);
                        totalDamage += damageThisFrame;

                        laserEndPos = hit.point;
                    }
                }

                // 조금더 중심을 노리도록 offset값
                float offset = 0.2f;

                // 라인 렌더러 시작과 끝 지점 설정
                lineRenderer.SetPosition(0, laserStartPos);
                lineRenderer.SetPosition(1, laserEndPos + laserDirection * offset);

                elapsed += Time.deltaTime;
                yield return null;
            }

            // 레이저 종료하고 총 준 데미지 출력
            lineRenderer.enabled = false;
            Debug.Log($"총 데미지: {totalDamage}");
        }
    }
}
// 타겟팅

using SimpleTD.GameLogic.Enemy;
using UnityEngine;

namespace SimpleTD.GameLogic.Tower
{
    public class TowerBase : MonoBehaviour
    {
        [SerializeField] EnemyBase _target;
        [SerializeField] AttackStrategyType _type;
        [SerializeField] private TargetingStrategyType _targetingType;
        [SerializeField] private LayerMask _enemyLayer;

        private float _damage;
        private int _cost;
        private int _upgradeLevel;

        private IAttackStrategy _attackStrategy;
        private TargetingStrategy _targetingStrategy;
        private TowerData _data;
        private float _attackCooldown;


        public LayerMask EnemyLayer => _enemyLayer;

        public TargetingStrategyType TargetingType
        {
            get => _targetingType;
            set
            {
                _targetingType = value;
                SetTargetingStrategy(TargetingStrategyFactory.Create(_targetingType));
            }
        }

        public TowerData Data { 
            get => _data;
            set { _data = value;
                InitFromData();
                SetAttackStrategy(StrategyFactory.CreateAttackStrategy(_data._attackStrategy));
            }
        }

        public float Damage => _damage;
        public int Cost => _cost;
        public int UpgradeLevel => _upgradeLevel;

        private void Awake()
        {
            //_targetingType = TargetingStrategyType.NEAREST; 디버깅용 초기값 세팅
            _attackStrategy = StrategyFactory.CreateAttackStrategy(_type);
            _targetingStrategy = TargetingStrategyFactory.Create(_targetingType);
        }

        private void OnValidate()
        {
            if (Application.isPlaying)
            {
                SetTargetingStrategy(TargetingStrategyFactory.Create(_targetingType));
            }
        }

        private void OnEnable()
        {
            _attackCooldown = 0f;
        }

        public void SetAttackStrategy(IAttackStrategy attackStrategy)
        {
            _attackStrategy = attackStrategy;
        }
        
        public void SetTargetingStrategy(TargetingStrategy targetingStrategy)
        {
            if (targetingStrategy == null)
                return;

            _targetingStrategy = targetingStrategy;
        }

        private void Update()
        {
            if (_attackStrategy != null)
            {
                //Debug.Log("공격 전략 존재");
                _attackCooldown += Time.deltaTime;
                
                if (_attackCooldown >= _data._attackInterval)
                {
                    //Debug.Log("타겟 설정");

                    _target = _targetingStrategy.SelectTarget(this);
                    if (_target != null)
                    {
                        //Debug.Log("타워 공격");
                        _attackStrategy.Attack(_target, this);
                        Debug.Log($"타워 타게팅 전략 : {_targetingType}");
                        _attackCooldown = 0f;

                    }

                    else if (_target == null) {
                        //Debug.Log("타겟 없음");
                    }
                }
            }
            else if (_attackStrategy == null)
            {
                //Debug.LogWarning("공격 전략이 설정되지 않았습니다.");
            }
        }

        private void InitFromData()
        {
            if (_data == null) return;

            _damage = _data._damage;
            _cost = _data._cost;
            _upgradeLevel = 0;
        }

        // 타워 데미지, 코스트 반영, 강화 레벨
        public void UpgradeTowerDamage(float addedDamage, int upgradeCost)
        {
            // 강화 레벨 1씩 증가
            _upgradeLevel++;

            // 단일 타워 데미지 상승
            _damage += addedDamage;

            // 누적 코스트 반영 (준혁님 cost 값을 가져가서 계산하도록)
            _cost += upgradeCost;

            Debug.Log($"타워 업그레이드. 데미지: {_damage}, 누적비용: {_cost}");
        }
    }
}
// 타겟팅

using UnityEditor.SearchService;
using UnityEngine;
using SimpleTD.GameLogic.Enemy;

namespace SimpleTD.GameLogic.Tower
{
    public abstract class TargetingStrategy
    {
        public abstract EnemyBase SelectTarget(TowerBase tower);
    }
}

-----------------------------------------------------------------------

using SimpleTD.GameLogic.Tower;

public static class TargetingStrategyFactory
{
    public static TargetingStrategy Create(TargetingStrategyType type)
    {
        return type switch
        {
            TargetingStrategyType.NEAREST => new NearestTargetStrategy(),
            TargetingStrategyType.FAR => new FarTargetStrategy(),
            TargetingStrategyType.BOSS_FIRST => new BossTargetStrategy(),
            TargetingStrategyType.NORMAL_FIRST => new NormalTargetStrategy(),
            _ => null
        };
    }
}
// 타겟팅

using SimpleTD.GameLogic.Enemy;
using UnityEngine;

namespace SimpleTD.GameLogic.Tower
{
    //[CreateAssetMenu(
    //fileName = "Nearest",
    //menuName = "Scriptable Objects/TargetingStrategy/Nearest")
    //]
    public class NearestTargetStrategy : TargetingStrategy
    {
        public override EnemyBase SelectTarget(TowerBase tower)
        {
            Collider[] hits = Physics.OverlapSphere(tower.transform.position, tower.Data._range, tower.EnemyLayer);
            //Debug.Log("SelectTarget 들어옴");
            EnemyBase nearest = null;
            float nearestDistance = tower.Data._range * tower.Data._range;
            foreach (var hit in hits)
            {
                EnemyBase enemy = hit.GetComponent<EnemyBase>();
                if (enemy != null)
                {
                    float distance = (enemy.transform.position - tower.transform.position).sqrMagnitude;
                    if (distance < nearestDistance)
                    {
                        nearest = enemy;
                        nearestDistance = distance;
                    }
                }
            }
            //Debug.Log(nearest != null ? $"타겟 선택: {nearest.name}" : "타겟 없음");
            return nearest;
        }
    }
}
// 타겟팅

using SimpleTD.GameLogic.Enemy;
using UnityEngine;

namespace SimpleTD.GameLogic.Tower
{
    //[CreateAssetMenu(
    //fileName = "Far",
    //menuName = "Scriptable Objects/TargetingStrategy/Far")
    //]
    public class FarTargetStrategy : TargetingStrategy
    {
        public override EnemyBase SelectTarget(TowerBase tower)
        {
            Collider[] hits = Physics.OverlapSphere(tower.transform.position, tower.Data._range, tower.EnemyLayer);

            EnemyBase far = null;
            float farDistance = 0f;

            foreach (var hit in hits)
            {
                EnemyBase enemy = hit.GetComponent<EnemyBase>();
                if (enemy != null)
                {
                    float distance = (enemy.transform.position - tower.transform.position).sqrMagnitude;
                    if (distance > farDistance)
                    {
                        far = enemy;
                        farDistance = distance;
                    }
                }
            }
            return far;
        }
    }
}
// 타겟팅

using SimpleTD.GameLogic.Enemy;
using UnityEngine;

namespace SimpleTD.GameLogic.Tower
{
    //[CreateAssetMenu(
    //fileName = "Boss",
    //menuName = "Scriptable Objects/TargetingStrategy/Boss")
    //]
    public class BossTargetStrategy : TargetingStrategy
    {
        public override EnemyBase SelectTarget(TowerBase tower)
        {
            Collider[] hits = Physics.OverlapSphere(tower.transform.position, tower.Data._range, tower.EnemyLayer);

            EnemyBase best = null;
            float bestDistance = float.MaxValue;

            foreach (var hit in hits)
            {
                EnemyBase enemy = hit.GetComponent<EnemyBase>();
                if (enemy == null)
                    continue;

                if (enemy.Type != EnemyType.Boss)
                    continue;

                float distance = (enemy.transform.position - tower.transform.position).sqrMagnitude;

                if (distance < bestDistance)
                {
                    best = enemy;
                    bestDistance = distance;
                }
            }
            if (best == null)
            {
                bestDistance = float.MaxValue;

                foreach (var hit in hits)
                {
                    EnemyBase enemy = hit.GetComponent<EnemyBase>();
                    if (enemy == null)
                        continue;

                    if (enemy.Type != EnemyType.Normal)
                        continue;

                    float distance = (enemy.transform.position - tower.transform.position).sqrMagnitude;
                    if (distance < bestDistance)
                    {
                        best = enemy;
                        bestDistance = distance;
                    }
                }
            }

            return best;
        }
    }
}

3일차

// Hp Bar

using UnityEngine;

public class Billboard : MonoBehaviour
{
    void LateUpdate()
    {
        transform.LookAt(transform.position + Camera.main.transform.rotation * Vector3.forward,Camera.main.transform.rotation * Vector3.up);
    }
}
-------------------------------------------------------------------------------
// Hp Bar

using UnityEngine;
using UnityEngine.UI;

public class HpBar : MonoBehaviour
{
    public float maxHp = 100f;
    public float currentHp;
    public Image fillImage;

    void Awake()
    {
        currentHp = maxHp;
        UpdateHpBar();
    }

    public void TakeDamage(float damage)
    {
        currentHp -= damage;
        currentHp = Mathf.Clamp(currentHp, 0, maxHp);
        UpdateHpBar();

        if (currentHp <= 0)
        {
            Die();
        }
    }

    void UpdateHpBar()
    {
        if (fillImage != null)
        {
            fillImage.fillAmount = currentHp / maxHp;
        }
    }

    void Die()
    {
        Destroy(gameObject);
    }
}
// 데미지, 코스트 등등 반영

using SimpleTD.GameLogic.Enemy;
using UnityEngine;

namespace SimpleTD.GameLogic.Tower
{
    public class TowerBase : MonoBehaviour
    {
        [SerializeField] EnemyBase _target;
        [SerializeField] AttackStrategyType _type;
        [SerializeField] private TargetingStrategyType _targetingType;
        [SerializeField] private LayerMask _enemyLayer;

        private float _damage;
        private int _cost;
        private int _upgradeLevel;

        private IAttackStrategy _attackStrategy;
        private TargetingStrategy _targetingStrategy;
        private TowerData _data;
        private float _attackCooldown;


        public LayerMask EnemyLayer => _enemyLayer;

        public TargetingStrategyType TargetingType
        {
            get => _targetingType;
            set
            {
                _targetingType = value;
                SetTargetingStrategy(TargetingStrategyFactory.Create(_targetingType));
            }
        }

        public TowerData Data { 
            get => _data;
            set { _data = value;
                InitFromData();
                SetAttackStrategy(StrategyFactory.CreateAttackStrategy(_data._attackStrategy));
            }
        }

        public float Damage => _damage;
        public int Cost => _cost;
        public int UpgradeLevel => _upgradeLevel;

        private void Awake()
        {
            //_targetingType = TargetingStrategyType.NEAREST; 디버깅용 초기값 세팅
            _attackStrategy = StrategyFactory.CreateAttackStrategy(_type);
            _targetingStrategy = TargetingStrategyFactory.Create(_targetingType);
        }

        private void OnValidate()
        {
            if (Application.isPlaying)
            {
                SetTargetingStrategy(TargetingStrategyFactory.Create(_targetingType));
            }
        }

        private void OnEnable()
        {
            _attackCooldown = 0f;
        }

        public void SetAttackStrategy(IAttackStrategy attackStrategy)
        {
            _attackStrategy = attackStrategy;
        }
        
        public void SetTargetingStrategy(TargetingStrategy targetingStrategy)
        {
            if (targetingStrategy == null)
                return;

            _targetingStrategy = targetingStrategy;
        }

        private void Update()
        {
            if (_attackStrategy != null)
            {
                //Debug.Log("공격 전략 존재");
                _attackCooldown += Time.deltaTime;
                
                if (_attackCooldown >= _data._attackInterval)
                {
                    //Debug.Log("타겟 설정");

                    _target = _targetingStrategy.SelectTarget(this);
                    if (_target != null)
                    {
                        //Debug.Log("타워 공격");
                        _attackStrategy.Attack(_target, this);
                        Debug.Log($"타워 타게팅 전략 : {_targetingType}");
                        _attackCooldown = 0f;

                    }

                    else if (_target == null) {
                        //Debug.Log("타겟 없음");
                    }
                }
            }
            else if (_attackStrategy == null)
            {
                //Debug.LogWarning("공격 전략이 설정되지 않았습니다.");
            }
        }

        private void InitFromData()
        {
            if (_data == null) return;

            _damage = _data._damage;
            _cost = _data._cost;
            _upgradeLevel = 0;
        }

        // 타워 데미지, 코스트 반영, 강화 레벨
        public void UpgradeTowerDamage(float addedDamage, int upgradeCost)
        {
            // 강화 레벨 1씩 증가
            _upgradeLevel++;

            // 단일 타워 데미지 상승
            _damage += addedDamage;

            // 누적 코스트 반영 (준혁님 cost 값을 가져가서 계산하도록)
            _cost += upgradeCost;

            Debug.Log($"타워 업그레이드. 데미지: {_damage}, 누적비용: {_cost}");
        }
    }
}
// 실제 타워 기능 함수 수행

using SimpleTD.Core;
using SimpleTD.GameLogic;
using SimpleTD.GameLogic.Tower;
using SimpleTD.UI.TowerInfo;
using System;
using System.Collections.Generic;
using TMPro;
using UnityEngine;

namespace SimpleTD.UI.TowerMenu
{
    public class TowerMenuUI : MonoBehaviour
    {
        [Serializable]
        private class RuntimeButton
        {
            public TowerMenuVisualItem _data;
            public RadialMenuButtonUI _view;
        }

        [SerializeField] private float _radius = 150f;
        [SerializeField] private float _startAngle = 90f;

        [SerializeField] private GameObject _root;
        [SerializeField] private TextMeshProUGUI _titleText;

        [SerializeField] private RectTransform _rootTransform;
        [SerializeField] private Camera _worldCamera;
        [SerializeField] private Canvas _canvas;

        private Node _currentNode;

        [SerializeField] private RadialMenuButtonUI _buttonPrefab;

        [SerializeField] private List<TowerMenuVisualItem> _menuItems = new List<TowerMenuVisualItem>();
        [SerializeField] private List<RuntimeButton> _menuButtons = new List<RuntimeButton>();

        private void Awake()
        {
            if (_root == null)
            {
                _root = gameObject;
            }

            if (_rootTransform == null)
            {
                _rootTransform = _root.GetComponent<RectTransform>();
            }

            if (_canvas == null)
            {
                _canvas = GetComponentInParent<Canvas>();
            }

            if (_worldCamera == null)
            {
                _worldCamera = Camera.main;
            }

            BuildButtons();
            LayoutButtons();
            Hide();

            EventBus.OnNodeSelected += HandleNodeSelected;
        }

        private void OnDestroy()
        {
            EventBus.OnNodeSelected -= HandleNodeSelected;
        }

        private void HandleNodeSelected(Node node)
        {
            if (node == null || node.CurrentTower == null)
            {
                Hide();
                return;
            }

            _currentNode = node;
            Show();
        }

        public void Show()
        {
            _root.SetActive(true);
            FollowTarget();

            if (_titleText != null)
            {
                _titleText.text = "Tower Menu";
            }
        }

        public void Hide()
        {
            _currentNode = null;
            _root.SetActive(false);
        }

        private void FollowTarget()
        {
            if (_currentNode == null || _currentNode.CurrentTower == null)
            {
                return;
            }

            Vector3 worldPos = _currentNode.CurrentTower.transform.position;
            Vector3 screenPos = _worldCamera.WorldToScreenPoint(worldPos);

            RectTransform canvasTransform = _canvas.GetComponent<RectTransform>();
            if (RectTransformUtility.ScreenPointToLocalPointInRectangle(
                canvasTransform,
                screenPos,
                null,
                out Vector2 localPoint))
            {
                _rootTransform.anchoredPosition = localPoint;
            }
        }

        private void BuildButtons()
        {
            for (int i = 0; i < _menuItems.Count; i++)
            {
                TowerMenuVisualItem item = _menuItems[i];

                RadialMenuButtonUI buttonView = Instantiate(_buttonPrefab, _rootTransform);

                RuntimeButton runtimeButton = new RuntimeButton
                {
                    _data = item,
                    _view = buttonView
                };
                _menuButtons.Add(runtimeButton);
                SetupButton(runtimeButton);
            }
        }

        private void SetupButton(RuntimeButton runtimeButton)
        {
            runtimeButton._view.SetLabel(runtimeButton._data._label);
            runtimeButton._view.Button.onClick.RemoveAllListeners();
            runtimeButton._view.Button.onClick.AddListener(
                () => OnMenuButtonClicked(runtimeButton._data._type)
                );
        }

        private void LayoutButtons()
        {
            if (_menuButtons.Count <= 0)
                return;

            float step = 360f / _menuButtons.Count;

            for (int i = 0; i < _menuButtons.Count; i++)
            {
                float angle = _startAngle + step * i;
                // radian 0.0f ~ 3.14f * 2f
                // degree(도) 0 ~ 360
                float rad = Mathf.Deg2Rad * angle;
                float x = _radius * Mathf.Cos(rad);
                float y = _radius * Mathf.Sin(rad);

                _menuButtons[i]._view.RectTransform.anchoredPosition = new Vector2(x, y);
            }
        }

        private void OnMenuButtonClicked(TowerMenuType type)
        {
            if (_currentNode == null || _currentNode.CurrentTower == null) return;

            TowerBase tower = _currentNode.CurrentTower.GetComponent<TowerBase>();
            if (tower == null) return;

            switch (type)
            {
                case TowerMenuType.SELL:
                    SellTower();
                    break;

                case TowerMenuType.UPGRADE:
                    TryUpgradeTower(tower);
                    break;

                case TowerMenuType.INFO:
                    TowerInfoUI.Instance.Show(tower);
                    break;

                case TowerMenuType.CLOSE:
                    Hide();
                    break;
            }
        }

        // 타워 공격력 업그레이드
        private void TryUpgradeTower(TowerBase tower)
        {
            float addedDamage = 5f;
            int upgradeCost = 10;

            if (GameManager.Instance.Gold < upgradeCost)
            {
                Debug.Log($"골드가 부족합니다. (필요: {upgradeCost} / 보유: {GameManager.Instance.Gold})");
                return;
            }

            // 골드 소모 및 타워 업그레이드 실행
            GameManager.Instance.SpendGold(upgradeCost);
            tower.UpgradeTowerDamage(addedDamage, upgradeCost);

            // 업그레이드 후 메뉴 닫기
            Hide();
        }

        private void SellTower()
        {
            if (_currentNode == null || _currentNode.CurrentTower == null)
            {
                return;
            }

            //타워 및 타워베이스 가져오기
            GameObject tower = _currentNode.CurrentTower.gameObject;
            TowerBase towerBase = tower.GetComponent<TowerBase>();

            // 타워베이스에 지정되어있는 Cost의 절반값만 sellGold에 담기
            int sellGold = Mathf.RoundToInt(towerBase.Cost * 0.5f);

            //게임매니저 Gold에 sellGold 더하기
            GameManager.Instance.Gold += sellGold;

            //currentNode의 CurrentTower null값으로 만들기
            _currentNode.CurrentTower = null;

            // tower 지우기
            Destroy(tower);

            // 메뉴창 없애기 (타워 팔았으니까)
            Hide();
        }
    }
}

5. 문제 발생 & 해결 방법

1일차

  1. 문제 : 유니티 버전 컨트롤 브렌치를 나눠서 머지하는 과정이 익숙하지 않은 문제.
    해결 : 기능을 여러번 사용해보면서 조금씩 알아가 보면서 해결했습니다.
  2. 문제 : 보스 몬스터를 일반 몬스터를 찾고 던져야 하는데 대상을 못찾는 문제.
    해결 : 오버랩스피어를 사용 했기 때문에 콜라이더를 일반 몬스터에게 추가해줘서
    해결했습니다.
  3. 문제 : Enemy 움직임 로직과 Boss의 Grab And Throw 하는 로직이 맞지않는 문제.
    해결 : Rigidbody 기반이었던 Throw를 병합 과정에서 Rigidbody를 제거해서
    해결했습니다.
  4. 문제 : 보스 던지기 스크립트에는 Enemy움직임 스크립트의 field중 일부를 get하는
    구조로 짰으나 Enemy움직임 코드에는 getter가 없는 문제.
    해결 : 의사소통을 통해 협업자의 코드에 추가해야할 수정사항을 주석을 통해 명시하고
    병합해서 해결했습니다.

2일차

  1. TargetingStrategy를 Factory Pattern으로 구현하는 과정에서 TargetingStrategy를 SO형태로 구현하려고 했는데 Factory Pattern의 new 과정에서 SO를 new하는 과정이 Unity가 권장하지 않았음.
    ⇒ SO 형태를 개선해 interface가 아닌 abstract 클래스 형태로 구현하였음.
  2. Missile의 발사체 구현 과정에서 기존 Strategy 틀 내에선 MonoBehaviour를 상속받지 않기 때문에 gameObject. 명령어를 사용할 수 없었음.
    ⇒ 강사님께 현재 구조에 대한 질문과 Object.Instantiate 명령어의 사용 방법과 Strategy에 prefab을 들게하는 형식이 아닌, MissileData라는 새로운 형식의 abstract class를 만들어 각 Tower별로 필요한 Data를 구현하고 책임질 Class를 구현하여 TowerData에 추가해줌. 이후 Missile에 Missile Controller를 직접 생성해주었고, Object Instantiate를 MissileData에 있는 prefab을 불러와서 해주고 계산도 프리팹 각자에서 하게 해주는 방식으로 책임을 분리했음.
  3. 레이저 포탑의 데미지가 프레임단위로 들어가는게 아닌 한번에 총합 데미지가 들어가는 문제가 있었음
    ⇒ 데미지를 초당 데미지로 계산한 뒤에 Time.deltaTime을 곱해 프레임당 데미지를 구해OnDamage를 호출할때 값으로 넣어줌

3일차

  1. 문제 : TowerData는 Tower의 초기값을 가지고 있어서 업그레이드시에 Damage나 Cost를 변경을 할 수 없는 문제
    해결 : TowerBase에서 초기값을 저장하고 TowerBase에서 각각 Tower의 변경값을
    저장하고 관리하도록하게 해서 해결

  2. 문제 : 보스의 Hp Bar 위치가 조금 엇나가는 문제
    해결 : 카메라의 위치와 각도를 수정해서 해결

  3. 문제 : 각자 맡은 기능 함수를 실제로 실행을 어디서 시킬지 문제가 생겼음
    해결 : 포탑 기능 메뉴 UI 스크립트에 switch문을 활용해서 각자 만든 기능 함수들을 메뉴에 맞게 넣기만 하면 수행되도록 해결

  4. 문제 : 체력바의 빨간 게이지가 검은 배경 UI에 딱 맞지 않고 붕 뜨거나 둥근 테두리 때문에 빈틈이 생김.
    해결 : 기본 제공되는 UISprite 의 둥근 디자인과 배경의 빈틈이 문제라 패키지 매니저를 통해 2D Sprite 패키지를 설치하여 완전한 직사각형 이미지를 생성한 후 체력바의 빨간 게이지를 가득 채움.

  5. 문제 : 3D 환경에서의 UI 회전 문제
    해결 : UI가 몬스터의 자식 오브젝트로 설정되어 부모의 회전 값을 그대로 받는걸 알고, 스크립트를 작성하여 체력바 캔버스가 몬스터의 회전과 상관없이 항상 메인 카메라를 정면으로 응시하도록 제어.

6. 배운 점

UI 및 디펜스 게임 개발

  • 전체적으로 캔버스와 패널, 버튼과 텍스트들을 이용해서 UI를 구현하는 것을 배웠습니다.
    (피벗과 앵커로 UI의 Transform 설정 / 버튼들을 OnClick() 함수를 연결해서 구현하는 방법 / 오브젝트와 UI를 연동시키는 방법 / 오브젝트 선택 Event를 구독하고 선택 되면 3D 위치를 2D 위치로 변환하여 UI를 띄우는 방법 등등)
  • 중간에 Scriptable 오브젝트를 통해 각종 포탑들과 적들의 정보의 데이터를 저장하고 메모리 최적화와 유지보수의 용이성을 올릴 수 있다는 것을 배웠습니다.
  • 옵저버 패턴을 이용해서 게임에서 중요한 이벤트들을 따로 관리하는 기능을 알게 되었고, 구독 한 함수들은 반드시 구독 해제도 해서 메모리 누수를 막아야 하는 것을 배웠습니다.
  • 포탑을 생성하는 역할은 팩토리 구조를 사용했고 가독성과 유지보수, 객체 지향적 구조, 코드의 확장성이 얼마나 좋은지 체감하고 배웠습니다.
  • 옵저버 패턴, 팩토리 구조를 이용해서 각 스테이지 상태마다 어떤 팝업 UI를 띄울지 설정하고 포탑은 단일 공격인지 다중 공격인지, 가까운 적을 타겟팅 할 것인지 보스만 타겟팅 할 것인지 등등 가독성/유지보수/확장성을 챙기면서 코드 구현 하는 방법을 배웠습니다.

팀 협업 방식

  • 유니티 버전 컨트롤을 이용해서 브랜치를 나누고 작업 완료 -> 체크인 -> 병합하여 팀 작업을 하는 방법에 대해 제대로 알게 되었습니다.
  • 팀 작업을 할 때 업무 분담을 최대한 서로 영향을 주지 않도록 분담하는 것이 효율적이라고 생각이 들었고, 서로 영향을 주는 상황이 생기면 소통이 정말 중요하다는 것을 깨달았습니다.
  • 팀 작업 전 미리 규칙들을 설정하고 시간 별 혹은 일 별로 회의를 잡아야겠다는 생각을 했고 구현 도중에도 중요 사항, 수정 사항을 즉시 팀원들과 공유하고 소통해야 한다는 것을 배웠습니다.

0개의 댓글