오늘 한 일: 1:1 게임인 걸 감안해서 자동 조준/발사를 가볍게 리팩토링했다.
핵심은 타깃 탐색 제거(=TargetRef에 단일 적 캐시), 직선/포물선 두 가지 발사 모드, 리드샷 옵션 제거.
나중에 봐도 바로 이해할 수 있게 수식/의도/셋업 체크리스트까지 정리.
1:1이면 매 프레임 적을 다시 찾을 필요가 없다 → TargetRef에 적 Transform만 들고 쏜다.
발사 방식은 두 가지:
직선: velocity = dir * speed (총알 gravityScale = 0)
포물선: 정점(arcHeight) 을 기준으로 초기속도 v0 = (v0x, v0y) 계산 (총알 gravityScale > 0)
쿨타임/사거리/방향 연출은 AutoAimShooter 안에서 해결.
코드
using UnityEngine;
public class TargetRef : MonoBehaviour
{
[SerializeField] private Transform target;
public Transform Target => target;
public void Bind(Transform t) => target = t;
public void Clear() => target = null;
void Start()
{
if (!target)
{
var go = GameObject.FindWithTag("Enemy");
if (go) target = go.transform; // 1:1이면 이것만으로 충분
}
}
}
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
public class AutoAimShooter : MonoBehaviour
{
[Header("Refs")]
[SerializeField] private TargetRef targetRef; // 1:1 대상 보관소(적 Transform)
[SerializeField] private Transform firePoint; // 총알이 나갈 위치
[Header("Fire")]
[SerializeField] private float fireRate = 3f; // 초당 발사 수
[SerializeField] private float range = 0f; // 0이면 사거리 무제한
[SerializeField] private bool useBallistic = true; // true: 포물선, false: 직선
[Header("Straight")]
[SerializeField] private float straightSpeed = 16f; // 직선 발사 속도
[Header("Ballistic")]
[SerializeField] private float speedHint = 12f; // 비행시간 추정용 속도 힌트
[SerializeField] private float minT = 0.25f, maxT = 0.9f; // 비행시간 최소/최대
[SerializeField] private float arcHeight = 1.5f; // 정점(꼭대기) 높이
float shootTimer;
void Awake()
{
if (!firePoint) firePoint = transform;
if (!targetRef) targetRef = FindObjectOfType<TargetRef>();
}
void Update()
{
// 1) 타깃 없거나 비활성이면 아무것도 안 함
var tgt = targetRef ? targetRef.Target : null;
if (!tgt || !tgt.gameObject.activeInHierarchy) return;
// 2) 사거리 제한(옵션)
if (range > 0f)
{
float sq = ((Vector2)tgt.position - (Vector2)transform.position).sqrMagnitude;
if (sq > range * range) return;
}
// 3) 발사 타이밍
shootTimer -= Time.deltaTime;
if (shootTimer <= 0f)
{
Shoot(tgt);
shootTimer = 1f / fireRate;
}
}
void Shoot(Transform tgt)
{
if (useBallistic) ShootBallistic(tgt);
else ShootStraight(tgt);
}
// ───────────────────────── 직선 발사 ─────────────────────────
void ShootStraight(Transform tgt)
{
Vector2 origin = firePoint.position;
Vector2 dir = ((Vector2)tgt.position - origin).normalized;
FireVelocity(dir * straightSpeed);
}
// ───────────────────────── 포물선 발사 ─────────────────────────
void ShootBallistic(Transform tgt)
{
Vector2 origin = firePoint.position;
Vector2 dest = tgt.position;
var sample = ObjectPoolManager.Instance.Get(PoolKey.PlayerBullet);
var sampleRB = sample.GetComponent<Rigidbody2D>();
float g = Physics2D.gravity.y * (sampleRB ? sampleRB.gravityScale : 1f); // 보통 음수
float gAbs = Mathf.Abs(g);
sample.SetActive(false);
// 1) 목표 정점(꼭대기) 높이
Vector2 dp = dest - origin; // (Δx, Δy)
float yApex = Mathf.Max(origin.y, dest.y) + Mathf.Max(0.01f, arcHeight);
// 2) 올라가는 구간
float hUp = Mathf.Max(0.001f, yApex - origin.y);
float v0y = Mathf.Sqrt(2f * gAbs * hUp); // 위(+y)로 가는 초기 y속도
float tUp = v0y / gAbs; // 올라가는 시간 t = v/g
// 3) 내려가는 구간
float hDown = Mathf.Max(0f, yApex - dest.y);
float tDown = Mathf.Sqrt(2f * hDown / gAbs);
// 4) 총 비행시간 T
float T = Mathf.Clamp(tUp + tDown, minT, maxT);
// 5) 수평 속도 v0x
float v0x = dp.x / Mathf.Max(0.001f, T);
// 6) 초기 속도 벡터 v0
Vector2 v0 = new Vector2(v0x, v0y);
FireVelocity(v0, orient: true);
}
// ───────────────────────── 총알 발사 공통 처리 ─────────────────────────
void FireVelocity(Vector2 v0, bool orient = true)
{
var go = ObjectPoolManager.Instance.Get(PoolKey.PlayerBullet);
go.transform.position = firePoint.position;
if (orient) go.transform.up = v0.normalized; // 스프라이트 '앞'을 진행 방향으로
var b = go.GetComponent<Bullet>();
var rb = go.GetComponent<Rigidbody2D>();
if (b != null)
{
// Bullet이 내부에서 rb.velocity를 세팅하고 활성화까지 처리
b.Initialize(BulletTeam.Player, v0);
}
else if (rb)
{
rb.velocity = v0;
go.SetActive(true);
}
}
}
전제: Bullet.Initialize(BulletTeam, Vector2 velocity)는 내부에서Rigidbody2D.velocity = velocity 및 활성화/수명 리셋을 처리한다.
동작 원리(의도와 이유)
왜 TargetRef?
1:1이면 매 프레임 OverlapCircle로 적 탐색은 낭비.
적 리스폰/교체 시에만 TargetRef.Bind(newEnemyTransform) 한 번 호출하면 끝.
발사 컴포넌트는 항상 같은 타깃 참조를 사용 → 가볍고 예측 가능.
Update의 3단계
타깃 유효성: 적이 비활성/사망이면 조준/발사 중단.
사거리 체크(옵션): range == 0이면 무제한. 그 외에는 제곱거리로 빠르게 검사.
발사 타이밍: shootTimer로 fireRate에 맞춰 일정한 간격으로 Shoot() 호출.
직선 발사
방향만 정규화해서 v = dir * straightSpeed.
직선 탄은 총알 Rigidbody2D.gravityScale = 0이 안정적.
튜닝 포인트
발사 간격: fireRate 증가 → 더 자주 발사
사거리: range = 0 무제한 / 값 주면 제한
포물선 높이: arcHeight ↑ → 탄도가 더 높고 부드러움
비행 시간: minT/maxT로 너무 느린/빠른 탄 방지
직선 속도: straightSpeed로 명중까지 시간 조절