나 : GridMap 생성 시에 Enemy의 경로 생성 및 설정 구현
팀원1 : Enemy의 생성 및 삭제 로직과 생성된 경로를 받아 Enemy의 이동 구현
팀원2 : BossEnemy의 특별행동인 nomalEnemy를 잡아 던지는 로직 구현
팀원3 : Enemy와 Nexus의 에셋 찾기와 Enemy의 모션과 에니메이터 구현
제공된 프로젝트를 기반으로 같은 프로젝트 내에서 작업합니다.
적이 생성되면 Spawn 위치에서 Nexus 위치까지 이동해야 합니다.
타워와 몬스터는 서로 공격하지 않아도 됩니다.
Spawn 위치와 Nexus 위치를 설정하면 자동으로 이동 경로가 생성됩니다.
적이 생성된 후, 설정된 경로를 따라 이동해야 합니다.
이동 시 자연스러운 애니메이션이 포함되어야 합니다.
적 몬스터는 현재 타일에서 자연스럽게 다음 타일로 이동하는 형식으로 구현되어야 합니다. (순간이동 X)
보스 몬스터는 생성 후 걸어다닙니다.
이동 중 일반 몬스터를 잡아서 앞으로 던지는 기능이 있어야 합니다. (애니메이션 포함)
이로 인해 일반 몬스터는 경로를 단축하여 더 빠르게 이동할 수 있습니다.
적은 1~2초 간격으로 한 마리씩 생성됩니다.
공격 전략 패턴 구현 (Strategy Pattern)
공격 방식별로 클래스를 분리하여 구현
공격 타입별 정리
적 타겟팅 전략 패턴 구현
“누굴 공격할지”를 결정하는 로직 분리
UI를 통해 몬스터의 체력을 시각적으로 표시한다.
타워(또는 유닛) 관리 기능을 구현한다.
// 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);
}
}
}
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;
}
}
}
// 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();
}
}
}
문제 : TowerData는 Tower의 초기값을 가지고 있어서 업그레이드시에 Damage나 Cost를 변경을 할 수 없는 문제
해결 : TowerBase에서 초기값을 저장하고 TowerBase에서 각각 Tower의 변경값을
저장하고 관리하도록하게 해서 해결
문제 : 보스의 Hp Bar 위치가 조금 엇나가는 문제
해결 : 카메라의 위치와 각도를 수정해서 해결
문제 : 각자 맡은 기능 함수를 실제로 실행을 어디서 시킬지 문제가 생겼음
해결 : 포탑 기능 메뉴 UI 스크립트에 switch문을 활용해서 각자 만든 기능 함수들을 메뉴에 맞게 넣기만 하면 수행되도록 해결
문제 : 체력바의 빨간 게이지가 검은 배경 UI에 딱 맞지 않고 붕 뜨거나 둥근 테두리 때문에 빈틈이 생김.
해결 : 기본 제공되는 UISprite 의 둥근 디자인과 배경의 빈틈이 문제라 패키지 매니저를 통해 2D Sprite 패키지를 설치하여 완전한 직사각형 이미지를 생성한 후 체력바의 빨간 게이지를 가득 채움.
문제 : 3D 환경에서의 UI 회전 문제
해결 : UI가 몬스터의 자식 오브젝트로 설정되어 부모의 회전 값을 그대로 받는걸 알고, 스크립트를 작성하여 체력바 캔버스가 몬스터의 회전과 상관없이 항상 메인 카메라를 정면으로 응시하도록 제어.