Projext/Assets/02_Scripts/Player/Combat/Attack.cs

573 lines
40 KiB
C#
Raw Normal View History

2026-02-12 15:23:25 +00:00
using UnityEngine; // 유니티 엔진의 기본 기능을 불러올거에요 -> UnityEngine을
using System.Collections; // 코루틴 기능을 사용할거에요 -> System.Collections를
using System.Collections.Generic; // 리스트 기능을 사용할거에요 -> System.Collections.Generic을
2026-01-29 06:58:38 +00:00
2026-02-12 15:23:25 +00:00
public class PlayerAttack : MonoBehaviour // 클래스를 선언할거에요 -> MonoBehaviour를 상속받는 PlayerAttack을
2026-01-29 06:58:38 +00:00
{
2026-02-12 15:23:25 +00:00
[Header("--- 활 설정 ---")] // 인스펙터 창에 제목을 표시할거에요 -> --- 활 설정 --- 을
[SerializeField] private Transform firePoint; // 변수를 선언할거에요 -> 화살 발사 위치인 firePoint를
[SerializeField] private PlayerAnimator pAnim; // 변수를 선언할거에요 -> 플레이어 애니메이터 스크립트인 pAnim을
2026-02-25 10:39:20 +00:00
[SerializeField] private PlayerSoundFX soundFX; // [NEW] 변수를 선언할거에요 -> 사운드 스크립트인 soundFX를
2026-02-06 04:20:12 +00:00
2026-02-12 15:23:25 +00:00
[Header("--- 스탯 참조 ---")] // 인스펙터 창에 제목을 표시할거에요 -> --- 스탯 참조 --- 를
[SerializeField] private Stats playerStats; // 변수를 선언할거에요 -> 플레이어 스탯 스크립트인 playerStats를
2026-02-09 14:49:44 +00:00
2026-02-12 15:23:25 +00:00
[Header("--- 일반 공격 (좌클릭) ---")] // 인스펙터 창에 제목을 표시할거에요 -> --- 일반 공격 (좌클릭) --- 을
[SerializeField] private float normalRange = 15f; // 변수를 선언할거에요 -> 일반 공격 사거리인 normalRange를
[SerializeField] private float normalSpeed = 20f; // 변수를 선언할거에요 -> 일반 화살 속도인 normalSpeed를
[Tooltip("좌클릭 공격 쿨타임 (초)\n" +
"낮을수록 연사가 빨라짐 / 추천: 0.08~0.2\n" +
"[v8: 0.15 → 0.1] 클릭 즉시 연사 체감 극대화")]
[SerializeField] private float attackCooldown = 0.1f; // 변수를 선언할거에요 -> 공격 쿨타임인 attackCooldown을 (v8: 0.15→0.1 단축, 클릭 연타 즉시 반응)
2026-02-06 04:20:12 +00:00
2026-02-12 15:23:25 +00:00
[Header("--- 차징 공격 (우클릭) ---")] // 인스펙터 창에 제목을 표시할거에요 -> --- 차징 공격 (우클릭) --- 을
[SerializeField] private float maxChargeTime = 2.0f; // 변수를 선언할거에요 -> 최대 차징 시간인 maxChargeTime을
2026-02-06 04:20:12 +00:00
2026-02-12 15:23:25 +00:00
[System.Serializable] // 직렬화할거에요 -> 인스펙터에서 수정할 수 있게 구조체를
public struct ChargeStage // 구조체를 정의할거에요 -> 차징 단계를 정의하는 ChargeStage를
2026-02-06 04:20:12 +00:00
{
2026-02-12 15:23:25 +00:00
public float chargeTime; // 변수를 선언할거에요 -> 도달 시간인 chargeTime을
public float damageMult; // 변수를 선언할거에요 -> 데미지 배율인 damageMult를
public float rangeMult; // 변수를 선언할거에요 -> 사거리/속도 배율인 rangeMult를
[Min(1)] public int arrowCount; // 변수를 선언할거에요 -> 발사 화살 수인 arrowCount를 (최소 1)
public float spreadAngle; // 변수를 선언할거에요 -> 부채꼴 총 각도(도)인 spreadAngle을
2026-02-06 04:20:12 +00:00
}
2026-02-12 15:23:25 +00:00
[SerializeField] private List<ChargeStage> chargeStages; // 리스트를 선언할거에요 -> 차징 단계 목록인 chargeStages를
2026-02-02 15:02:12 +00:00
[Header("--- 에임 설정 ---")] // 인스펙터 창에 제목을 표시할거에요 -> --- 에임 설정 --- 을
[Tooltip("마우스 레이캐스트가 바닥을 감지할 레이어\n" +
"⭐ 반드시 바닥/지형 레이어를 설정하세요! (Default, Ground 등)\n" +
"비어있으면 수학 평면 폴백 사용 (정확도 낮음)")]
[SerializeField] private LayerMask groundLayer; // 변수를 선언할거에요 -> 바닥 감지 레이어를 (Physics.Raycast용)
[Tooltip("groundLayer가 비어있을 때 사용하는 폴백 평면 높이")]
[SerializeField] private float aimPlaneHeight = 1f; // 변수를 선언할거에요 -> 폴백 평면 높이를
[Tooltip("에임 미세 보정 각도 (도 단위)\n" +
"화살이 커서보다 오른쪽으로 가면 → 음수(-3 ~ -5)\n" +
"화살이 커서보다 왼쪽으로 가면 → 양수(3 ~ 5)")]
[Range(-15f, 15f)]
[SerializeField] private float aimAngleOffset = 0f; // 변수를 선언할거에요 -> 에임 미세 보정 각도를
2026-02-12 15:23:25 +00:00
[SerializeField] private bool enableAutoAim = true; // 변수를 선언할거에요 -> 자동 조준 사용 여부인 enableAutoAim을
[SerializeField] private float autoAimRange = 15f; // 변수를 선언할거에요 -> 자동 조준 감지 거리인 autoAimRange를
[SerializeField] private float autoAimAngle = 45f; // 변수를 선언할거에요 -> 자동 조준 감지 각도인 autoAimAngle을
[Range(0f, 1f)] // 슬라이더를 만들거에요 -> 0부터 1 사이의 값으로
[SerializeField] private float aimAssistStrength = 0.4f; // 변수를 선언할거에요 -> 보정 강도인 aimAssistStrength를
[SerializeField] private LayerMask enemyLayer; // 변수를 선언할거에요 -> 적 레이어인 enemyLayer를
2026-02-25 10:39:20 +00:00
// onlyMaxCharge 제거 — 차징/일반 공격 모두 동일하게 마우스 방향 + 사거리 내 에임보정 적용
2026-02-09 14:49:44 +00:00
// ============================================
// [NEW] 파티클 기반 화살 시스템
// ============================================
2026-02-12 15:23:25 +00:00
[Header("--- 파티클 화살 시스템 ---")] // 인스펙터 창에 제목을 표시할거에요 -> --- 파티클 화살 시스템 --- 을
[SerializeField] private GameObject defaultProjectilePrefab; // 변수를 선언할거에요 -> 기본 발사체 프리팹인 defaultProjectilePrefab을
2026-02-09 14:49:44 +00:00
2026-02-12 15:23:25 +00:00
private GameObject _currentProjectilePrefab; // 변수를 선언할거에요 -> 현재 사용할 발사체 프리팹인 _currentProjectilePrefab을
private ArrowElementType _currentElementType = ArrowElementType.None; // 변수를 초기화할거에요 -> 현재 화살 속성인 _currentElementType을 없음으로
private float _currentElementDamage = 0f; // 변수를 초기화할거에요 -> 현재 속성 데미지인 _currentElementDamage를 0으로
private float _currentElementDuration = 0f; // 변수를 초기화할거에요 -> 현재 속성 지속 시간인 _currentElementDuration을 0으로
2026-02-06 09:27:08 +00:00
2026-02-12 15:23:25 +00:00
private float _lastAttackTime; // 변수를 선언할거에요 -> 마지막 공격 시간인 _lastAttackTime을
private float _chargeTimer; // 변수를 선언할거에요 -> 차징 진행 시간인 _chargeTimer를
private bool _isCharging = false; // 변수를 초기화할거에요 -> 차징 중 여부인 _isCharging을 거짓으로
private bool _isAttacking = false; // 변수를 초기화할거에요 -> 공격 동작 중 여부인 _isAttacking을 거짓으로
private bool _waitForRelease = false; // 변수를 초기화할거에요 -> 발사 대기 상태 여부인 _waitForRelease를 거짓으로
private bool _normalAttackFired = false; // 변수를 초기화할거에요 -> 일반공격 즉시 발사 완료 플래그 (OnShootArrow 중복 방지)
2026-02-06 05:32:48 +00:00
2026-02-12 15:23:25 +00:00
private float _pendingDamage; // 변수를 선언할거에요 -> 발사될 화살의 데미지인 _pendingDamage를
private float _pendingSpeed; // 변수를 선언할거에요 -> 발사될 화살의 속도인 _pendingSpeed를
private float _pendingRange; // 변수를 선언할거에요 -> 발사될 화살의 사거리인 _pendingRange를
private Vector3 _pendingShootDirection; // 변수를 선언할거에요 -> 발사될 방향인 _pendingShootDirection을
private int _pendingArrowCount = 1; // 변수를 선언할거에요 -> 차징 발사 시 화살 수를
private float _pendingSpreadAngle = 0f; // 변수를 선언할거에요 -> 차징 발사 시 부채꼴 각도를
2026-02-06 09:27:08 +00:00
2026-02-12 15:23:25 +00:00
public bool IsCharging => _isCharging; // 프로퍼티를 선언할거에요 -> 차징 중인지 여부를 반환하는 IsCharging을
public bool IsAttacking { get => _isAttacking; set => _isAttacking = value; } // 프로퍼티를 선언할거에요 -> 공격 중인지 여부를 읽고 쓰는 IsAttacking을
public float ChargeProgress => Mathf.Clamp01(_chargeTimer / maxChargeTime); // 프로퍼티를 선언할거에요 -> 차징 진행률(0~1)을 반환하는 ChargeProgress를
2026-02-06 04:20:12 +00:00
2026-02-09 14:49:44 +00:00
// [NEW] 외부에서 현재 속성 확인용
2026-02-12 15:23:25 +00:00
public ArrowElementType CurrentElement => _currentElementType; // 프로퍼티를 선언할거에요 -> 현재 화살 속성을 반환하는 CurrentElement를
public GameObject CurrentProjectilePrefab => _currentProjectilePrefab; // 프로퍼티를 선언할거에요 -> 현재 발사체 프리팹을 반환하는 CurrentProjectilePrefab을
2026-02-09 14:49:44 +00:00
2026-02-12 15:23:25 +00:00
private void Start() // 함수를 실행할거에요 -> 스크립트 시작 시 Start를
2026-02-06 04:20:12 +00:00
{
2026-02-12 15:23:25 +00:00
if (playerStats == null) playerStats = GetComponentInParent<Stats>(); // 조건이 맞으면 가져올거에요 -> 부모의 Stats 컴포넌트를 playerStats에
2026-02-09 14:49:44 +00:00
// 기본 발사체 프리팹 설정
2026-02-12 15:23:25 +00:00
if (_currentProjectilePrefab == null) // 조건이 맞으면 실행할거에요 -> 현재 설정된 프리팹이 없다면
_currentProjectilePrefab = defaultProjectilePrefab; // 값을 넣을거에요 -> 기본 프리팹을
2026-02-09 14:49:44 +00:00
2026-02-12 15:23:25 +00:00
if (chargeStages == null || chargeStages.Count == 0) // 조건이 맞으면 실행할거에요 -> 차징 단계 리스트가 비어있다면
2026-02-06 04:20:12 +00:00
{
2026-02-12 15:23:25 +00:00
chargeStages = new List<ChargeStage> // 리스트를 생성할거에요 -> 기본 3단계 차징 설정을 담은 리스트를
2026-02-06 04:20:12 +00:00
{
new ChargeStage { chargeTime = 0f, damageMult = 1f, rangeMult = 1f, arrowCount = 1, spreadAngle = 0f }, // 0단계: 즉시 발사, 단발
new ChargeStage { chargeTime = 0.8f, damageMult = 1.25f, rangeMult = 1.1f, arrowCount = 3, spreadAngle = 30f }, // 1단계: 3발, 30° 부채꼴
new ChargeStage { chargeTime = 1.6f, damageMult = 1.5f, rangeMult = 1.2f, arrowCount = 5, spreadAngle = 45f } // 2단계: 5발, 45° 부채꼴
2026-02-06 04:20:12 +00:00
};
}
2026-02-06 09:27:08 +00:00
2026-02-12 15:23:25 +00:00
float calculatedMaxTime = 0f; // 변수를 초기화할거에요 -> 계산된 최대 시간을 0으로
foreach (var stage in chargeStages) // 반복할거에요 -> 모든 차징 단계에 대해
2026-02-06 09:27:08 +00:00
{
2026-02-12 15:23:25 +00:00
if (stage.chargeTime > calculatedMaxTime) calculatedMaxTime = stage.chargeTime; // 조건이 맞으면 갱신할거에요 -> 더 긴 시간이 있다면 그 값으로
2026-02-06 09:27:08 +00:00
}
2026-02-12 15:23:25 +00:00
maxChargeTime = Mathf.Max(calculatedMaxTime, 0.1f); // 값을 설정할거에요 -> 계산된 최대 시간으로 (최소 0.1 보장)
2026-02-06 04:20:12 +00:00
}
2026-01-30 07:45:11 +00:00
2026-02-12 15:23:25 +00:00
private void Update() // 함수를 실행할거에요 -> 매 프레임마다 Update를
2026-01-29 06:58:38 +00:00
{
2026-02-12 15:23:25 +00:00
if (_isCharging) _chargeTimer += Time.deltaTime; // 조건이 맞으면 더할거에요 -> 차징 중이라면 타이머에 시간을
2026-01-29 06:58:38 +00:00
}
2026-02-09 14:49:44 +00:00
// ============================================
// [NEW] 화살 장착 (SwapArrow 대체)
// ============================================
/// <summary>
/// ArrowPickup에서 습득 시 호출합니다.
/// 파티클 프리팹 + 속성 정보를 저장합니다.
/// </summary>
2026-02-12 15:23:25 +00:00
public void SetCurrentArrow(ArrowData data) // 함수를 선언할거에요 -> 새로운 화살 데이터를 적용하는 SetCurrentArrow를
2026-02-09 14:49:44 +00:00
{
2026-02-12 15:23:25 +00:00
if (data.projectilePrefab != null) // 조건이 맞으면 실행할거에요 -> 데이터에 프리팹이 있다면
_currentProjectilePrefab = data.projectilePrefab; // 값을 바꿀거에요 -> 현재 발사체 프리팹을 새 것으로
else // 조건이 틀리면 실행할거에요 -> 데이터에 프리팹이 없다면
_currentProjectilePrefab = defaultProjectilePrefab; // 값을 바꿀거에요 -> 기본 프리팹으로
2026-02-09 14:49:44 +00:00
2026-02-12 15:23:25 +00:00
_currentElementType = data.elementType; // 값을 저장할거에요 -> 화살 속성 타입을
_currentElementDamage = data.elementDamage; // 값을 저장할거에요 -> 속성 데미지를
_currentElementDuration = data.elementDuration; // 값을 저장할거에요 -> 속성 지속 시간을
2026-02-09 14:49:44 +00:00
Debug.Log($"화살 장착: [{data.arrowName}] 속성={data.elementType}, " +
2026-02-12 15:23:25 +00:00
$"속성데미지={data.elementDamage}, 지속시간={data.elementDuration}s"); // 로그를 출력할거에요 -> 장착된 화살 정보를
2026-02-09 14:49:44 +00:00
}
/// <summary>
/// 기본 화살로 초기화
/// </summary>
2026-02-12 15:23:25 +00:00
public void ResetArrow() // 함수를 선언할거에요 -> 화살을 기본 상태로 되돌리는 ResetArrow를
2026-02-09 14:49:44 +00:00
{
2026-02-12 15:23:25 +00:00
_currentProjectilePrefab = defaultProjectilePrefab; // 값을 바꿀거에요 -> 발사체를 기본 프리팹으로
_currentElementType = ArrowElementType.None; // 값을 바꿀거에요 -> 속성을 없음(None)으로
_currentElementDamage = 0f; // 값을 초기화할거에요 -> 속성 데미지를 0으로
_currentElementDuration = 0f; // 값을 초기화할거에요 -> 속성 시간을 0으로
Debug.Log("화살 초기화: 기본 화살"); // 로그를 출력할거에요 -> 초기화 완료 메시지를
2026-02-09 14:49:44 +00:00
}
// ============================================
// 일반 공격 (좌클릭) — 기존 로직 100% 유지
// ============================================
2026-02-12 15:23:25 +00:00
public void PerformNormalAttack() // 함수를 선언할거에요 -> 일반 공격을 수행하는 PerformNormalAttack을
2026-01-29 06:58:38 +00:00
{
// [v8 수정] 코루틴 제거 → 클릭 즉시 FireArrow() 직접 호출 (0프레임 딜레이)
// v7: 클릭 → 코루틴(0.05초 대기) → 발사 (미세 딜레이 존재)
// v8: 클릭 → 같은 프레임에서 바로 발사 (체감 딜레이 제로)
if (Time.time < _lastAttackTime + attackCooldown) return; // 조건이 맞으면 중단할거에요 -> 쿨타임 중이면
2026-01-29 06:58:38 +00:00
2026-02-12 15:23:25 +00:00
_pendingDamage = (playerStats != null) ? playerStats.TotalAttackDamage : 10f; // 값을 가져올거에요 -> 스탯이 있으면 총 공격력을, 없으면 10을
_pendingSpeed = normalSpeed; // 값을 저장할거에요 -> 기본 속도를
_pendingRange = normalRange; // 값을 저장할거에요 -> 기본 사거리를
2026-01-30 07:45:11 +00:00
2026-02-12 15:23:25 +00:00
_lastAttackTime = Time.time; // 값을 갱신할거에요 -> 마지막 공격 시간을 현재로
_normalAttackFired = true; // 설정할거에요 -> 즉시 발사 플래그를 (OnShootArrow 중복 방지)
2026-01-29 06:58:38 +00:00
// ⭐ [v8] PlayFastThrow: 애니를 빠른 속도로 강제 재시작!
// 기존 TriggerThrow는 이전 애니 끝나야 다음이 재생돼서 연사 시 밀림
// PlayFastThrow는 현재 애니를 즉시 끊고 처음부터 빠르게 재생해요
if (pAnim != null) pAnim.PlayFastThrow(); // 실행할거에요 -> 빠른 Throw 애니 강제 재시작을 (연사에도 밀리지 않음)
// ⭐ [v8 핵심] 코루틴 없이 같은 프레임에서 즉시 화살 발사!
// 마우스 방향을 이 순간 바로 계산해서 FireArrow에 넘겨요
FireArrow(GetMouseDirection()); // 실행할거에요 -> 클릭한 바로 이 프레임에서 화살 즉시 발사를
StartCoroutine(AttackRoutine()); // 코루틴을 시작할거에요 -> 공격 상태 관리를 위한 AttackRoutine을 (_isAttacking 플래그 제어 + 애니 속도 복원)
2026-01-30 07:45:11 +00:00
}
2026-02-09 14:49:44 +00:00
// ============================================
// 차징 공격 (우클릭) — 기존 로직 100% 유지
// ============================================
2026-02-12 15:23:25 +00:00
public void StartCharging() // 함수를 선언할거에요 -> 차징을 시작하는 StartCharging을
2026-01-30 07:45:11 +00:00
{
2026-02-12 15:23:25 +00:00
if (_waitForRelease) return; // 조건이 맞으면 중단할거에요 -> 이미 발사 대기 중이라면
_isCharging = true; // 상태를 바꿀거에요 -> 차징 중 상태를 참(true)으로
_chargeTimer = 0f; // 값을 초기화할거에요 -> 차징 타이머를 0으로
if (pAnim != null) pAnim.SetCharging(true); // 실행할거에요 -> 애니메이터에 차징 시작을 알리기를
if (CinemachineShake.Instance != null) CinemachineShake.Instance.SetZoom(true); // 조건이 맞으면 실행할거에요 -> 카메라 줌 효과를 켜기를
2026-02-27 00:44:52 +00:00
// 활 당기는 소리는 애니메이션 이벤트 OnBowDraw에서 재생
2026-01-29 06:58:38 +00:00
}
2026-02-12 15:23:25 +00:00
private void ResetChargingEffects() // 함수를 선언할거에요 -> 차징 효과를 초기화하는 ResetChargingEffects를
2026-01-29 06:58:38 +00:00
{
2026-02-12 15:23:25 +00:00
_isCharging = false; // 상태를 바꿀거에요 -> 차징 상태를 거짓(false)으로
_chargeTimer = 0f; // 값을 초기화할거에요 -> 차징 타이머를
if (pAnim != null) pAnim.SetCharging(false); // 실행할거에요 -> 애니메이터 차징 상태 끄기를
if (CinemachineShake.Instance != null) CinemachineShake.Instance.SetZoom(false); // 조건이 맞으면 실행할거에요 -> 카메라 줌 효과를 끄기를
2026-02-25 10:39:20 +00:00
if (soundFX != null) soundFX.StopChargeLoop(); // [NEW] 실행할거에요 -> 차징 루프 소리 정지를
2026-01-29 06:58:38 +00:00
}
2026-02-27 00:44:52 +00:00
public void PlayBowDrawSound() // 함수를 선언할거에요 -> 활 당기는 소리를 재생하는 PlayBowDrawSound를 (PlayerAnimator에서 호출)
{
if (soundFX != null) soundFX.PlayBowDraw(); // 실행할거에요 -> 활 당기는 소리를
}
2026-02-12 15:23:25 +00:00
public void CancelCharging() // 함수를 선언할거에요 -> 차징을 취소하는 CancelCharging을
2026-02-06 05:32:48 +00:00
{
2026-02-12 15:23:25 +00:00
ResetChargingEffects(); // 함수를 실행할거에요 -> 차징 효과 초기화를
_waitForRelease = false; // 상태를 바꿀거에요 -> 발사 대기 상태를 거짓으로
2026-02-06 05:32:48 +00:00
}
2026-02-12 15:23:25 +00:00
public void ReleaseAttack() // 함수를 선언할거에요 -> 차징된 공격을 발사하는 ReleaseAttack을
2026-01-29 06:58:38 +00:00
{
2026-02-12 15:23:25 +00:00
if (!_isCharging) return; // 조건이 맞으면 중단할거에요 -> 차징 중이 아니라면
2026-01-29 06:58:38 +00:00
2026-02-12 15:23:25 +00:00
ChargeStage currentStage = chargeStages[0]; // 변수를 초기화할거에요 -> 기본 0단계를 시작값으로
foreach (var stage in chargeStages) // 반복할거에요 -> 모든 단계를 돌면서
2026-01-31 13:07:35 +00:00
{
2026-02-12 15:23:25 +00:00
if (_chargeTimer >= stage.chargeTime) currentStage = stage; // 조건이 맞으면 갱신할거에요 -> 타이머가 단계 시간보다 길다면 해당 단계로
2026-01-31 13:07:35 +00:00
}
2026-02-12 15:23:25 +00:00
float baseDmg = (playerStats != null) ? playerStats.TotalAttackDamage : 10f; // 값을 가져올거에요 -> 기본 공격력을
_pendingDamage = baseDmg * currentStage.damageMult; // 값을 계산할거에요 -> 배율을 적용한 최종 데미지를
_pendingSpeed = normalSpeed * currentStage.rangeMult; // 값을 계산할거에요 -> 배율을 적용한 속도를
_pendingRange = normalRange * currentStage.rangeMult; // 값을 계산할거에요 -> 배율을 적용한 사거리를
_pendingArrowCount = Mathf.Max(currentStage.arrowCount, 1); // 저장할거에요 -> 발사 화살 수를 (최소 1발 보장)
_pendingSpreadAngle = currentStage.spreadAngle; // 저장할거에요 -> 부채꼴 각도를
2026-01-31 13:07:35 +00:00
2026-02-12 15:23:25 +00:00
bool isMaxCharge = _chargeTimer >= maxChargeTime * 0.95f; // 조건을 확인할거에요 -> 풀차징(95% 이상)인지 여부를
_pendingShootDirection = GetShootDirection(isMaxCharge); // 값을 계산할거에요 -> 보정이 적용된 발사 방향을
2026-02-06 09:27:08 +00:00
2026-02-12 15:23:25 +00:00
if (pAnim != null) pAnim.TriggerThrow(); // 실행할거에요 -> 던지는 애니메이션을
_waitForRelease = true; // 상태를 바꿀거에요 -> 발사 대기 상태를 참으로
_lastAttackTime = Time.time; // 값을 갱신할거에요 -> 마지막 공격 시간을
StartCoroutine(AttackRoutine()); // 코루틴을 시작할거에요 -> 공격 루틴을
2026-02-06 04:20:12 +00:00
}
2026-02-09 14:49:44 +00:00
// ============================================
// 에임 보정 시스템 — 기존 로직 100% 유지
// ============================================
// ============================================
// 마우스 월드 좌표 & 방향 계산
// ============================================
/// <summary>
/// 마우스 커서가 가리키는 바닥 월드 좌표를 반환해요.
///
/// [방식 1] groundLayer 설정됨 → Physics.Raycast로 실제 바닥 충돌 지점 감지
/// [방식 2] groundLayer 미설정 → 플레이어 발 높이의 수평면(Plane)과 카메라 레이 교차
///
/// ⚠ "모든 콜라이더 대상 Raycast"는 사용하지 않습니다!
/// → 플레이어/적/오브젝트 콜라이더에 먼저 부딪혀 엉뚱한 위치가 나올 수 있기 때문.
/// </summary>
private Vector3 GetMouseWorldPosition() // 함수를 선언할거에요 -> 마우스 월드 좌표를 구하는
2026-02-06 09:27:08 +00:00
{
2026-02-12 15:23:25 +00:00
Ray ray = Camera.main.ScreenPointToRay(Input.mousePosition); // 레이를 쏠거에요 -> 카메라에서 마우스 위치로
2026-02-25 10:39:20 +00:00
// ── 1차: groundLayer가 설정되어 있으면 Physics.Raycast (가장 정확!) ──
if (groundLayer.value != 0) // 조건이 맞으면 실행할거에요 -> 바닥 레이어가 지정되어 있으면
{
if (Physics.Raycast(ray, out RaycastHit hit, 200f, groundLayer)) // 레이캐스트를 쏠거에요 -> 바닥 레이어만
return hit.point; // 반환할거에요 -> 실제 바닥 충돌 지점을
}
// ── 2차: 플레이어 발 높이의 수평면과 카메라 레이 교차 ──
// 플레이어 발(transform.position.y) 높이에 수평면을 만들어서
// 카메라 → 마우스 레이가 이 평면과 교차하는 점 = 마우스가 가리키는 바닥 좌표
Plane groundPlane = new Plane(Vector3.up, transform.position); // 만들거에요 -> 플레이어 발 높이 수평면을
if (groundPlane.Raycast(ray, out float distance)) // 조건이 맞으면 실행할거에요 -> 평면에 닿으면
return ray.GetPoint(distance); // 반환할거에요 -> 교차점 월드 좌표를
return transform.position + transform.forward * 10f; // 반환할거에요 -> 최종 폴백을
}
/// <summary>
/// 카메라 레이 → 바닥 평면 교차 방식으로 마우스 월드 좌표를 구하고,
/// 플레이어에서 그 좌표까지의 수평 방향을 반환해요.
///
/// ⭐ PlayerAim.RotateTowardsMouse()와 100% 동일한 방식!
/// 캐릭터가 바라보는 방향 = 화살이 날아가는 방향 = 항상 일치.
/// </summary>
private Vector3 GetMouseDirection() // 함수를 선언할거에요 -> 마우스 방향을 구하는
{
Vector3 origin = (firePoint != null) ? firePoint.position : transform.position; // 결정할거에요 -> 화살 출발 위치를
2026-02-06 09:27:08 +00:00
// 1. 카메라에서 마우스 위치로 레이 발사
Ray ray = Camera.main.ScreenPointToRay(Input.mousePosition); // 레이를 쏠거에요 -> 카메라에서 마우스 방향으로
// 2. ⭐ firePoint 높이의 수평면과 레이 교차
Plane aimPlane = new Plane(Vector3.up, origin); // 만들거에요 -> firePoint 높이 수평면을
if (aimPlane.Raycast(ray, out float distance)) // 조건이 맞으면 실행할거에요 -> 교차 성공하면
2026-02-06 09:27:08 +00:00
{
// 3. 교차점 = 화살 높이에서 커서와 정확히 겹치는 월드 좌표
Vector3 aimPoint = ray.GetPoint(distance); // 계산할거에요 -> 에임 포인트를
// 4. firePoint → 에임 포인트 방향 (수평만)
Vector3 dir = aimPoint - origin; // 계산할거에요 -> 방향 벡터를
dir.y = 0f; // 무시할거에요 -> 수직 성분을
if (dir.sqrMagnitude > 0.001f) // 방어할거에요 -> 유효한 벡터면
{
dir = dir.normalized; // 정규화할거에요 -> 단위 벡터로
// 5. ⭐ 인스펙터 미세 보정 적용 (카메라/캐릭터 셋업에 따른 미세 오차 보정)
if (aimAngleOffset != 0f) // 조건이 맞으면 실행할거에요 -> 보정값이 있으면
dir = Quaternion.Euler(0f, aimAngleOffset, 0f) * dir; // 회전할거에요 -> 보정 각도만큼
// [디버그] Scene 뷰에서 에임 방향 확인용 (빨강=화살 방향, 초록=커서 위치)
Debug.DrawRay(origin, dir * 15f, Color.red); // 그릴거에요 -> 화살 방향 레이를
Debug.DrawLine(origin, aimPoint, Color.green); // 그릴거에요 -> 커서 위치까지 선을
return dir; // 반환할거에요 -> 보정된 방향을
}
2026-02-06 09:27:08 +00:00
}
return transform.forward; // 반환할거에요 -> 실패 시 현재 정면 방향을
2026-02-06 09:27:08 +00:00
}
2026-02-25 10:39:20 +00:00
// ⭐ GetShootDirection — 차징/일반 공격 모두 동일한 로직
//
// 1. 기본: 마우스 커서 방향으로 발사
// 2. autoAimRange 내에 적이 있고 마우스 방향 autoAimAngle 이내라면 에임보정 적용
// (풀차징 여부 관계없이 항상 동일하게 동작)
private Vector3 GetShootDirection(bool isMaxCharge = false) // 함수를 선언할거에요 -> 최종 발사 방향을 구하는 GetShootDirection을 (isMaxCharge는 더 이상 사용 안 함)
2026-02-06 09:27:08 +00:00
{
2026-02-25 10:39:20 +00:00
Vector3 mouseDir = GetMouseDirection(); // 가져올거에요 -> 현재 마우스 방향을
if (!enableAutoAim) return mouseDir; // 중단할거에요 -> 에임보정 꺼져있으면 마우스 방향 그대로
2026-02-06 09:27:08 +00:00
2026-02-25 10:39:20 +00:00
Transform bestTarget = FindBestTarget(mouseDir); // 찾을거에요 -> 마우스 방향 기준 가장 가까운 적을
if (bestTarget != null) // 조건이 맞으면 실행할거에요 -> 에임보정 대상이 있으면
2026-02-06 09:27:08 +00:00
{
2026-02-25 10:39:20 +00:00
Vector3 targetPos = bestTarget.position + Vector3.up * 1.2f; // 계산할거에요 -> 타겟 중심 위치를 (높이 보정)
Vector3 targetDir = (targetPos - firePoint.position).normalized; // 계산할거에요 -> 발사 위치 → 타겟 방향을
targetDir.y = 0f; // 무시할거에요 -> 수직 성분을
2026-02-06 09:27:08 +00:00
2026-02-12 15:23:25 +00:00
Vector3 assistedDir = Vector3.Lerp(mouseDir, targetDir, aimAssistStrength); // 보간할거에요 -> 마우스 방향과 타겟 방향 사이를 강도만큼
assistedDir.Normalize(); // 정규화할거에요 -> 벡터 길이를 1로
2026-02-25 10:39:20 +00:00
return assistedDir; // 반환할거에요 -> 에임보정된 방향을
2026-02-06 09:27:08 +00:00
}
2026-02-25 10:39:20 +00:00
return mouseDir; // 반환할거에요 -> 적 없으면 마우스 방향 그대로
2026-02-06 09:27:08 +00:00
}
2026-02-12 15:23:25 +00:00
private Transform FindBestTarget(Vector3 mouseDirection) // 함수를 선언할거에요 -> 조준 보정 대상을 찾는 FindBestTarget을
2026-02-06 09:27:08 +00:00
{
2026-02-12 15:23:25 +00:00
Collider[] enemies = Physics.OverlapSphere(transform.position, autoAimRange, enemyLayer); // 배열에 담을거에요 -> 사거리 내의 모든 적을
if (enemies.Length == 0) return null; // 조건이 맞으면 반환할거에요 -> 적이 없으면 null을
2026-02-06 09:27:08 +00:00
2026-02-12 15:23:25 +00:00
Transform bestTarget = null; // 변수를 초기화할거에요 -> 최고 타겟을 비워두고
float bestScore = float.MaxValue; // 변수를 초기화할거에요 -> 점수를 최대값으로
2026-02-06 09:27:08 +00:00
2026-02-12 15:23:25 +00:00
foreach (var enemy in enemies) // 반복할거에요 -> 모든 적에 대해
2026-02-06 09:27:08 +00:00
{
2026-02-12 15:23:25 +00:00
Vector3 dirToEnemy = (enemy.transform.position - transform.position).normalized; // 방향을 구할거에요 -> 적을 향하는 방향을
float angle = Vector3.Angle(mouseDirection, dirToEnemy); // 각도를 잴거에요 -> 마우스 방향과 적 방향 사이를
if (angle > autoAimAngle) continue; // 조건이 맞으면 건너뛸거에요 -> 시야각 밖이라면
2026-02-06 09:27:08 +00:00
2026-02-12 15:23:25 +00:00
float distance = Vector3.Distance(transform.position, enemy.transform.position); // 거리를 잴거에요 -> 적까지의 거리를
float score = angle * 0.5f + distance * 0.5f; // 점수를 매길거에요 -> 각도와 거리를 합산해서 (낮을수록 좋음)
2026-02-06 09:27:08 +00:00
2026-02-12 15:23:25 +00:00
if (score < bestScore) // 조건이 맞으면 갱신할거에요 -> 현재 점수가 최고 점수보다 낮다면
2026-02-06 09:27:08 +00:00
{
2026-02-12 15:23:25 +00:00
bestScore = score; // 값을 저장할거에요 -> 새 점수를
bestTarget = enemy.transform; // 값을 저장할거에요 -> 새 타겟을
2026-02-06 09:27:08 +00:00
}
}
2026-02-12 15:23:25 +00:00
return bestTarget; // 반환할거에요 -> 가장 점수가 좋은 타겟을
2026-02-06 09:27:08 +00:00
}
2026-02-09 14:49:44 +00:00
// ============================================
// [MODIFIED] 화살 생성 — 파티클 프리팹 발사
// ============================================
/// <summary>
/// 애니메이션 이벤트에서 호출됩니다.
/// 파티클 프리팹을 Instantiate하고 PlayerArrow 컴포넌트로 이동/충돌을 처리합니다.
/// </summary>
/// <summary>
/// 애니메이션 이벤트에서 호출됩니다.
/// [v7] 일반 공격은 InstantNormalFire에서 이미 발사했으므로 여기서는 스킵해요.
/// 차징 공격만 이 이벤트에서 발사해요.
/// </summary>
2026-02-12 15:23:25 +00:00
public void OnShootArrow() // 함수를 선언할거에요 -> 애니메이션 이벤트로 호출될 발사 함수 OnShootArrow를
2026-02-06 04:20:12 +00:00
{
// [v7] 일반 공격은 InstantNormalFire()에서 이미 즉시 발사했으므로
// 애니메이션 이벤트에서 또 발사하면 화살이 2발 나가요 → 스킵!
if (_normalAttackFired) // 조건이 맞으면 실행할거에요 -> 일반 공격으로 이미 발사 완료됐으면
{
_normalAttackFired = false; // 초기화할거에요 -> 플래그를 (다음 공격을 위해)
return; // 중단할거에요 -> 중복 발사 방지
}
// ── 차징 공격: 애니메이션 이벤트 타이밍에 발사 ──────────
2026-02-12 15:23:25 +00:00
if (_currentProjectilePrefab == null || firePoint == null) // 조건이 맞으면 중단할거에요 -> 프리팹이나 발사 위치가 없다면
2026-02-06 09:27:08 +00:00
{
Debug.LogWarning("발사체 프리팹 또는 firePoint가 없습니다!"); // 경고 로그를 띄울거에요
2026-02-06 09:27:08 +00:00
return;
}
2026-01-29 06:58:38 +00:00
// 차징 발사: 에임보정 포함된 방향 재계산 → 부채꼴 다발 발사
Vector3 shootDir = GetShootDirection(); // 계산할거에요 -> 에임보정 포함 발사 방향을
FireChargedArrows(shootDir); // 실행할거에요 -> 차징 부채꼴 다발 발사를 (arrowCount/spreadAngle 기반)
_waitForRelease = false; // 초기화할거에요 -> 발사 대기 상태를
}
/// <summary>
/// 화살 생성 공통 메서드.
/// shootDir 방향 그대로 화살을 firePoint 위치에서 생성합니다.
/// 방향 계산은 호출 전에 GetMouseDirection()이 담당합니다.
/// </summary>
private void FireArrow(Vector3 shootDir) // 함수를 선언할거에요 -> 화살을 생성하는 공통 메서드를
{
if (_currentProjectilePrefab == null || firePoint == null) // 조건이 맞으면 중단할거에요 -> 프리팹이나 발사 위치 없으면
{
Debug.LogWarning("[PlayerAttack] 발사체 프리팹 또는 firePoint가 없습니다!"); // 경고를 찍을거에요
return; // 중단할거에요
}
2026-02-25 10:39:20 +00:00
if (soundFX != null) soundFX.PlayBowRelease(); // 실행할거에요 -> 화살 발사 소리를
// ⭐ [100% 정확도] 화살 스폰 위치에서 커서까지 방향을 직접 재계산
// firePoint의 실제 위치에서 카메라 레이와 교차하는 점까지의 방향 = 화면상 100% 커서와 겹침
Ray aimRay = Camera.main.ScreenPointToRay(Input.mousePosition); // 레이를 쏠거에요 -> 카메라에서 마우스로
Plane spawnPlane = new Plane(Vector3.up, firePoint.position); // 만들거에요 -> 스폰 높이 수평면을
if (spawnPlane.Raycast(aimRay, out float aimDist)) // 조건이 맞으면 실행할거에요 -> 교차하면
{
Vector3 exactAimPoint = aimRay.GetPoint(aimDist); // 계산할거에요 -> 정확한 에임 포인트를
Vector3 exactDir = exactAimPoint - firePoint.position; // 계산할거에요 -> 스폰 위치에서 에임까지 벡터를
exactDir.y = 0f; // 무시할거에요 -> 수직 성분을
if (exactDir.sqrMagnitude > 0.001f) // 방어할거에요 -> 유효한 벡터면
shootDir = exactDir.normalized; // 덮어쓸거에요 -> 100% 정확한 방향으로
}
2026-02-25 10:39:20 +00:00
Quaternion rotation = Quaternion.LookRotation(shootDir); // 계산할거에요 -> 발사 방향으로의 회전을
2026-02-12 15:23:25 +00:00
GameObject projectile = Instantiate(_currentProjectilePrefab, firePoint.position, rotation); // 생성할거에요 -> 화살 오브젝트를
2026-02-09 14:49:44 +00:00
2026-02-25 10:39:20 +00:00
PlayerArrow arrowScript = projectile.GetComponent<PlayerArrow>(); // 가져올거에요 -> PlayerArrow 스크립트를
2026-02-12 15:23:25 +00:00
if (arrowScript == null) // 조건이 맞으면 실행할거에요 -> 스크립트가 없다면
2026-01-29 06:58:38 +00:00
{
2026-02-12 15:23:25 +00:00
arrowScript = projectile.AddComponent<PlayerArrow>(); // 추가할거에요 -> PlayerArrow 컴포넌트를 즉석에서
2026-01-29 06:58:38 +00:00
}
2026-02-09 14:49:44 +00:00
2026-02-12 15:23:25 +00:00
arrowScript.Initialize( // 초기화할거에요 -> 화살 스크립트에 모든 정보를 전달해서
_pendingDamage, // 클릭 시점에 확정된 데미지
2026-02-25 10:39:20 +00:00
_pendingSpeed, // 클릭 시점에 확정된 속도
_pendingRange, // 클릭 시점에 확정된 사거리
shootDir, // 발사 방향
_currentElementType, // 화살 속성
_currentElementDamage, // 속성 데미지
_currentElementDuration // 속성 지속 시간
2026-02-09 14:49:44 +00:00
);
}
2026-02-25 10:39:20 +00:00
// ============================================
// 차징 부채꼴 다발 발사
// ============================================
/// <summary>
/// 차징 공격 전용: _pendingArrowCount만큼 부채꼴(spreadAngle)로 화살을 발사해요.
/// ⭐ 발사 순간 firePoint → 마우스 월드 좌표로 중심 방향을 재계산합니다.
/// 1발이면 기존 FireArrow와 동일, 2발 이상이면 중심 방향 기준 좌우 균등 분배.
/// </summary>
private void FireChargedArrows(Vector3 centerDir) // 함수를 선언할거에요 -> 차징 부채꼴 다발 발사를
{
// ⭐ [핵심] 발사 순간에 firePoint → 마우스 월드 좌표 방향을 재계산
// 이전 프레임에 계산된 방향을 쓰면 firePoint 이동으로 오차 발생
// → 지금 이 순간의 firePoint에서 마우스까지 직접 방향을 잡아야 정확함
centerDir = GetMouseDirection(); // 재계산할거에요 -> 발사 순간의 정확한 방향을
int count = _pendingArrowCount; // 가져올거에요 -> 발사할 화살 수를
if (count <= 1 || _pendingSpreadAngle <= 0f) // 조건이 맞으면 실행할거에요 -> 1발이거나 각도 없으면
{
FireArrow(centerDir); // 실행할거에요 -> 단발 발사를 (기존과 동일)
return; // 중단할거에요
}
// 부채꼴 각도를 화살 수에 맞춰 균등 분할
float halfSpread = _pendingSpreadAngle * 0.5f; // 계산할거에요 -> 부채꼴 반각을
float step = _pendingSpreadAngle / (count - 1); // 계산할거에요 -> 화살 간 각도 간격을
for (int i = 0; i < count; i++) // 반복할거에요 -> 화살 수만큼
{
float angle = -halfSpread + step * i; // 계산할거에요 -> 이 화살의 회전 각도를 (중심 기준)
Vector3 dir = Quaternion.AngleAxis(angle, Vector3.up) * centerDir; // 회전할거에요 -> 중심 방향을 Y축 기준으로
FireArrow(dir); // 발사할거에요 -> 오프셋이 적용된 방향으로
}
Debug.Log($"[PlayerAttack] 차징 부채꼴 발사! {count}발, 각도={_pendingSpreadAngle}°"); // 로그를 찍을거에요
2026-01-29 06:58:38 +00:00
}
2026-02-09 14:49:44 +00:00
// ============================================
// 유틸리티 — 기존 로직 100% 유지
// ============================================
2026-02-12 15:23:25 +00:00
public void OnAttackEnd() // 함수를 선언할거에요 -> 공격 애니메이션 종료 시 호출될 OnAttackEnd를
2026-02-06 05:32:48 +00:00
{
2026-02-12 15:23:25 +00:00
_isAttacking = false; // 상태를 바꿀거에요 -> 공격 상태를 거짓으로
ResetChargingEffects(); // 함수를 실행할거에요 -> 차징 효과 초기화를
if (pAnim != null) pAnim.ResetAnimSpeed(); // 복원할거에요 -> 애니메이터 속도를 원본으로 (안전장치)
2026-02-06 05:32:48 +00:00
}
2026-02-12 15:23:25 +00:00
private IEnumerator AttackRoutine() // 코루틴 함수를 선언할거에요 -> 공격 상태 안전장치인 AttackRoutine을
2026-01-29 06:58:38 +00:00
{
2026-02-12 15:23:25 +00:00
_isAttacking = true; // 상태를 바꿀거에요 -> 공격 중 상태를 참으로
yield return new WaitForSeconds(0.12f); // 기다릴거에요 -> 0.12초만큼 (v8: 0.2→0.12 단축, 연사 체감 극대화)
2026-02-12 15:23:25 +00:00
if (_isAttacking) // 조건이 맞으면 실행할거에요 -> 아직도 공격 중 상태라면 (강제 종료)
2026-02-06 05:32:48 +00:00
{
2026-02-12 15:23:25 +00:00
_isAttacking = false; // 상태를 바꿀거에요 -> 공격 상태를 거짓으로
ResetChargingEffects(); // 함수를 실행할거에요 -> 효과 초기화를
2026-02-06 05:32:48 +00:00
}
// ⭐ [v8] 빠른 공격 애니 속도를 원래대로 복원해요
// 이걸 안 하면 이동 애니, 차징 애니 등도 빠르게 재생됨
if (pAnim != null) pAnim.ResetAnimSpeed(); // 복원할거에요 -> 애니메이터 속도를 원본(1.0)으로
2026-01-29 06:58:38 +00:00
}
2026-02-09 14:49:44 +00:00
// [DEPRECATED] 하위 호환성을 위해 남겨둠
2026-02-12 15:23:25 +00:00
[System.Obsolete("SetCurrentArrow(ArrowData)를 사용하세요.")] // 경고를 띄울거에요 -> 이 함수는 더 이상 쓰지 말라고
public void SwapArrow(GameObject newArrow) // 함수를 선언할거에요 -> 구버전 화살 교체 함수 SwapArrow를
2026-02-06 09:27:08 +00:00
{
2026-02-12 15:23:25 +00:00
if (newArrow == null) return; // 조건이 맞으면 중단할거에요 -> 비어있다면
Debug.LogWarning("SwapArrow()는 더 이상 사용되지 않습니다. SetCurrentArrow()를 사용하세요."); // 경고 로그를 출력할거에요 -> 새 함수 사용 권장을
2026-02-06 09:27:08 +00:00
}
2026-02-12 15:23:25 +00:00
public void StartWeaponCollision() { } // 함수를 비워둘거에요 -> 근접 무기 충돌 시작(활에는 필요 없음)
public void StopWeaponCollision() { } // 함수를 비워둘거에요 -> 근접 무기 충돌 종료(활에는 필요 없음)
2026-02-06 09:27:08 +00:00
2026-02-09 14:49:44 +00:00
// ============================================
// Gizmo 디버그 — 기존 로직 100% 유지
// ============================================
2026-02-12 15:23:25 +00:00
private void OnDrawGizmosSelected() // 함수를 실행할거에요 -> 에디터 선택 시 디버그 그림을 그리는 OnDrawGizmosSelected를
2026-02-06 09:27:08 +00:00
{
2026-02-12 15:23:25 +00:00
if (!enableAutoAim) return; // 조건이 맞으면 중단할거에요 -> 자동 조준이 꺼져있다면
Gizmos.color = Color.yellow; // 색상을 설정할거에요 -> 노란색으로
Gizmos.DrawWireSphere(transform.position, autoAimRange); // 그림을 그릴거에요 -> 자동 조준 범위를
2026-02-06 09:27:08 +00:00
2026-02-12 15:23:25 +00:00
if (Application.isPlaying && firePoint != null) // 조건이 맞으면 실행할거에요 -> 게임 실행 중이고 발사 위치가 있다면
2026-02-06 09:27:08 +00:00
{
2026-02-12 15:23:25 +00:00
Vector3 mouseDir = GetMouseDirection(); // 값을 가져올거에요 -> 마우스 방향을
Gizmos.color = Color.blue; // 색상을 설정할거에요 -> 파란색으로
Gizmos.DrawRay(firePoint.position, mouseDir * 5f); // 선을 그릴거에요 -> 마우스 방향 레이를
2026-02-06 09:27:08 +00:00
2026-02-12 15:23:25 +00:00
Transform target = FindBestTarget(mouseDir); // 값을 가져올거에요 -> 타겟을
if (target != null) // 조건이 맞으면 실행할거에요 -> 타겟이 있다면
2026-02-06 09:27:08 +00:00
{
2026-02-12 15:23:25 +00:00
Vector3 targetPos = target.position + Vector3.up * 1.2f; // 위치를 계산할거에요 -> 타겟 중심점을
Vector3 targetDir = (targetPos - firePoint.position).normalized; // 방향을 계산할거에요 -> 타겟 방향을
targetDir.y = 0; // 값을 바꿀거에요 -> 수평으로
Vector3 assistedDir = Vector3.Lerp(mouseDir, targetDir, aimAssistStrength); // 보간할거에요 -> 보정된 방향을
assistedDir.Normalize(); // 정규화할거에요 -> 벡터를
2026-02-25 10:39:20 +00:00
Gizmos.color = Color.red; // 색상을 설정할거에요 -> 빨간색으로
2026-02-12 15:23:25 +00:00
Gizmos.DrawRay(firePoint.position, assistedDir * 7f); // 선을 그릴거에요 -> 최종 발사 방향을
Gizmos.color = Color.green; // 색상을 설정할거에요 -> 초록색으로
Gizmos.DrawWireSphere(targetPos, 0.3f); // 그림을 그릴거에요 -> 타겟 위치 표시를
2026-02-06 09:27:08 +00:00
}
}
}
2026-01-29 06:58:38 +00:00
}