534 lines
22 KiB
C#
534 lines
22 KiB
C#
using System;
|
|
using System.Collections.Generic;
|
|
using UnityEngine.InputSystem;
|
|
using UnityEngine;
|
|
|
|
[RequireComponent(typeof(CharacterVoiceObject))]
|
|
public class DialogPlayer : MonoBehaviour
|
|
{
|
|
// 대화 후보는 인스펙터가 아니라 StoryDatabase에서 온다 —
|
|
// 현재 장소(LocationManager.Current) + 이 캐릭터의 비트 중 조건을 만족하는 것들.
|
|
// 여럿이면 선택 메뉴가 뜨고, DB 목록에서 위에 있을수록 우선순위가 높다.
|
|
|
|
private CharacterVoiceObject _voice; // 이 NPC의 캐릭터 정보 (호감도 조건 대상)
|
|
private Animator _animator;
|
|
|
|
// 대화 중 제스처/표정을 재생한 Animator들의 원래 상태 — 대화 종료 시 전부 복원.
|
|
// (끼어든 다른 NPC의 Animator도 포함되므로 딕셔너리로 추적한다)
|
|
private readonly Dictionary<Animator, (int gestureHash, int expressionHash, bool hasExpression)> _touchedAnimators = new();
|
|
public bool IsPlaying { get; private set; }
|
|
|
|
// 지금 실제 대사를 재생 중인 DialogPlayer (전역 1개) — 이때 다른 NPC의 Play()는 무시된다.
|
|
// 선택 메뉴만 떠 있는 단계는 여기 안 잡힌다 (그 경우는 새 NPC가 메뉴를 취소하고 시작).
|
|
private static DialogPlayer _entryInProgress;
|
|
|
|
// ── 전역 대화 진행 신호 (대화 중 월드 상호작용 차단용) ────────
|
|
// 선택 메뉴 단계부터 대사 종료까지, 어느 NPC든 대화가 진행 중이면 true.
|
|
// 이동 버튼·클릭 인터랙션 등이 AnyActiveChanged를 구독해 대화 중 입력을 잠그면 된다.
|
|
public static bool IsAnyActive => _activeCount > 0;
|
|
public static event Action<bool> AnyActiveChanged;
|
|
private static int _activeCount;
|
|
|
|
private static void PushActive()
|
|
{
|
|
if (++_activeCount == 1) AnyActiveChanged?.Invoke(true);
|
|
}
|
|
|
|
private static void PopActive()
|
|
{
|
|
if (_activeCount <= 0) return;
|
|
if (--_activeCount == 0) AnyActiveChanged?.Invoke(false);
|
|
}
|
|
|
|
// Enter Play Mode에서 도메인 리로드를 꺼도 이전 상태가 안 남게
|
|
[RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.SubsystemRegistration)]
|
|
private static void ResetStatics()
|
|
{
|
|
_entryInProgress = null;
|
|
_activeCount = 0;
|
|
AnyActiveChanged = null;
|
|
}
|
|
|
|
private void Awake()
|
|
{
|
|
_voice = GetComponent<CharacterVoiceObject>();
|
|
_animator = GetComponentInChildren<Animator>(); // 화자를 못 찾을 때의 폴백 Animator
|
|
}
|
|
|
|
private void OnDestroy()
|
|
{
|
|
// 재생 도중 파괴(씬 전환 등)돼도 전역 잠금이 남지 않도록
|
|
if (_entryInProgress == this) _entryInProgress = null;
|
|
}
|
|
|
|
public async Awaitable Play()
|
|
{
|
|
if (IsPlaying) return;
|
|
|
|
// 다른 NPC가 실제 대사를 재생 중이면 무시 — 대화 중 다른 NPC 상호작용 차단
|
|
if (_entryInProgress != null) return;
|
|
|
|
// 다른 NPC의 선택 메뉴가 떠 있으면 먼저 취소한다.
|
|
// (취소된 쪽의 Play가 이 자리에서 HUD 숨김 등 정리를 마친 뒤에 이쪽이 시작된다)
|
|
if (ChoiceHud.Instance != null)
|
|
ChoiceHud.Instance.CancelPending();
|
|
|
|
// 선택 메뉴가 떠 있는 동안에도 재진입을 막아야 하므로 여기서 잠근다.
|
|
IsPlaying = true;
|
|
PushActive();
|
|
try
|
|
{
|
|
var playable = FindPlayableBeats();
|
|
if (playable.Count == 0)
|
|
{
|
|
Debug.Log($"[DialogPlayer] 조건에 맞는 대화가 없음: {name}");
|
|
return;
|
|
}
|
|
|
|
StoryBeat beat = playable.Count == 1 ? playable[0] : await SelectBeat(playable);
|
|
if (beat == null) return; // 선택 대기 중 취소됨 (다른 NPC와 대화 시작, 씬 전환 등)
|
|
|
|
_entryInProgress = this; // 여기서부터 실제 대사 재생 — 끝날 때까지 다른 NPC 상호작용 무시
|
|
await PlayBeat(beat);
|
|
}
|
|
catch (OperationCanceledException)
|
|
{
|
|
// 대화 도중 선택지 대기가 취소됨 (씬 전환으로 ChoiceHud 비활성화 등) — PlayBeat의 finally에서 정리됨
|
|
}
|
|
finally
|
|
{
|
|
if (_entryInProgress == this) _entryInProgress = null;
|
|
IsPlaying = false;
|
|
PopActive();
|
|
}
|
|
}
|
|
|
|
// 조건/선택 메뉴를 건너뛰고 특정 대화 그룹을 강제로 시작한다 (버튼·컷신 등 UnityEvent 연결용).
|
|
// DB에 등록된 비트면 그 설정(진행도 보상 등)을 그대로 쓰고, 없으면 임시 비트로 재생한다.
|
|
// 완료 여부는 검사하지 않으므로(강제) 1회성이 필요하면 호출하는 쪽(존의 Trigger Once)에서 보장할 것.
|
|
public void PlayGroup(DialogGroup group)
|
|
{
|
|
_ = PlayGroupForced(group);
|
|
}
|
|
|
|
private async Awaitable PlayGroupForced(DialogGroup group)
|
|
{
|
|
if (group == null || IsPlaying) return;
|
|
if (_entryInProgress != null) return; // 다른 NPC가 대사 재생 중
|
|
|
|
// 다른 NPC의 선택 메뉴가 떠 있으면 먼저 취소
|
|
if (ChoiceHud.Instance != null)
|
|
ChoiceHud.Instance.CancelPending();
|
|
|
|
IsPlaying = true;
|
|
PushActive();
|
|
try
|
|
{
|
|
// DB에 등록된 비트가 있으면 그 설정(진행도 보상 등)을 그대로 사용, 없으면 임시 비트로 재생
|
|
var lm = LocationManager.Instance;
|
|
StoryBeat beat = lm != null && lm.Database != null ? lm.Database.FindBeat(group) : null;
|
|
beat ??= new StoryBeat { Group = group };
|
|
|
|
_entryInProgress = this;
|
|
await PlayBeat(beat);
|
|
}
|
|
catch (OperationCanceledException)
|
|
{
|
|
// 씬 전환 등으로 취소됨 — PlayBeat의 finally에서 정리됨
|
|
}
|
|
finally
|
|
{
|
|
if (_entryInProgress == this) _entryInProgress = null;
|
|
IsPlaying = false;
|
|
PopActive();
|
|
}
|
|
}
|
|
|
|
// 현재 장소에서 이 캐릭터가 걸 수 있는 대화 비트들 (DB 목록 순서 = 우선순위)
|
|
private List<StoryBeat> FindPlayableBeats()
|
|
{
|
|
var lm = LocationManager.Instance;
|
|
if (lm == null || lm.Database == null)
|
|
{
|
|
Debug.LogWarning($"[DialogPlayer] LocationManager 또는 StoryDatabase가 없음: {name}");
|
|
return new List<StoryBeat>();
|
|
}
|
|
return lm.Database.GetPlayableBeats(lm.Current, _voice.Character);
|
|
}
|
|
|
|
// 재생 가능한 대화가 여럿일 때 ChoiceHud로 플레이어에게 고르게 한다. 취소되면 null.
|
|
private async Awaitable<StoryBeat> SelectBeat(List<StoryBeat> playable)
|
|
{
|
|
if (ChoiceHud.Instance == null)
|
|
return playable[0]; // 선택 UI가 없으면 기존처럼 최상단 우선
|
|
|
|
var options = new List<DialogChoice>(playable.Count);
|
|
foreach (var beat in playable)
|
|
{
|
|
string label = !string.IsNullOrWhiteSpace(beat.MenuLabel)
|
|
? beat.MenuLabel
|
|
: beat.Group.DialogGroupName;
|
|
options.Add(new DialogChoice { ChoiceText = label }); // Code 없음 → 선택 이력에 기록 안 됨
|
|
}
|
|
|
|
try
|
|
{
|
|
int picked = await ChoiceHud.Instance.Show(null, options);
|
|
return playable[picked];
|
|
}
|
|
catch (OperationCanceledException)
|
|
{
|
|
// 대기 중 ChoiceHud가 비활성화됨(씬 전환 등) — 재생하지 않음
|
|
return null;
|
|
}
|
|
}
|
|
|
|
private async Awaitable PlayBeat(StoryBeat beat)
|
|
{
|
|
try
|
|
{
|
|
var node = beat.Group.StartNode;
|
|
int routingHops = 0; // 연속 라우팅 횟수 — 라우팅 노드끼리 순환하면 대기 없는 무한 루프가 되므로 차단
|
|
while (node != null)
|
|
{
|
|
// 호감도 라우팅 노드 — 대사 없이 즉시 분기 (플레이어에겐 분기 자체가 보이지 않는다)
|
|
if (node.AffectionCheck)
|
|
{
|
|
if (++routingHops > 100)
|
|
{
|
|
Debug.LogError($"[DialogPlayer] 라우팅 노드가 순환합니다 — 대화 중단: {beat.Group.name}");
|
|
break;
|
|
}
|
|
node = IsAffectionMet(node) ? node.AffectionPassBranch : node.Next;
|
|
continue;
|
|
}
|
|
routingHops = 0; // 실제 대사 노드에 도달 — 카운터 리셋
|
|
|
|
// 이 노드가 히든 분기를 가지면, 노드가 재생되는 동안 발동 감시를 무장한다.
|
|
// (무장 안 된 노드는 아래 대기/선택이 기존과 완전히 동일하게 동작)
|
|
bool armed = node.HiddenBranches != null && node.HiddenBranches.Count > 0;
|
|
if (armed) HiddenBranchResolver.Arm(node.HiddenBranches);
|
|
try
|
|
{
|
|
bool diverted = await PlayNode(node); // 대사 표시 + 대기(무장 시 발동 감시 포함)
|
|
|
|
if (diverted)
|
|
{
|
|
node = TakeHiddenBranch(node); // 대사 도중 발동 → 몰래 분기
|
|
}
|
|
else if (node.Choices != null && node.Choices.Count > 0)
|
|
{
|
|
int picked = await WaitForChoice(node); // 메뉴(무장 시 발동 감시 포함)
|
|
if (picked == DivertIndex)
|
|
{
|
|
node = TakeHiddenBranch(node); // 메뉴 도중 발동 → 메뉴엔 없던 히든 분기
|
|
}
|
|
else
|
|
{
|
|
RecordChoice(node, picked);
|
|
node = node.Choices[picked].DestinationNode;
|
|
}
|
|
}
|
|
else
|
|
{
|
|
node = node.Next;
|
|
}
|
|
}
|
|
finally
|
|
{
|
|
if (armed) HiddenBranchResolver.Disarm();
|
|
}
|
|
}
|
|
|
|
// 여기까지 왔으면 자연 종료(끝까지 재생) — 이때만 완료로 기록한다.
|
|
// (중간에 오브젝트 파괴 등으로 끊기면 예외로 빠져나가 기록되지 않음)
|
|
var story = StoryManager.Instance;
|
|
bool firstTime = story.MarkDialogCompleted(beat.Group.name);
|
|
if (firstTime && beat.ProgressOnComplete > 0)
|
|
story.MainProgress += beat.ProgressOnComplete;
|
|
story.Save();
|
|
|
|
Debug.Log($"[DialogPlayer] 대화 종료: {beat.Group.name}");
|
|
}
|
|
finally
|
|
{
|
|
if (DialogHud.Instance != null)
|
|
DialogHud.Instance.Hide();
|
|
if (SoundManager.Instance != null)
|
|
SoundManager.Instance.ClearOverrideBGM(); // 대화가 끝나면 기본 BGM으로 복귀
|
|
RestoreDefaultAnimations();
|
|
}
|
|
}
|
|
|
|
// 대화 중 제스처/표정을 재생했던 모든 Animator(끼어든 NPC 포함)를 원래 상태로 복원
|
|
private void RestoreDefaultAnimations()
|
|
{
|
|
foreach (var kvp in _touchedAnimators)
|
|
{
|
|
var anim = kvp.Key;
|
|
if (anim == null) continue;
|
|
anim.CrossFade(kvp.Value.gestureHash, 0.3f, 0, normalizedTimeOffset: 0f);
|
|
if (kvp.Value.hasExpression)
|
|
anim.CrossFade(kvp.Value.expressionHash, 0.3f, 1, normalizedTimeOffset: 0f);
|
|
}
|
|
_touchedAnimators.Clear();
|
|
}
|
|
|
|
// 처음 건드리는 Animator면 현재 상태를 기억해 둔다 (대화 종료 시 복원 기준)
|
|
private void CaptureInitialAnimState(Animator anim)
|
|
{
|
|
if (_touchedAnimators.ContainsKey(anim)) return;
|
|
bool hasExpression = anim.layerCount > 1;
|
|
_touchedAnimators[anim] = (
|
|
anim.GetCurrentAnimatorStateInfo(0).fullPathHash,
|
|
hasExpression ? anim.GetCurrentAnimatorStateInfo(1).fullPathHash : 0,
|
|
hasExpression);
|
|
}
|
|
|
|
// 반환: true = 대기 도중 히든 제스처가 발동됨(→ HiddenBranch로 분기), false = 평범하게 진행
|
|
private async Awaitable<bool> PlayNode(DialogNode node)
|
|
{
|
|
// DialogHud에 대사 표시 (이름은 노드 오버라이드 우선)
|
|
if (DialogHud.Instance != null)
|
|
DialogHud.Instance.Show(node.Speaker, node.TalkText, node.SpeakerNameOverride);
|
|
|
|
// 호감도 증감 — 화자 기준, 화자가 비어 있으면 대화 주인 NPC
|
|
if (node.Affection != 0)
|
|
{
|
|
var affectionTarget = node.Speaker != null ? node.Speaker : _voice.Character;
|
|
StoryManager.Instance.AddAffection(affectionTarget, node.Affection);
|
|
}
|
|
|
|
// 메인 진행도 증가 — 분기 끝 노드마다 다른 값을 주면 선택지에 따라 진행도가 달라진다
|
|
if (node.Progress != 0)
|
|
StoryManager.Instance.MainProgress += node.Progress;
|
|
|
|
// 전용 BGM: 설정돼 있으면 교체, 비어 있으면 기본 BGM으로 복귀
|
|
if (SoundManager.Instance != null)
|
|
{
|
|
if (node.Bgm != null)
|
|
SoundManager.Instance.PlayOverrideBGM(node.Bgm);
|
|
else
|
|
SoundManager.Instance.ClearOverrideBGM();
|
|
}
|
|
|
|
// 보이스 재생
|
|
if (node.Voice != null && node.Speaker != null)
|
|
{
|
|
var voiceObj = CharacterVoiceObject.Find(node.Speaker);
|
|
if (voiceObj != null && node.Voice.Clip != null)
|
|
voiceObj.Play(node.Voice.Clip);
|
|
}
|
|
|
|
// 제스처/표정은 화자(Target)의 Animator에 적용 — 끼어든 NPC의 대사/연출 노드도 그 캐릭터가 움직인다.
|
|
// 화자를 못 찾으면 대화 주인 NPC의 Animator로 폴백.
|
|
if (node.Gesture != null || node.Expression != null)
|
|
{
|
|
var gestureObj = node.Speaker != null ? CharacterVoiceObject.Find(node.Speaker) : null;
|
|
var anim = gestureObj != null ? gestureObj.GetComponentInChildren<Animator>() : _animator;
|
|
if (anim == null) anim = _animator;
|
|
|
|
if (anim != null)
|
|
{
|
|
CaptureInitialAnimState(anim); // 대화 종료 시 복원할 원래 상태 기억
|
|
if (node.Gesture != null)
|
|
anim.CrossFade(node.Gesture.StateName, node.Gesture.CrossFadeDuration, node.Gesture.AnimationLayer);
|
|
if (node.Expression != null)
|
|
anim.CrossFade(node.Expression.StateName, node.Expression.CrossFadeDuration, node.Expression.AnimationLayer);
|
|
}
|
|
}
|
|
|
|
// 진행은 항상 플레이어 입력 대기.
|
|
// 히든 분기가 무장된 노드는 대기 중 히든 발동을 함께 감시(레이스)한다.
|
|
if (HiddenBranchResolver.Armed)
|
|
return await WaitAdvanceOrDivert();
|
|
|
|
await WaitForAdvanceInput();
|
|
return false;
|
|
}
|
|
|
|
// 호감도 분기 노드의 조건식 평가.
|
|
// 각 조건의 JoinWithNext로 이어 붙이며, AND가 OR보다 우선순위가 높다:
|
|
// A and B or C → (A and B) or C
|
|
// 즉 "AND 그룹들을 OR로 합치는" 형태로 계산한다. 조건이 없으면 통과로 본다.
|
|
private bool IsAffectionMet(DialogNode node)
|
|
{
|
|
var requirements = node.AffectionRequirements;
|
|
if (requirements == null || requirements.Count == 0) return true;
|
|
|
|
var story = StoryManager.Instance;
|
|
bool result = false; // 지금까지 닫힌 AND 그룹들을 OR로 합친 값
|
|
bool group = true; // 현재 진행 중인 AND 그룹
|
|
|
|
for (int i = 0; i < requirements.Count; i++)
|
|
{
|
|
var req = requirements[i];
|
|
if (req == null) continue;
|
|
|
|
var target = req.Character != null ? req.Character : _voice.Character;
|
|
group &= req.IsMet(story.GetAffection(target));
|
|
|
|
// 다음 연결자가 OR이거나 마지막이면 AND 그룹을 닫고 OR로 합친다
|
|
bool isLast = i == requirements.Count - 1;
|
|
if (isLast || req.JoinWithNext == AffectionJoin.Or)
|
|
{
|
|
result |= group;
|
|
group = true;
|
|
}
|
|
}
|
|
|
|
return result;
|
|
}
|
|
|
|
// 선택 기록: 선택지 Code를 StoryState에 영구 기록 (대화 활성화 조건 판정에 사용)
|
|
private void RecordChoice(DialogNode node, int index)
|
|
{
|
|
string code = (node.Choices != null && index >= 0 && index < node.Choices.Count)
|
|
? node.Choices[index].Code : null;
|
|
code = DialogVariables.Format(code); // {token} 치환 → 동적으로 생성된 코드 반영
|
|
|
|
if (!string.IsNullOrEmpty(code))
|
|
StoryManager.Instance.RecordChoice(code);
|
|
}
|
|
|
|
// 발동한 히든 분기로 이동한다. 어느 분기가 발동했는지는 Resolver가 키를 보고 이미 골라 뒀다.
|
|
// (Disarm보다 먼저 불려야 하므로 반드시 무장 구간 안에서 호출할 것)
|
|
private DialogNode TakeHiddenBranch(DialogNode node)
|
|
{
|
|
int index = HiddenBranchResolver.FiredIndex;
|
|
if (node.HiddenBranches == null || index < 0 || index >= node.HiddenBranches.Count)
|
|
{
|
|
// 정상 흐름에선 올 수 없다(발동 = 매칭 성공). 와도 대화가 끊기지 않게 원래 흐름으로.
|
|
Debug.LogWarning($"[DialogPlayer] 히든 분기 인덱스가 범위를 벗어남: {index}");
|
|
return node.Next;
|
|
}
|
|
|
|
var branch = node.HiddenBranches[index];
|
|
|
|
// 선택지 Code와 동일하게 StoryState에 영구 기록
|
|
string code = DialogVariables.Format(branch.Code); // {token} 치환
|
|
if (!string.IsNullOrEmpty(code))
|
|
StoryManager.Instance.RecordChoice(code);
|
|
|
|
return branch.Destination;
|
|
}
|
|
|
|
// 히든 분기 신호값 — 선택지 인덱스(0..n-1)와 절대 겹치지 않는 값.
|
|
private const int DivertIndex = -1;
|
|
|
|
private async Awaitable<int> WaitForChoice(DialogNode node)
|
|
{
|
|
if (ChoiceHud.Instance == null)
|
|
{
|
|
Debug.LogWarning("[DialogPlayer] ChoiceHud 없음 — 0번 자동 선택");
|
|
return 0;
|
|
}
|
|
|
|
// 무장 안 된 노드는 기존과 동일 — 그냥 메뉴 선택을 기다린다.
|
|
if (!HiddenBranchResolver.Armed)
|
|
return await ChoiceHud.Instance.Show(node.ChoiceQuestion, node.Choices);
|
|
|
|
// 무장된 노드: 메뉴가 떠 있는 동안 발동이 들어오면 메뉴를 취소하고 히든으로 분기.
|
|
if (HiddenBranchResolver.Fired)
|
|
return DivertIndex; // 메뉴가 뜨기 전에 이미 발동한 경우
|
|
|
|
void OnFired() => ChoiceHud.Instance.CancelPending(); // 발동 → 메뉴 즉시 취소
|
|
HiddenBranchResolver.FiredEvent += OnFired;
|
|
try
|
|
{
|
|
return await ChoiceHud.Instance.Show(node.ChoiceQuestion, node.Choices);
|
|
}
|
|
catch (OperationCanceledException)
|
|
{
|
|
// 우리가 히든 발동으로 취소한 것이면 히든 분기, 아니면(씬 전환 등) 위로 전파
|
|
if (HiddenBranchResolver.Fired) return DivertIndex;
|
|
throw;
|
|
}
|
|
finally
|
|
{
|
|
HiddenBranchResolver.FiredEvent -= OnFired;
|
|
}
|
|
}
|
|
|
|
// 노드 대기 + 히든 발동 감시(레이스). 무장된 노드에서만 호출된다.
|
|
// 반환: true = 대기 도중 히든 발동(→히든 분기), false = 평범하게 진행(입력)
|
|
private async Awaitable<bool> WaitAdvanceOrDivert()
|
|
{
|
|
var im = InputManager.Instance;
|
|
bool advance = false;
|
|
void OnAdvance() => advance = true;
|
|
if (im != null) im.OnDialogNext_Event += OnAdvance;
|
|
|
|
// InputManager가 없으면 기존 WaitForAdvanceInput처럼 1초 후 진행
|
|
float timeLeft = im == null ? 1f : -1f;
|
|
|
|
try
|
|
{
|
|
while (true)
|
|
{
|
|
if (HiddenBranchResolver.Fired) return true; // 히든 발동 → 히든 분기
|
|
if (advance) return false; // 진행 입력 → 평범하게 진행
|
|
if (timeLeft >= 0f)
|
|
{
|
|
timeLeft -= Time.deltaTime;
|
|
if (timeLeft <= 0f) return false;
|
|
}
|
|
await Awaitable.NextFrameAsync(destroyCancellationToken);
|
|
}
|
|
}
|
|
catch (OperationCanceledException)
|
|
{
|
|
return false; // 대기 중 파괴/씬 전환 — 기존과 동일하게 조용히 종료
|
|
}
|
|
finally
|
|
{
|
|
if (im != null) im.OnDialogNext_Event -= OnAdvance;
|
|
}
|
|
}
|
|
|
|
// 대화 진행 입력(OnDialogNext) 한 번을 대기
|
|
private async Awaitable WaitForAdvanceInput()
|
|
{
|
|
var im = InputManager.Instance;
|
|
if (im == null)
|
|
{
|
|
// 입력 매니저 없으면 안전하게 잠깐 대기 후 진행
|
|
await Awaitable.WaitForSecondsAsync(1f);
|
|
return;
|
|
}
|
|
|
|
bool pressed = false;
|
|
void Handler() => pressed = true;
|
|
im.OnDialogNext_Event += Handler;
|
|
try
|
|
{
|
|
while (!pressed)
|
|
await Awaitable.NextFrameAsync(destroyCancellationToken);
|
|
}
|
|
catch (OperationCanceledException)
|
|
{
|
|
// 대기 중 오브젝트 파괴 시 조용히 종료
|
|
}
|
|
finally
|
|
{
|
|
im.OnDialogNext_Event -= Handler;
|
|
}
|
|
}
|
|
|
|
//테스트용 — 캐릭터를 클릭하면 대화 시작 (Collider2D가 있어야 잡힌다)
|
|
private void Update()
|
|
{
|
|
if (Mouse.current == null) return;
|
|
if (!Mouse.current.leftButton.wasPressedThisFrame) return;
|
|
if (Camera.main == null) return;
|
|
|
|
var world = Camera.main.ScreenToWorldPoint(Mouse.current.position.ReadValue());
|
|
var hit = Physics2D.OverlapPoint(world);
|
|
if (hit != null && hit.transform.IsChildOf(transform))
|
|
{
|
|
Debug.Log("캐릭터 클릭");
|
|
_ = Play(); // 테스트용 fire-and-forget (Update는 await 불가)
|
|
}
|
|
}
|
|
}
|