Files
2026-07-15 14:07:25 +09:00

209 lines
10 KiB
C#
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
using System.Collections.Generic;
using TMPro;
using UnityEngine;
// 화자(NPC) 옆에 떠 있는 World-space 대사 HUD 싱글턴.
// DialogPlayer가 대사 노드를 재생할 때 Show()로 화자 이름 + 대사를 표시한다.
//
// Placement(화자 옆 배치) 로직을 담당한다. 원래 ChoiceHud에 있던 로직을 이리로 옮겼다.
// 카메라(플레이어)가 아니라 화자(NPC)의 회전을 기준으로 배치/회전한다 —
// NPC가 LookAtPlayer 등으로 돌면 HUD도 같이 돈다. 플레이어 위치에는 영향받지 않는다.
// 자기 자신(transform)을 화자 옆으로 옮기므로, ChoiceHud를 이 오브젝트의 자식으로 두면 함께 따라온다.
// 주의: 이 오브젝트(GO)는 항상 활성 상태여야 한다(LateUpdate가 돌아야 하므로).
// 보이기/숨기기는 자식 패널(_panel)만 토글한다.
public class DialogHud : MonoBehaviour
{
public static DialogHud Instance { get; private set; }
[Header("Refs")]
[SerializeField] private GameObject _panel; // 대사 패널(토글 대상). 보통 이 오브젝트의 자식.
[SerializeField] private TMP_Text _speakerName;
[SerializeField] private TMP_Text _dialogueText;
[Header("Placement (기본값 — Show에서 오프셋을 안 넘길 때 폴백)")]
[SerializeField] private float _chestHeight = 1.2f; // 화자 발 기준 가슴 높이
[SerializeField] private float _forwardOffset = 0.5f; // 화자가 바라보는 방향으로 띄울 거리
[SerializeField] private float _lateralOffset = 0f; // 좌우 오프셋 (+ 화자와 마주보는 시점 오른쪽)
[SerializeField] private Vector3 _rotationOffset = Vector3.zero; // 화자 회전 기준 추가 회전 (+α, 오일러 각)
// ── 후보 위치 자동 선택 ─────────────────────────────────────
// 대사창을 띄울 때 후보를 우선순위 순서대로 검사해서, 벽/지형에 겹치지 않고
// 플레이어에게 가려지지 않는 첫 후보 위치에 창을 띄운다.
// 후보 목록 자체는 NPC마다 크기가 달라 화자의 DialogHudPlacement에서 넘어온다.
// 후보 하나가 완전한 배치값을 갖는다 (기본값 + 보정 방식 아님 — 그대로 적용됨)
[System.Serializable]
public struct PlacementCandidate
{
public string Name; // 인스펙터 식별용
public float Height; // 화자 발 기준 높이
public float Forward; // 화자가 바라보는 방향으로 띄울 거리
public float Lateral; // 좌우 오프셋 (+ 화자와 마주보는 시점 오른쪽)
public Vector3 RotationOffset; // 추가 회전 (오일러 각)
}
[Header("Auto Placement (후보 위치 자동 선택)")]
[Tooltip("대사창이 피해야 할 레이어 (벽/지형 = Default). NPC·플레이어 레이어는 넣지 말 것")]
[SerializeField] private LayerMask _obstacleMask = 1; // Default 레이어
[Tooltip("충돌 검사에 쓰는 대사창 절반 크기 — 실제 캔버스 크기에 맞출 것")]
[SerializeField] private Vector3 _panelHalfExtents = new(0.5f, 0.3f, 0.05f);
[Tooltip("가림 허용 비율 — 패널 표본점(5×4=20점, 1점=5%) 중 이 비율까지는 가려져도 통과")]
[Range(0f, 1f)] [SerializeField] private float _maxOccludedFraction = 0.05f;
private Transform _speakerTransform;
private float _activeChestHeight;
private float _activeForwardOffset;
private float _activeLateralOffset;
private Vector3 _activeRotationOffset;
private void Awake()
{
if (Instance != null && Instance != this) { Destroy(gameObject); return; }
Instance = this;
Hide();
}
private void OnDestroy()
{
if (Instance == this) Instance = null;
}
// 화자 옆에 대사 표시.
// - 배치는 화자 오브젝트의 DialogHudPlacement 값 사용, 없으면 이 컴포넌트의 기본값 폴백
// - speakerNameOverride가 비어있지 않으면 CharacterData.Name 대신 그 이름을 표시한다 (예: "???")
// - 화자의 DialogHudPlacement에 후보 목록이 있으면 벽/가림 검사를 통과하는 첫 후보 위치로 자동 배치
// - anchorOverride가 있으면 창의 위치/배치만 그 캐릭터 기준으로 하고 이름/대사는 speaker 것을 쓴다
// (다른 NPC가 끼어드는 대사지만 창은 대화 주인 옆에 두고 싶을 때)
public void Show(CharacterData speaker, string text, string speakerNameOverride = null, CharacterData anchorOverride = null)
{
var anchorData = anchorOverride != null ? anchorOverride : speaker;
var voiceObj = anchorData != null ? CharacterVoiceObject.Find(anchorData) : null;
_speakerTransform = voiceObj != null ? voiceObj.transform : null;
DialogHudPlacement placement = null;
if (voiceObj != null) voiceObj.TryGetComponent(out placement);
if (placement != null && placement.Candidates != null && placement.Candidates.Count > 0)
{
ApplyCandidate(placement.Candidates[0]); // 일단 첫 후보(기본)로 배치하고
ApplyBestCandidate(placement.Candidates); // 검사를 통과하는 후보가 있으면 교체
}
else
{
_activeChestHeight = _chestHeight;
_activeForwardOffset = _forwardOffset;
_activeLateralOffset = _lateralOffset;
_activeRotationOffset = _rotationOffset;
}
if (_speakerName != null)
{
string speakerName = !string.IsNullOrEmpty(speakerNameOverride) ? speakerNameOverride
: speaker != null ? speaker.Name : string.Empty;
_speakerName.text = DialogVariables.Format(speakerName); // {key} 토큰 치환
}
if (_dialogueText != null)
_dialogueText.text = DialogVariables.Format(text); // {key} 토큰 치환
if (_panel != null) _panel.SetActive(true);
}
public void Hide()
{
if (_dialogueText != null) _dialogueText.text = string.Empty;
if (_speakerName != null) _speakerName.text = string.Empty;
if (_panel != null) _panel.SetActive(false);
_speakerTransform = null;
}
// 선택된 후보의 배치값을 그대로 적용한다
private void ApplyCandidate(PlacementCandidate cand)
{
_activeChestHeight = cand.Height;
_activeForwardOffset = cand.Forward;
_activeLateralOffset = cand.Lateral;
_activeRotationOffset = cand.RotationOffset;
}
// 후보 위치들을 우선순위대로 검사해 통과하는 첫 후보를 적용한다. 전부 실패하면 첫 후보 유지.
// 검사 기준은 호출 시점의 화자→플레이어(카메라) 방향 — 대화가 시작되면 NPC가
// 플레이어 쪽으로 돌아서므로, 돌아선 뒤의 실제 배치와 일치한다.
private void ApplyBestCandidate(List<PlacementCandidate> candidates)
{
if (candidates == null || candidates.Count == 0) return;
if (_speakerTransform == null || Camera.main == null) return;
Vector3 camPos = Camera.main.transform.position;
Vector3 dir = camPos - _speakerTransform.position;
dir.y = 0f;
if (dir.sqrMagnitude < 0.0001f) return;
dir.Normalize();
Vector3 right = Vector3.Cross(dir, Vector3.up);
foreach (var cand in candidates)
{
Vector3 pos = _speakerTransform.position
+ Vector3.up * cand.Height
+ dir * cand.Forward
+ right * cand.Lateral;
Quaternion rot = Quaternion.LookRotation(-dir) * Quaternion.Euler(cand.RotationOffset);
// 벽/지형에 겹치는가
if (Physics.CheckBox(pos, _panelHalfExtents, rot, _obstacleMask, QueryTriggerInteraction.Ignore))
continue;
// 플레이어 눈에서 벽에 가려지는가 — 패널 전체를 표본점으로 검사해 허용 비율까지만 통과
if (OccludedFraction(camPos, pos, rot) > _maxOccludedFraction)
continue;
ApplyCandidate(cand);
return;
}
// 전부 실패 — 미리 적용해 둔 첫 후보(기본) 그대로 사용
}
// 카메라 눈에서 패널 표면 표본점들로 레이를 쏴서 벽에 가려진 비율(0~1)을 구한다.
// 표본은 패널 사각형을 모서리·가장자리까지 덮는 5×4 격자 — 중심 한 점만 검사하면
// 가장자리가 벽 뒤로 반쯤 걸쳐도 통과해 버리는 문제를 잡기 위한 것.
private const int _occlusionCols = 5;
private const int _occlusionRows = 4;
private float OccludedFraction(Vector3 camPos, Vector3 center, Quaternion rot)
{
Vector3 right = rot * Vector3.right * _panelHalfExtents.x;
Vector3 up = rot * Vector3.up * _panelHalfExtents.y;
int blocked = 0;
for (int r = 0; r < _occlusionRows; r++)
{
float v = (float)r / (_occlusionRows - 1) * 2f - 1f; // -1(아래) ~ +1(위)
for (int c = 0; c < _occlusionCols; c++)
{
float u = (float)c / (_occlusionCols - 1) * 2f - 1f; // -1(좌) ~ +1(우)
Vector3 p = center + right * u + up * v;
if (Physics.Linecast(camPos, p, _obstacleMask, QueryTriggerInteraction.Ignore))
blocked++;
}
}
return (float)blocked / (_occlusionRows * _occlusionCols);
}
private void LateUpdate()
{
if (_speakerTransform == null) return;
// 화자(NPC)가 바라보는 수평 방향 (yaw만) — LookAtPlayer 등으로 NPC가 돌면 HUD도 같이 돈다
Vector3 dir = _speakerTransform.forward;
dir.y = 0f;
if (dir.sqrMagnitude < 0.0001f) return;
dir.Normalize();
Vector3 right = Vector3.Cross(dir, Vector3.up); // 화자와 마주보는 시점 기준 오른쪽(수평)
Vector3 chestWorld = _speakerTransform.position + Vector3.up * _activeChestHeight;
transform.position = chestWorld + dir * _activeForwardOffset + right * _activeLateralOffset;
// 읽는 면(-Z)이 화자가 바라보는 쪽을 향하도록 놓고, 그 위에 추가 회전(+α)을 곱한다
transform.rotation = Quaternion.LookRotation(-dir) * Quaternion.Euler(_activeRotationOffset);
}
}