// PD 지시 #750 — 시작 맵 기상 연출 (쓰러진 상태 → 일어나기 → 조작 개방) // 2단계 배치 경로: Assets/WL/Start/StartWakeSequence.cs // 배치 방법: WL_Start.prefab 루트(MapData 와 같은 오브젝트)에 컴포넌트로 추가하고 // settings 에 Assets/WL/Settings/StartWakeSettings.asset 을 연결한다. // 컴포넌트가 붙어 있다는 사실 자체가 「이 맵에서 기상 연출을 한다」는 플래그다. // (MapData 에 bool 을 추가하지 않으므로 힘민지 기존 코드 수정이 필요 없다.) // // ── 힘민지 코드 수정량: 0줄 ─────────────────────────────────────────── // 이 컴포넌트는 자체 코루틴으로 MyValue.bMyPC 를 폴링하므로 훅이 필요 없다. // 더 완벽한 프레임 단위 시작을 원하면 아래 「선택적 훅」을 참고. (§주석 하단) // // ── 실측 근거 (2026-09-05) ──────────────────────────────────────────── // · 시작 맵은 e_MapType == Lobby → PCActor.Set_Obj (PCActor.cs:270-273) 가 // "Assets/Res_Addr/Animations/pcanim_lobby.controller" 만 로드한다. // 따라서 상태 추가는 컨트롤러 13개가 아니라 pcanim_lobby 1개면 충분하다. // · 컨트롤러 할당은 비동기다 (PCActor.cs:283 콜백). Actor.Set 의 Play_Idle()(Actor.cs:297) 은 // 컨트롤러 할당 이전에 실행되므로, 기상 포즈는 컨트롤러가 붙은 뒤에 걸어야 한다. // · 조이스틱 이동을 실제로 막는 유일한 레버는 OptionInfo.Set_PCMove 다. // PCActor.Run_byJoystick (PCActor.cs:351-392) 은 IsStop 을 검사하지 않는다. // 선례: FieldBossData.Co_Cam (FieldBossData.cs:42-52) 가 동일한 잠금→연출→해제 패턴. // · 애니메이션 재생 API 는 전부 문자열 상태명이다 — Actor.AnimationPlay (Actor.cs:2020). using System.Collections; using UnityEngine; namespace WL.Start { public class StartWakeSequence : MonoBehaviour { [SerializeField] private StartWakeSettings settings; /// 연출이 진행 중인가. 다른 시스템이 참조할 수 있게 공개한다. public static bool IsPlaying { get; private set; } /// 이 맵에서 기상 연출을 쓰는가 (컴포넌트 존재 = true). public static bool IsArmed { get; private set; } private void Awake() { IsArmed = true; if (settings == null) { Debug.LogError("[StartWake] settings 가 비어 있다. StartWakeSettings.asset 을 연결할 것. 연출을 건너뛴다."); IsArmed = false; return; } StartCoroutine(Co_Sequence()); } private void OnDestroy() { if (IsPlaying) Unlock(null); IsArmed = false; } private void Log(string s) { if (settings != null && settings.verboseLog) Debug.Log("[StartWake] " + s); } private IEnumerator Co_Sequence() { IsPlaying = true; float t0 = Time.realtimeSinceStartup; // 1) 플레이어 등장 대기 — PCActor.ReSet 에서 MyValue.MyPC 가 채워진다 (PCActor.cs:151) while (!MyValue.bMyPC) { if (Timeout(t0)) { Log("플레이어 대기 타임아웃 — 연출 취소"); Unlock(null); yield break; } yield return null; } var pc = MyValue.MyPC; Log("플레이어 인식"); // #756 (2026-09-06): 로비 모드는 PCActor.Set_Obj 가 Change_Weapon 을 건너뛴다 → MapData.showWeaponInLobby 가 켜진 맵에서는 여기서 장착한다. var mapData = GetComponent(); if (mapData != null && mapData.showWeaponInLobby) { pc.Change_Weapon(); Log("무기 장착 (showWeaponInLobby)"); } // 2) 즉시 잠근다 (조이스틱이 살아나기 전에) Lock(pc); // 3+4) 애니메이터 컨트롤러가 비동기로 붙기를 기다린다 (PCActor.Set_Obj 의 LoadObject 콜백). // // 🔴 실측(2026-09-05 Play 검증): 클래스 컨트롤러(pcanim_mage)가 먼저 붙어 있고 // 로비용 pcanim_lobby 는 그 뒤에 콜백으로 "교체"된다. // 따라서 "컨트롤러가 null 이 아님"만 기다리면 아직 pcanim_mage 인 시점에 // 상태 검사를 해 버려 연출이 통째로 건너뛰어졌다. // → lying/getup 상태를 실제로 가진 컨트롤러가 붙을 때까지 기다린다. int lyingHash = Animator.StringToHash(settings.lyingStateName); int getUpHash = Animator.StringToHash(settings.getUpStateName); var anim = pc.m_animation; while (anim == null || anim.runtimeAnimatorController == null || !anim.HasState(0, lyingHash) || !anim.HasState(0, getUpHash)) { if (Timeout(t0)) { Debug.LogWarning("[StartWake] '" + settings.lyingStateName + "' / '" + settings.getUpStateName + "' 상태를 가진 컨트롤러가 " + settings.readyTimeout + "초 안에 붙지 않았다 (현재: " + (anim == null || anim.runtimeAnimatorController == null ? "없음" : anim.runtimeAnimatorController.name) + "). WL_StartWakeAnimSetup 실행 여부를 확인할 것. 연출을 건너뛴다."); Unlock(pc); yield break; } anim = pc.m_animation; yield return null; } Log("애니메이터 준비 완료 — " + anim.runtimeAnimatorController.name); // 5) 쓰러진 자세 — 컨트롤러가 idle 로 진입한 직후를 덮어쓴다 pc.AnimationPlay(settings.lyingStateName); Log("쓰러진 자세 진입"); // 6) 인트로 카메라 도착 대기 (RealCamera.CamStatus 0 → 1) if (settings.waitForIntroCamera) { float camT0 = Time.realtimeSinceStartup; while (!IntroCameraArrived()) { if (Time.realtimeSinceStartup - camT0 > settings.introCameraTimeout) { Log("인트로 카메라 대기 타임아웃 — 그대로 진행"); break; } // 카메라가 움직이는 동안 idle 로 되돌아가지 않도록 자세를 유지한다 KeepLying(anim, lyingHash); yield return null; } Log("인트로 카메라 도착"); } // 7) 뜸 들이기 float wait = settings.delayBeforeGetUp; while (wait > 0f) { KeepLying(anim, lyingHash); wait -= Time.deltaTime; yield return null; } // 8) 기상 pc.AnimationPlay(settings.getUpStateName); Log("기상 시작"); yield return null; // 상태 전이가 반영되는 1프레임 float dur = settings.getUpDuration; if (dur <= 0f) { var info = anim.GetCurrentAnimatorStateInfo(0); dur = info.shortNameHash == getUpHash ? info.length : 1.5f; Log("기상 클립 길이 실측 = " + dur.ToString("F3") + "s"); } yield return new WaitForSeconds(dur); if (settings.delayAfterGetUp > 0f) yield return new WaitForSeconds(settings.delayAfterGetUp); // 9) 개방 pc.Play_Idle(); Unlock(pc); Log("조작 개방 — 연출 종료"); } private bool Timeout(float t0) { return Time.realtimeSinceStartup - t0 > settings.readyTimeout; } // 애니메이터가 다른 상태로 새면 되돌린다 (Actor 쪽에서 Play_Idle 이 불릴 수 있다) private void KeepLying(Animator anim, int lyingHash) { if (anim == null) return; var st = anim.GetCurrentAnimatorStateInfo(0); if (st.shortNameHash != lyingHash) anim.Play(settings.lyingStateName); } // RealCamera 의 인트로 이동 완료 판정. // CamStatus 의 접근성이 미확인이라 리플렉션으로 읽고, 못 읽으면 false 를 돌려 // introCameraTimeout 으로 넘어가게 한다. private static System.Reflection.FieldInfo _camStatusField; private static bool _camStatusResolved; private bool IntroCameraArrived() { var cam = MyValue.m_RealCamera; if (cam == null) return false; if (!_camStatusResolved) { _camStatusResolved = true; _camStatusField = cam.GetType().GetField("CamStatus", System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.Public | System.Reflection.BindingFlags.NonPublic); if (_camStatusField == null) Debug.LogWarning("[StartWake] RealCamera.CamStatus 를 못 읽었다 — introCameraTimeout 으로 진행한다."); } if (_camStatusField == null) return false; object v = _camStatusField.GetValue(cam); return v is int && (int)v != 0; } private void Lock(PCActor pc) { if (pc == null) return; if (settings.lockMove && OptionInfo.Ins != null) { OptionInfo.Ins.Set_PCMove(pc, false); Log("이동 잠금 (Set_PCMove false)"); } if (settings.lockSkillsViaStun) Log("주의: lockSkillsViaStun 은 Actor 의 dic_ccData 접근이 필요하다 — 2단계 구현 전 PD 결정 필요"); } private void Unlock(PCActor pc) { IsPlaying = false; if (pc == null) pc = MyValue.bMyPC ? MyValue.MyPC : null; if (pc == null) return; if (settings != null && settings.lockMove && OptionInfo.Ins != null) { OptionInfo.Ins.Set_PCMove(pc, true); Log("이동 잠금 해제 (Set_PCMove true)"); } } } } // ───────────────────────────────────────────────────────────────────── // 선택적 훅 (프레임 단위로 완벽한 시작을 원할 때만 — 힘민지 코드 2줄) // // 1) Assets/Script/Character/PCActor.cs — Set_Obj() 의 컨트롤러 로드 콜백 안, // m_animation.runtimeAnimatorController = handle.Result; (283행) 바로 다음 줄에: // // if (WL.Start.StartWakeSequence.IsArmed) AnimationPlay("lying"); // // 효과: 컨트롤러가 붙는 그 프레임에 즉시 엎드린 자세가 된다. // 이 훅이 없으면 컨트롤러 기본 상태(idle)가 1~2프레임 보일 수 있다. // 단, 그 시점의 카메라는 인트로 이동 중이라 실사용상 거의 드러나지 않는다. // // 2) Assets/Script/Util/RealCamera.cs — Start_Game() (244행) 첫 줄에: // // if (WL.Start.StartWakeSequence.IsPlaying) { /* 연출 중 — 자동 개방을 늦춘다 */ } // // 효과: All_Stop(false) 시점을 연출 종료까지 미루고 싶을 때만 필요. // 현재 구현은 Set_PCMove 로 이동을 막으므로 이 훅 없이도 동작한다. // ─────────────────────────────────────────────────────────────────────