From 527dc542184e49647eade25230db8dbee7041c45 Mon Sep 17 00:00:00 2001 From: "NJ\\skrwn" Date: Tue, 1 Sep 2026 21:36:23 +0900 Subject: [PATCH] =?UTF-8?q?=EC=98=A4=EB=A5=98=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Assets/01_Scenes/MainScene.unity | 4 +- .../02_Scripts/Character/CharacterDragger.cs | 16 ++ .../Character/CharacterMotionDirector.cs | 133 ++++++++--- .../02_Scripts/Character/ICharacterMotion.cs | 24 ++ .../02_Scripts/Character/PlatformOccluder.cs | 136 +++++++++++ .../Character/PlatformOccluder.cs.meta | 2 + Assets/02_Scripts/Character/PoseMenuUI.cs | 222 ++++++++++++++++++ .../02_Scripts/Character/PoseMenuUI.cs.meta | 2 + Assets/02_Scripts/Character/WindowClimber.cs | 110 +++++++++ Assets/02_Scripts/Chat/ChatUiBuilder.cs | 33 +++ Assets/02_Scripts/Chat/ChatWindowUI.cs | 33 +-- .../02_Scripts/Desktop/ClickThroughHitTest.cs | 2 +- Assets/02_Scripts/Desktop/FrameRateLimiter.cs | 86 +++++++ .../Desktop/FrameRateLimiter.cs.meta | 2 + .../Desktop/PlatformDebugOverlay.cs | 4 +- Assets/02_Scripts/Desktop/TrayIcon.cs | 20 ++ Assets/02_Scripts/Desktop/Win32.cs | 1 + .../Editor/CharacterMotionSetupMenu.cs | 76 +++++- Assets/03_Shaders.meta | 8 + Assets/03_Shaders/PlatformOccluder.shader | 61 +++++ .../03_Shaders/PlatformOccluder.shader.meta | 9 + Assets/04_Animations/Fall.anim | 4 +- Assets/04_Animations/FlySitIdle.anim | 4 +- Assets/04_Animations/Origin/Fall.FBX.meta | 2 +- .../04_Animations/Origin/FlySitIdle.FBX.meta | 2 +- Assets/04_Animations/Origin/Walk.FBX.meta | 2 +- Assets/04_Animations/Walk.anim | 4 +- 27 files changed, 922 insertions(+), 80 deletions(-) create mode 100644 Assets/02_Scripts/Character/PlatformOccluder.cs create mode 100644 Assets/02_Scripts/Character/PlatformOccluder.cs.meta create mode 100644 Assets/02_Scripts/Character/PoseMenuUI.cs create mode 100644 Assets/02_Scripts/Character/PoseMenuUI.cs.meta create mode 100644 Assets/02_Scripts/Desktop/FrameRateLimiter.cs create mode 100644 Assets/02_Scripts/Desktop/FrameRateLimiter.cs.meta create mode 100644 Assets/03_Shaders.meta create mode 100644 Assets/03_Shaders/PlatformOccluder.shader create mode 100644 Assets/03_Shaders/PlatformOccluder.shader.meta diff --git a/Assets/01_Scenes/MainScene.unity b/Assets/01_Scenes/MainScene.unity index c052441..125c7f1 100644 --- a/Assets/01_Scenes/MainScene.unity +++ b/Assets/01_Scenes/MainScene.unity @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:9b74de853bf429427add4271dc3edb23c65cb210dc7ce0243ee3a75da79fe086 -size 22921 +oid sha256:db4d9d698adf13f8fd8fb761947ae805f209668b974f1a8a9fc2bf6461122265 +size 24910 diff --git a/Assets/02_Scripts/Character/CharacterDragger.cs b/Assets/02_Scripts/Character/CharacterDragger.cs index 4454014..1af4211 100644 --- a/Assets/02_Scripts/Character/CharacterDragger.cs +++ b/Assets/02_Scripts/Character/CharacterDragger.cs @@ -46,6 +46,7 @@ public class CharacterDragger : MonoBehaviour bool pressing; // 버튼이 눌린 상태(아직 드래그는 아닐 수 있음) bool dragging; // 임계값을 넘겨 실제 드래그 중 bool prevButtonDown; // 눌린 순간만 잡아내기 위한 직전 프레임 상태 + bool prevRightDown; Vector2 pressStartPos; Vector2 grabScreenOffset; // 캐릭터 원점 - 커서 (화면 좌표) Vector2 boundsOffMin, boundsOffMax; // 원점 기준 캐릭터의 화면상 범위 @@ -65,6 +66,15 @@ public class CharacterDragger : MonoBehaviour /// public event System.Action Clicked; + /// + /// 캐릭터 위에서 오른쪽 버튼을 누른 순간. 포즈 바꾸기 등에 쓴다. + /// + /// 왼쪽 버튼과 같은 방식으로 OS 에 직접 묻는다. 이 창은 포커스를 받지 않아 + /// Unity 입력을 신뢰하기 어렵고, 무엇보다 판정에 쓰는 커서 좌표를 이미 + /// GetCursorPos 로 얻고 있어 두 경로를 섞을 이유가 없다. + /// + public event System.Action RightClicked; + void Awake() { hitTest = GetComponent(); @@ -78,11 +88,17 @@ void Update() { #if !UNITY_EDITOR && UNITY_STANDALONE_WIN bool buttonDown = Win32.IsKeyDown(Win32.VK_LBUTTON); + bool rightDown = Win32.IsKeyDown(Win32.VK_RBUTTON); #else var mouse = UnityEngine.InputSystem.Mouse.current; bool buttonDown = mouse != null && mouse.leftButton.isPressed; + bool rightDown = mouse != null && mouse.rightButton.isPressed; #endif + // 오른쪽 버튼은 누른 순간만 본다. 드래그와 달리 이어지는 조작이 없다. + if (rightDown && !prevRightDown && hitTest.IsOverCharacter) RightClicked?.Invoke(); + prevRightDown = rightDown; + // 눌린 "순간"만 잡는다. 이미 누른 채로 커서가 캐릭터 위로 지나가는 경우 // (다른 창을 끌고 오다가 캐릭터를 스치는 등)에 잡히면 안 된다. // 클릭으로 채팅창이 토글되면서부터는 이 오작동이 눈에 띄게 된다. diff --git a/Assets/02_Scripts/Character/CharacterMotionDirector.cs b/Assets/02_Scripts/Character/CharacterMotionDirector.cs index ad1937b..53da668 100644 --- a/Assets/02_Scripts/Character/CharacterMotionDirector.cs +++ b/Assets/02_Scripts/Character/CharacterMotionDirector.cs @@ -19,6 +19,7 @@ public class CharacterMotionDirector : MonoBehaviour [Header("참조 (비우면 씬에서 탐색)")] [SerializeField] VrmCharacterLoader loader; [SerializeField] WindowClimber climber; + [SerializeField] CharacterDragger dragger; [Header("애니메이션")] [Tooltip("Humanoid 클립을 담은 Animator Controller. 비우면 ProceduralIdle 로 동작한다")] @@ -39,28 +40,32 @@ public class CharacterMotionDirector : MonoBehaviour [SerializeField] string sitState = "Sit"; [SerializeField] string lieDownState = "LieDown"; - [Header("쉬는 자세")] - [Tooltip("발판에 선 뒤 자세를 바꾸기까지 기다리는 시간의 최소/최대(초). " + - "매번 다르게 해야 기계적으로 보이지 않는다")] - [SerializeField] float restDelayMin = 5f; - [SerializeField] float restDelayMax = 14f; + [Header("포즈 (캐릭터를 우클릭하면 목록이 뜬다)")] + [Tooltip("비우면 씬에서 탐색. 없으면 우클릭이 다음 포즈로 넘기는 방식으로 대체된다")] + [SerializeField] PoseMenuUI poseMenu; - [Tooltip("서 있을 때 고를 자세들. Idle 을 섞어둬야 계속 누워만 있지 않는다")] + [Tooltip("우클릭 목록에 나올 포즈들. 첫 항목이 기본 자세이고, " + + "걷거나 떨어졌다 돌아오면 항상 첫 항목으로 되돌아온다. " + + "각 항목의 Hide Behind Platform 을 켜면 그 포즈일 때 발판 아래가 가려진다")] [SerializeField] - CharacterMotionKind[] restPoses = + PoseOption[] poses = { - CharacterMotionKind.Idle, - CharacterMotionKind.Sit, - CharacterMotionKind.LieDown, + new PoseOption { kind = CharacterMotionKind.Idle }, + new PoseOption { kind = CharacterMotionKind.Sit }, + new PoseOption { kind = CharacterMotionKind.LieDown }, }; + [Header("발판 뒤로 숨기기")] + [Tooltip("비우면 씬에서 탐색. 없으면 가림 기능만 조용히 꺼진다")] + [SerializeField] PlatformOccluder occluder; + [Header("동작")] [Tooltip("클립이 준비되면 ProceduralIdle 을 끈다. 둘 다 같은 본을 써서 함께 두면 싸운다")] [SerializeField] bool disableProceduralIdle = true; ICharacterMotion motion; ClimbState currentState = ClimbState.Falling; - float nextRestChangeTime; + int poseIndex; Dictionary BuildNames() => new Dictionary { @@ -76,6 +81,27 @@ void Awake() { if (loader == null) loader = FindFirstObjectByType(); if (climber == null) climber = FindFirstObjectByType(); + if (dragger == null) dragger = FindFirstObjectByType(); + if (occluder == null) occluder = FindFirstObjectByType(); + if (poseMenu == null) poseMenu = FindFirstObjectByType(); + + WarnIfOccluderMissing(); + } + + /// 가림을 켜둔 포즈가 있는데 가림 컴포넌트가 없으면, 조용히 안 되는 대신 알린다. + void WarnIfOccluderMissing() + { + if (poses == null || occluder != null) return; + + foreach (var pose in poses) + { + if (!pose.hideBehindPlatform) continue; + + Debug.LogWarning($"[CharacterMotionDirector] '{pose.kind}' 에 발판 가림이 켜져 있지만 " + + "씬에 PlatformOccluder 가 없어 동작하지 않습니다. " + + "메뉴 10 을 다시 실행하세요."); + return; + } } void OnEnable() @@ -92,25 +118,51 @@ void OnEnable() climber.StateChanged += OnStateChanged; currentState = climber.State; } + + if (dragger != null) dragger.RightClicked += OnRightClicked; } void OnDisable() { if (loader != null) loader.Loaded -= OnCharacterLoaded; if (climber != null) climber.StateChanged -= OnStateChanged; + if (dragger != null) dragger.RightClicked -= OnRightClicked; } - void Update() + /// + /// 캐릭터를 우클릭하면 포즈 목록을 띄운다. + /// + /// 서 있을 때만 받는다. 걷거나 떨어지는 중에 눕힐 수는 없다. + /// + void OnRightClicked() { - // 서 있을 때만 자세를 바꾼다. 걷거나 떨어지는 중에 눕게 할 수는 없다. if (motion == null || currentState != ClimbState.Standing) return; - if (Time.unscaledTime < nextRestChangeTime) return; + if (poses == null || poses.Length == 0) return; - ScheduleNextRestChange(); - - if (restPoses != null && restPoses.Length > 0) + if (poseMenu == null) { - motion.Play(restPoses[Random.Range(0, restPoses.Length)]); + // 메뉴가 없으면 최소한 다음 포즈로라도 넘어간다. 아무 반응이 없는 것보다 낫다. + poseIndex = (poseIndex + 1) % poses.Length; + PlayPose(poses[poseIndex]); + return; + } + + if (poseMenu.IsOpen) + { + poseMenu.Hide(); + return; + } + + if (!DesktopCursor.TryGetScreenPosition(CursorHwnd, out Vector2 cursor)) return; + poseMenu.Show(cursor, poses, PlayPose); + } + + System.IntPtr CursorHwnd + { + get + { + var window = FindFirstObjectByType(); + return window != null ? window.Hwnd : System.IntPtr.Zero; } } @@ -145,24 +197,51 @@ void OnCharacterLoaded(ICharacterAvatar avatar) } currentState = climber != null ? climber.State : ClimbState.Standing; - motion.Play(ToMotionKind(currentState)); - ScheduleNextRestChange(); + poseIndex = 0; + ApplyState(currentState); } void OnStateChanged(ClimbState state) { currentState = state; - motion?.Play(ToMotionKind(state)); - // 서기 시작한 시점부터 다시 센다. 착지하자마자 눕지 않게. - if (state == ClimbState.Standing) ScheduleNextRestChange(); + // 움직이기 시작하면 고른 포즈는 버린다. 걷다가 다시 서면 기본 자세부터. + poseIndex = 0; + ApplyState(state); } - void ScheduleNextRestChange() + /// + /// 이동 상태에 맞는 몸짓으로 간다. 서 있는 상태만 "포즈"로 취급해 + /// 목록의 첫 항목(가림 설정 포함)을 쓰고, 나머지는 그냥 이동 몸짓이다. + /// + void ApplyState(ClimbState state) { - float min = Mathf.Max(0.5f, restDelayMin); - float max = Mathf.Max(min, restDelayMax); - nextRestChangeTime = Time.unscaledTime + Random.Range(min, max); + if (state == ClimbState.Standing) PlayDefaultPose(); + else PlayMotion(ToMotionKind(state)); + } + + /// + /// 고른 포즈를 재생하고, 그 포즈의 가림 설정도 함께 반영한다. + /// 포즈를 바꾸는 곳이 여러 군데라 한 곳으로 모은다 — 따로 두면 가림만 남는 사고가 난다. + /// + void PlayPose(PoseOption pose) + { + motion?.Play(pose.kind); + if (occluder != null) occluder.Active = pose.hideBehindPlatform; + } + + /// 이동 중의 몸짓. 걷거나 떨어지는 동안에는 가리지 않는다. + void PlayMotion(CharacterMotionKind kind) + { + motion?.Play(kind); + if (occluder != null) occluder.Active = false; + } + + /// 발판에 섰을 때의 기본 자세. 목록의 첫 항목이다. + void PlayDefaultPose() + { + if (poses != null && poses.Length > 0) PlayPose(poses[0]); + else PlayMotion(CharacterMotionKind.Idle); } static CharacterMotionKind ToMotionKind(ClimbState state) diff --git a/Assets/02_Scripts/Character/ICharacterMotion.cs b/Assets/02_Scripts/Character/ICharacterMotion.cs index 116ecb4..f811755 100644 --- a/Assets/02_Scripts/Character/ICharacterMotion.cs +++ b/Assets/02_Scripts/Character/ICharacterMotion.cs @@ -1,3 +1,5 @@ +using UnityEngine; + /// /// 캐릭터가 지을 수 있는 몸짓. /// @@ -15,6 +17,28 @@ public enum CharacterMotionKind LieDown, // 눕기 } +/// +/// 우클릭 메뉴에 나올 포즈 하나. 표시 이름과 가림 여부를 함께 들고 다닌다. +/// +/// 예전에는 "메뉴에 넣을 포즈 목록"과 "가려야 할 포즈 목록"을 따로 뒀는데, +/// 둘을 손으로 맞춰야 했고 어긋나면(가림 목록에만 있고 메뉴 목록엔 없으면) +/// 그 포즈에 도달할 방법이 없어 조용히 아무 일도 안 일어났다. +/// 한 곳에 묶으면 그 상태 자체가 만들어질 수 없다. +/// +[System.Serializable] +public struct PoseOption +{ + [Tooltip("어떤 몸짓인지")] + public CharacterMotionKind kind; + + [Tooltip("메뉴에 표시할 이름. 비우면 기본 이름을 쓴다")] + public string label; + + [Tooltip("켜면 이 포즈일 때 발판 아래를 가린다 — 캐릭터가 창 뒤에 있는 것처럼 보인다. " + + "예: 창 밑에서 머리만 내미는 포즈")] + public bool hideBehindPlatform; +} + /// /// 캐릭터 몸짓을 재생하는 계층의 창구. /// diff --git a/Assets/02_Scripts/Character/PlatformOccluder.cs b/Assets/02_Scripts/Character/PlatformOccluder.cs new file mode 100644 index 0000000..d6a86df --- /dev/null +++ b/Assets/02_Scripts/Character/PlatformOccluder.cs @@ -0,0 +1,136 @@ +using UnityEngine; + +/// +/// 발판 아래에 깊이만 기록하는 사각형을 깔아 캐릭터를 가린다. +/// "발판(=실제 창) 뒤에 서 있는" 그림을 만들 때 쓴다. 예: 창 밑에서 머리만 내미는 포즈. +/// +/// 왜 렌더 순서로 해결되지 않는가: +/// 발판은 Unity 오브젝트가 아니라 진짜 데스크톱 창이고, 우리 창은 그 위에 떠 있는 +/// 최상위 오버레이다. 그래서 "창 뒤에 그린다"가 물리적으로 불가능하다. +/// 대신 그 영역에서 캐릭터를 아예 그리지 않으면, 우리 창은 그 자리에 투명한 채로 +/// 남고 진짜 창이 비쳐 보인다. 결과가 같다. +/// +/// 방법은 깊이 마스크다. 색을 안 쓰고(ColorMask 0) 깊이만 쓰는 사각형을 +/// 캐릭터보다 카메라 가까이, 캐릭터보다 먼저(Queue Geometry-100) 그리면 +/// 그 뒤의 캐릭터 픽셀이 깊이 테스트에서 탈락한다. 알파를 0 으로 덮는 방법과 달리 +/// 같은 자리에 그려진 다른 것(채팅창 등)을 건드리지 않는다. +/// +[DefaultExecutionOrder(100)] // WindowClimber(LateUpdate) 가 위치를 정한 뒤에 따라간다 +public class PlatformOccluder : MonoBehaviour +{ + [Header("참조 (비우면 씬에서 탐색)")] + [SerializeField] WindowClimber climber; + [SerializeField] Camera viewCamera; + + [Tooltip("MyCharacterAgent/PlatformOccluder 셰이더. " + + "직접 참조해야 빌드에서 셰이더가 빠지지 않는다")] + [SerializeField] Shader occluderShader; + + [Header("동작")] + [Tooltip("캐릭터보다 카메라 쪽으로 얼마나 앞에 둘지(월드 단위). " + + "너무 작으면 깊이 정밀도 문제로 지글거리고, 너무 크면 다른 것까지 가린다")] + [SerializeField] float depthBias = 0.05f; + + [Tooltip("발판 위쪽 경계를 얼마나 내릴지(픽셀). 양수면 발판선보다 아래부터 가린다. " + + "머리가 살짝 더 보이게 하고 싶을 때 조절한다")] + [SerializeField] float topOffset = 0f; + + /// 지금 가려야 하는지. CharacterMotionDirector 가 포즈에 따라 켜고 끈다. + public bool Active { get; set; } + + MeshFilter meshFilter; + MeshRenderer meshRenderer; + Mesh mesh; + readonly Vector3[] corners = new Vector3[4]; + + void Awake() + { + if (climber == null) climber = FindFirstObjectByType(); + if (viewCamera == null) viewCamera = Camera.main; + + BuildQuad(); + SetVisible(false); + } + + void OnDestroy() + { + if (mesh != null) Destroy(mesh); + if (meshRenderer != null && meshRenderer.material != null) Destroy(meshRenderer.material); + } + + void LateUpdate() + { + if (meshRenderer == null) return; + + if (!Active || climber == null || viewCamera == null) + { + SetVisible(false); + return; + } + + var platform = climber.CurrentPlatform; + if (platform == null) + { + // 공중에 있는 동안은 가릴 발판이 없다. + SetVisible(false); + return; + } + + UpdateQuad(platform.Value); + SetVisible(true); + } + + /// 발판의 X 범위 × (발판선 아래 ~ 화면 아래)를 덮는 사각형으로 갱신한다. + void UpdateQuad(DesktopPlatform platform) + { + // 캐릭터와 같은 평면보다 살짝 앞. 여기서 깊이를 써야 캐릭터가 탈락한다. + float d = Mathf.Max(0.01f, climber.CharacterDepth - depthBias); + float top = platform.Y - topOffset; + + corners[0] = viewCamera.ScreenToWorldPoint(new Vector3(platform.XMin, 0f, d)); + corners[1] = viewCamera.ScreenToWorldPoint(new Vector3(platform.XMax, 0f, d)); + corners[2] = viewCamera.ScreenToWorldPoint(new Vector3(platform.XMax, top, d)); + corners[3] = viewCamera.ScreenToWorldPoint(new Vector3(platform.XMin, top, d)); + + // 정점을 월드 좌표로 넣으므로 트랜스폼은 원점/무회전으로 둔다. + mesh.SetVertices(corners); + mesh.RecalculateBounds(); + } + + void BuildQuad() + { + transform.SetPositionAndRotation(Vector3.zero, Quaternion.identity); + transform.localScale = Vector3.one; + + mesh = new Mesh { name = "PlatformOccluderQuad" }; + // 매 프레임 정점이 바뀌므로 표시해 둔다. + mesh.MarkDynamic(); + mesh.SetVertices(corners); + mesh.SetTriangles(new[] { 0, 2, 1, 0, 3, 2 }, 0); + + meshFilter = gameObject.GetComponent(); + if (meshFilter == null) meshFilter = gameObject.AddComponent(); + meshFilter.sharedMesh = mesh; + + meshRenderer = gameObject.GetComponent(); + if (meshRenderer == null) meshRenderer = gameObject.AddComponent(); + meshRenderer.shadowCastingMode = UnityEngine.Rendering.ShadowCastingMode.Off; + meshRenderer.receiveShadows = false; + meshRenderer.lightProbeUsage = UnityEngine.Rendering.LightProbeUsage.Off; + meshRenderer.reflectionProbeUsage = UnityEngine.Rendering.ReflectionProbeUsage.Off; + + if (occluderShader == null) + { + Debug.LogWarning("[PlatformOccluder] 셰이더가 비어 있어 가림이 동작하지 않습니다. " + + "MyCharacterAgent/PlatformOccluder 셰이더를 연결하세요."); + return; + } + + meshRenderer.material = new Material(occluderShader) { name = "PlatformOccluder (runtime)" }; + } + + void SetVisible(bool value) + { + if (meshRenderer != null && meshRenderer.enabled != value) meshRenderer.enabled = value; + } +} diff --git a/Assets/02_Scripts/Character/PlatformOccluder.cs.meta b/Assets/02_Scripts/Character/PlatformOccluder.cs.meta new file mode 100644 index 0000000..b45367f --- /dev/null +++ b/Assets/02_Scripts/Character/PlatformOccluder.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: b3a860401aad71342b84131203d2e1d5 \ No newline at end of file diff --git a/Assets/02_Scripts/Character/PoseMenuUI.cs b/Assets/02_Scripts/Character/PoseMenuUI.cs new file mode 100644 index 0000000..8d8f39f --- /dev/null +++ b/Assets/02_Scripts/Character/PoseMenuUI.cs @@ -0,0 +1,222 @@ +using System; +using System.Collections.Generic; +using UnityEngine; +using UnityEngine.UI; + +/// +/// 캐릭터를 우클릭하면 뜨는 포즈 목록. 항목을 누르면 그 포즈가 된다. +/// +/// 이전에는 우클릭할 때마다 다음 포즈로 넘어갔는데, 그러면 어떤 포즈가 있는지 +/// 보이지 않고 원하는 것까지 여러 번 눌러야 한다. 무엇보다 목록에 없는 포즈는 +/// 도달할 방법이 아예 없어서(예: 가림 포즈만 따로 설정한 경우) 조용히 안 되는 +/// 상황이 생긴다. 목록을 눈에 보이게 하면 그 문제가 사라진다. +/// +/// 채팅창과 같은 방식으로 만든다 — 런타임 uGUI, 클릭 통과 히트테스트에 사각형 등록. +/// 등록하지 않으면 클릭이 뒤 창으로 새어나가 버튼이 눌리지 않는다. +/// +/// 키보드 포커스는 필요 없다(마우스만 쓴다). 그래서 채팅창과 달리 +/// WS_EX_NOACTIVATE 를 건드리지 않는다. +/// +public class PoseMenuUI : MonoBehaviour +{ + [Header("모양")] + [SerializeField] float menuWidth = 132f; + [SerializeField] float itemHeight = 32f; + [SerializeField] float padding = 6f; + [SerializeField] int fontSize = 13; + + [Header("참조 (비우면 씬에서 탐색)")] + [SerializeField] ClickThroughHitTest hitTest; + + Canvas canvas; + RectTransform panel; + RectTransform itemRoot; + Action onPick; + Func hitRegion; + bool prevLeftDown; + + public bool IsOpen => panel != null && panel.gameObject.activeSelf; + + void Awake() + { + if (hitTest == null) hitTest = FindFirstObjectByType(); + + ChatUiBuilder.EnsureEventSystem(); + Build(); + panel.gameObject.SetActive(false); + } + + void OnEnable() + { + if (hitTest != null) + { + hitRegion = ContainsScreenPoint; + hitTest.RegisterInteractiveRegion(hitRegion); + } + } + + void OnDisable() + { + if (hitTest != null && hitRegion != null) + { + hitTest.UnregisterInteractiveRegion(hitRegion); + hitRegion = null; + } + Hide(); + } + + void Update() + { + if (!IsOpen) return; + + // 바깥을 누르면 닫는다. 전체 화면 배경판을 깔면 화면 전체가 클릭을 받아버려서 + // 데스크톱 오버레이로서는 쓸 수 없다. 그래서 직접 커서를 확인한다. +#if !UNITY_EDITOR && UNITY_STANDALONE_WIN + bool leftDown = Win32.IsKeyDown(Win32.VK_LBUTTON); +#else + var mouse = UnityEngine.InputSystem.Mouse.current; + bool leftDown = mouse != null && mouse.leftButton.isPressed; +#endif + + if (leftDown && !prevLeftDown) + { + if (!DesktopCursor.TryGetScreenPosition(CursorHwnd, out Vector2 cursor) || + !ContainsScreenPoint(cursor)) + { + Hide(); + } + } + prevLeftDown = leftDown; + + var keyboard = UnityEngine.InputSystem.Keyboard.current; + if (keyboard != null && keyboard.escapeKey.wasPressedThisFrame) Hide(); + } + + IntPtr CursorHwnd + { + get + { + var window = hitTest != null ? hitTest.GetComponent() : null; + return window != null ? window.Hwnd : IntPtr.Zero; + } + } + + // ------------------------------------------------------------------ 조작 + + /// 커서 위치에 목록을 띄운다. 이미 열려 있으면 다시 그린다. + public void Show(Vector2 screenPos, IReadOnlyList poses, + Action pick) + { + if (panel == null || poses == null || poses.Count == 0) return; + + onPick = pick; + Rebuild(poses); + + panel.gameObject.SetActive(true); + Place(screenPos); + + // 이번 프레임의 우클릭이 "바깥 클릭"으로 오인되지 않게 한 번 흡수한다. + prevLeftDown = true; + } + + public void Hide() + { + if (panel != null) panel.gameObject.SetActive(false); + onPick = null; + } + + public bool ContainsScreenPoint(Vector2 screenPoint) + { + if (!IsOpen || panel == null) return false; + return RectTransformUtility.RectangleContainsScreenPoint(panel, screenPoint, null); + } + + void Place(Vector2 screenPos) + { + float scale = canvas != null ? canvas.scaleFactor : 1f; + Vector2 size = panel.sizeDelta * scale; + + // 커서 오른쪽 아래에 띄우되 화면 밖으로 나가지 않게 한다. + float x = Mathf.Clamp(screenPos.x, 0f, Mathf.Max(0f, Screen.width - size.x)); + float y = Mathf.Clamp(screenPos.y - size.y, 0f, Mathf.Max(0f, Screen.height - size.y)); + + panel.anchoredPosition = new Vector2(x, y) / scale; + } + + // ------------------------------------------------------------------ 계층 + + void Build() + { + var canvasGo = new GameObject("PoseMenuCanvas", typeof(Canvas), typeof(CanvasScaler), typeof(GraphicRaycaster)); + canvasGo.transform.SetParent(transform, false); + + canvas = canvasGo.GetComponent(); + canvas.renderMode = RenderMode.ScreenSpaceOverlay; + canvas.sortingOrder = 210; // 채팅창(200) 위에 + + var scaler = canvasGo.GetComponent(); + scaler.uiScaleMode = CanvasScaler.ScaleMode.ConstantPixelSize; + scaler.scaleFactor = 1f; + + panel = ChatUiBuilder.NewRect("Panel", canvasGo.transform as RectTransform); + panel.anchorMin = Vector2.zero; + panel.anchorMax = Vector2.zero; + panel.pivot = Vector2.zero; + + var background = panel.gameObject.AddComponent(); + background.sprite = ChatUiTheme.RoundedSmall; + background.type = Image.Type.Sliced; + background.color = new Color( + ChatUiTheme.PanelBackground.r, ChatUiTheme.PanelBackground.g, + ChatUiTheme.PanelBackground.b, 0.97f); + + itemRoot = ChatUiBuilder.NewRect("Items", panel); + ChatUiBuilder.Stretch(itemRoot, padding, padding); + var layout = ChatUiBuilder.MakeVerticalList(itemRoot, new RectOffset(0, 0, 0, 0), 2f); + layout.childForceExpandHeight = false; + } + + void Rebuild(IReadOnlyList poses) + { + for (int i = itemRoot.childCount - 1; i >= 0; i--) + { + Destroy(itemRoot.GetChild(i).gameObject); + } + + for (int i = 0; i < poses.Count; i++) + { + PoseOption pose = poses[i]; + string label = string.IsNullOrWhiteSpace(pose.label) ? LabelFor(pose.kind) : pose.label; + + var button = ChatUiBuilder.NewButton(pose.kind.ToString(), itemRoot, label, fontSize, + ChatUiTheme.InputBackground, ChatUiTheme.PrimaryText); + ChatUiBuilder.SetHeight(button.transform, itemHeight); + + button.onClick.AddListener(() => + { + var callback = onPick; + Hide(); + callback?.Invoke(pose); + }); + } + + panel.sizeDelta = new Vector2( + menuWidth, + poses.Count * itemHeight + Mathf.Max(0, poses.Count - 1) * 2f + padding * 2f); + } + + /// 목록에 보여줄 이름. 열거형 이름을 그대로 쓰면 사용자에게 불친절하다. + static string LabelFor(CharacterMotionKind kind) + { + switch (kind) + { + case CharacterMotionKind.Idle: return "서기"; + case CharacterMotionKind.Sit: return "걸터앉기"; + case CharacterMotionKind.LieDown: return "눕기"; + case CharacterMotionKind.Walk: return "걷기"; + case CharacterMotionKind.Fall: return "낙하"; + case CharacterMotionKind.Jump: return "점프"; + default: return kind.ToString(); + } + } +} diff --git a/Assets/02_Scripts/Character/PoseMenuUI.cs.meta b/Assets/02_Scripts/Character/PoseMenuUI.cs.meta new file mode 100644 index 0000000..5f7dc71 --- /dev/null +++ b/Assets/02_Scripts/Character/PoseMenuUI.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: c7e8cef4b39adce44b6dc1df6a6f4908 \ No newline at end of file diff --git a/Assets/02_Scripts/Character/WindowClimber.cs b/Assets/02_Scripts/Character/WindowClimber.cs index fd37bb2..85b70df 100644 --- a/Assets/02_Scripts/Character/WindowClimber.cs +++ b/Assets/02_Scripts/Character/WindowClimber.cs @@ -43,6 +43,8 @@ public class WindowClimber : MonoBehaviour [SerializeField] float walkSpeed = 160f; [Header("자동 올라타기")] + [Tooltip("스스로 창을 찾아 올라간다. 런타임에도 끌 수 있다 " + + "(트레이 아이콘 우클릭 메뉴, 또는 에디터에서는 이 체크박스)")] [SerializeField] bool autoClimb = true; [Tooltip("바닥에 있을 때만 올라탄다. 이미 창 위에 있으면 다른 창으로 옮기지 않는다")] @@ -85,6 +87,21 @@ public class WindowClimber : MonoBehaviour [Tooltip("화면 위쪽 여백. 캐릭터 머리가 이보다 위로 가면 발판에서 뛰어내린다")] [SerializeField] float ceilingMargin = 8f; + [Header("방향 전환")] + [Tooltip("걷고 뛰는 방향으로 몸을 돌린다. 끄면 늘 정면을 본 채 옆으로 미끄러진다")] + [SerializeField] bool turnToWalkDirection = true; + + [Tooltip("얼마나 돌릴지(도). 90 이면 완전히 옆모습이라 얼굴이 안 보인다. " + + "70 전후가 걷는 티는 나면서 얼굴도 살짝 보인다")] + [Range(0f, 90f)] + [SerializeField] float walkTurnAngle = 70f; + + [Tooltip("도는 속도(도/초). 낮추면 천천히 돌아선다")] + [SerializeField] float turnSpeed = 540f; + + [Tooltip("도는 쪽이 반대라면 켠다. 모델의 정면 축에 따라 달라진다")] + [SerializeField] bool invertTurnDirection = false; + [Header("기타")] [Tooltip("착지 판정 여유. 발판을 살짝 지나쳐도 잡아준다")] [SerializeField] float landTolerance = 24f; @@ -111,6 +128,34 @@ private set /// 이동 상태가 바뀔 때. 애니메이션 전환이 이걸 듣는다. public event System.Action StateChanged; + const string AutoClimbPrefKey = "MyCharacterAgent.AutoClimb"; + + /// + /// 스스로 창을 찾아 올라갈지. 런타임에 바꿀 수 있고 다음 실행에도 유지된다. + /// + /// 저장은 PlayerPrefs 로 한다. 모델 경로와 같은 방식이고, 채팅 설정 파일에 + /// 캐릭터 동작을 섞지 않으려는 이유도 있다. + /// + /// 끄더라도 진행 중인 이동은 끝까지 간다. 공중에서 갑자기 멈추면 더 어색하다. + /// + public bool AutoClimb + { + get => autoClimb; + set + { + if (autoClimb == value) return; + autoClimb = value; + + PlayerPrefs.SetInt(AutoClimbPrefKey, value ? 1 : 0); + PlayerPrefs.Save(); + + AutoClimbChanged?.Invoke(value); + } + } + + /// 자동 올라타기 설정이 바뀔 때. 트레이 메뉴 표시 갱신 등에 쓴다. + public event System.Action AutoClimbChanged; + Transform character; float depth; // 카메라로부터의 깊이. 드래그와 같은 평면을 유지한다. Vector2 screenPos; // 캐릭터 발밑의 화면 좌표 @@ -120,6 +165,16 @@ private set System.IntPtr standingHwnd; float offsetFromWindowLeft; bool hasStanding; + DesktopPlatform standingPlatform; + + /// + /// 지금 서 있는 발판. 서 있지 않으면 null. + /// 발판 아래를 가리는 처리(PlatformOccluder)가 이 사각형을 쓴다. + /// + public DesktopPlatform? CurrentPlatform => hasStanding ? standingPlatform : (DesktopPlatform?)null; + + /// 카메라로부터 캐릭터까지의 깊이. 가림 사각형을 같은 평면 근처에 두는 데 쓴다. + public float CharacterDepth => depth; // 목표 float walkTargetX; @@ -134,6 +189,10 @@ private set Vector2 charOffMin, charOffMax; bool hasExtents; + // 로더가 정한 "정면" 자세. 걷는 방향 회전은 여기에 더해서 얹는다. + Quaternion baseRotation = Quaternion.identity; + float currentTurn; + void Awake() { if (scanner == null) scanner = FindFirstObjectByType(); @@ -141,6 +200,12 @@ void Awake() if (loader == null) loader = FindFirstObjectByType(); if (viewCamera == null) viewCamera = Camera.main; + // 저장된 값이 있으면 그걸 쓰고, 없으면 인스펙터 기본값을 그대로 둔다. + if (PlayerPrefs.HasKey(AutoClimbPrefKey)) + { + autoClimb = PlayerPrefs.GetInt(AutoClimbPrefKey, 1) != 0; + } + if (loader != null) loader.Loaded += OnCharacterLoaded; if (scanner != null) scanner.Rescanned += OnRescanned; if (dragger != null) dragger.DragEnded += OnDragEnded; @@ -164,6 +229,11 @@ void OnCharacterLoaded(ICharacterAvatar avatar) hasExtents = CharacterScreenBounds.TryMeasure(viewCamera, character, out charOffMin, out charOffMax); + + // 로더가 걸어둔 회전이 "정면"이다. 여기서 잡아둬야 걷기 회전을 얹었다 뺄 수 있다. + baseRotation = character.localRotation; + currentTurn = 0f; + EnterFalling(); } @@ -255,6 +325,7 @@ void Land(DesktopPlatform p) standingHwnd = p.Hwnd; offsetFromWindowLeft = screenPos.x - p.WindowLeft; + standingPlatform = p; hasStanding = true; State = ClimbState.Standing; @@ -383,6 +454,9 @@ void UpdateRiding() var m = match.Value; + // 창이 움직였으면 발판 사각형도 갱신한다. 가림 처리가 이 값을 본다. + standingPlatform = m; + // 창을 위로 계속 끌어올리면 캐릭터가 화면 밖으로 밀려난다. 그전에 뛰어내린다. if (WouldBeClipped(m.Y)) { @@ -465,5 +539,41 @@ void Apply() character.position = viewCamera.ScreenToWorldPoint( new Vector3(screenPos.x, screenPos.y, depth)); + + ApplyFacing(); + } + + /// + /// 걷거나 뛰는 방향으로 몸을 돌린다. + /// + /// 정면을 본 채 옆으로 미끄러지면 게처럼 보인다. 다만 완전히 옆(90도)으로 돌리면 + /// 얼굴이 안 보여서 데스크톱 펫으로서는 손해다. 기본값을 70도로 둬서 걷는 티는 나되 + /// 얼굴은 살짝 이쪽을 향하게 한다. 머리는 HeadLookAt 이 커서 쪽으로 되돌리므로 + /// 몸만 돌아가고 시선은 사용자를 따라오는 그림이 된다. + /// + /// 회전은 캐릭터 루트에 건다. 컨트롤 리그는 이미 만들어진 뒤이므로(로더 참고) + /// 자식으로서 통째로 같이 돈다. + /// + void ApplyFacing() + { + if (!turnToWalkDirection) return; + + float dir = 0f; + if (State == ClimbState.Walking) + { + dir = Mathf.Sign(walkTargetX - screenPos.x); + } + else if (State == ClimbState.Jumping) + { + dir = Mathf.Sign(ClampToPlatform(climbTarget) - climbStart.x); + } + + // 서 있거나 떨어지는 중이면 정면으로 되돌아온다. + float desired = Mathf.Abs(dir) > 0.01f + ? walkTurnAngle * dir * (invertTurnDirection ? 1f : -1f) + : 0f; + + currentTurn = Mathf.MoveTowards(currentTurn, desired, turnSpeed * Time.deltaTime); + character.localRotation = baseRotation * Quaternion.Euler(0f, currentTurn, 0f); } } diff --git a/Assets/02_Scripts/Chat/ChatUiBuilder.cs b/Assets/02_Scripts/Chat/ChatUiBuilder.cs index 5cd6842..ae2cb31 100644 --- a/Assets/02_Scripts/Chat/ChatUiBuilder.cs +++ b/Assets/02_Scripts/Chat/ChatUiBuilder.cs @@ -1,4 +1,6 @@ using UnityEngine; +using UnityEngine.EventSystems; +using UnityEngine.InputSystem.UI; using UnityEngine.UI; /// @@ -9,6 +11,37 @@ /// public static class ChatUiBuilder { + /// + /// 씬에 EventSystem 이 없으면 만든다. 런타임 UI 를 쓰는 쪽은 모두 이걸 먼저 부른다. + /// + /// Active Input Handling 이 신규 Input System 전용이므로 모듈은 + /// InputSystemUIInputModule 이어야 한다. StandaloneInputModule 은 레거시 + /// UnityEngine.Input 을 쓰기 때문에 이 설정에서 예외를 던진다. + /// + public static void EnsureEventSystem() + { + var existing = Object.FindFirstObjectByType(); + if (existing == null) + { + var go = new GameObject("EventSystem"); + existing = go.AddComponent(); + var module = go.AddComponent(); + // 런타임에 붙이면 액션 에셋이 비어 있어 아무 입력도 받지 못한다. + if (module.actionsAsset == null) module.AssignDefaultActions(); + } + + // InputField 가 IME 상태를 물을 때 레거시 Input 으로 새지 않게 막는다. + // BaseInputModule 은 BaseInput 을 "상속한" 컴포넌트를 기본값으로 고르지 않으므로 + // inputOverride 에 직접 넣어야 한다. + var baseModule = existing.GetComponent(); + if (baseModule != null && baseModule.inputOverride == null) + { + var safeInput = existing.GetComponent(); + if (safeInput == null) safeInput = existing.gameObject.AddComponent(); + baseModule.inputOverride = safeInput; + } + } + public static RectTransform NewRect(string name, Transform parent) { var go = new GameObject(name, typeof(RectTransform)); diff --git a/Assets/02_Scripts/Chat/ChatWindowUI.cs b/Assets/02_Scripts/Chat/ChatWindowUI.cs index c30ed57..4209d34 100644 --- a/Assets/02_Scripts/Chat/ChatWindowUI.cs +++ b/Assets/02_Scripts/Chat/ChatWindowUI.cs @@ -72,7 +72,7 @@ public class ChatWindowUI : MonoBehaviour void Awake() { - EnsureEventSystem(); + ChatUiBuilder.EnsureEventSystem(); Build(); panel.gameObject.SetActive(false); } @@ -336,37 +336,6 @@ IEnumerator ScrollToBottomNextFrame() // ------------------------------------------------------------------ 계층 만들기 - /// - /// 씬에 EventSystem 이 없으면 만든다. - /// - /// Active Input Handling 이 신규 Input System 전용이므로 모듈은 - /// InputSystemUIInputModule 이어야 한다. StandaloneInputModule 은 레거시 - /// UnityEngine.Input 을 쓰기 때문에 이 설정에서 예외를 던진다. - /// - static void EnsureEventSystem() - { - var existing = FindFirstObjectByType(); - if (existing == null) - { - var go = new GameObject("EventSystem"); - existing = go.AddComponent(); - var module = go.AddComponent(); - // 런타임에 붙이면 액션 에셋이 비어 있어 아무 입력도 받지 못한다. - if (module.actionsAsset == null) module.AssignDefaultActions(); - } - - // InputField 가 IME 상태를 물을 때 레거시 Input 으로 새지 않게 막는다. - // BaseInputModule 은 BaseInput 을 "상속한" 컴포넌트를 기본값으로 고르지 않으므로 - // inputOverride 에 직접 넣어야 한다. - var baseModule = existing.GetComponent(); - if (baseModule != null && baseModule.inputOverride == null) - { - var safeInput = existing.GetComponent(); - if (safeInput == null) safeInput = existing.gameObject.AddComponent(); - baseModule.inputOverride = safeInput; - } - } - void Build() { var canvasGo = new GameObject("ChatCanvas", typeof(Canvas), typeof(CanvasScaler), typeof(GraphicRaycaster)); diff --git a/Assets/02_Scripts/Desktop/ClickThroughHitTest.cs b/Assets/02_Scripts/Desktop/ClickThroughHitTest.cs index e00d395..4b7dc4b 100644 --- a/Assets/02_Scripts/Desktop/ClickThroughHitTest.cs +++ b/Assets/02_Scripts/Desktop/ClickThroughHitTest.cs @@ -46,7 +46,7 @@ public class ClickThroughHitTest : MonoBehaviour bool useNoActivate = true; [Header("검증용")] - [SerializeField] bool showDebugHud = true; + [SerializeField] bool showDebugHud = false; [Tooltip("커서가 캐릭터 위에 있을 때 미세하게 커지는 반응. 과하면 화면 제한 계산에도 영향을 준다")] [SerializeField] bool visualizeHover = true; diff --git a/Assets/02_Scripts/Desktop/FrameRateLimiter.cs b/Assets/02_Scripts/Desktop/FrameRateLimiter.cs new file mode 100644 index 0000000..1ce36f9 --- /dev/null +++ b/Assets/02_Scripts/Desktop/FrameRateLimiter.cs @@ -0,0 +1,86 @@ +using UnityEngine; + +/// +/// 프레임레이트를 제한한다. 상시 구동 앱에서는 이게 없으면 안 된다. +/// +/// 기본 상태의 Unity 는 vSync 가 꺼져 있고 targetFrameRate 가 -1 이라 +/// "낼 수 있는 만큼" 렌더링한다. 거의 빈 씬이면 수백~수천 FPS 가 나오고, +/// 그동안 CPU 코어 하나와 GPU 를 계속 물고 있는다. 게임이라면 상관없지만 +/// 이 앱은 사용자가 다른 일을 하는 내내 떠 있으므로 그대로 두면 안 된다. +/// runInBackground = true 라 창이 뒤로 가도 멈추지 않는다는 점이 더 나쁘다. +/// +/// 한 걸음 더: 아무 일도 없을 때는 더 낮춘다. 캐릭터가 발판에 가만히 서 있고 +/// 커서도 근처에 없다면 초당 몇 장만 그려도 눈에 띄지 않는다. 사용자가 손을 +/// 가져가거나 캐릭터가 움직이기 시작하면 즉시 올린다. +/// +public class FrameRateLimiter : MonoBehaviour +{ + [Header("프레임 제한")] + [Tooltip("사용자와 상호작용 중이거나 캐릭터가 움직일 때의 상한")] + [Range(15, 144)] + [SerializeField] int activeFrameRate = 60; + + [Tooltip("아무 일도 없을 때의 상한. 낮출수록 배터리와 발열에 유리하다")] + [Range(5, 60)] + [SerializeField] int idleFrameRate = 15; + + [Tooltip("상호작용이 끝난 뒤 이만큼은 높은 프레임을 유지한다(초). " + + "값이 0 이면 커서를 뗄 때마다 뚝뚝 끊겨 보인다")] + [SerializeField] float activeLinger = 1.5f; + + [Tooltip("끄면 항상 activeFrameRate 로 고정한다")] + [SerializeField] bool dropWhenIdle = true; + + [Header("참조 (비우면 씬에서 탐색)")] + [SerializeField] ClickThroughHitTest hitTest; + [SerializeField] CharacterDragger dragger; + [SerializeField] WindowClimber climber; + [SerializeField] ChatController chat; + + float activeUntil; + int applied = -1; + + void Awake() + { + if (hitTest == null) hitTest = FindFirstObjectByType(); + if (dragger == null) dragger = FindFirstObjectByType(); + if (climber == null) climber = FindFirstObjectByType(); + if (chat == null) chat = FindFirstObjectByType(); + + // targetFrameRate 는 vSync 가 꺼져 있어야 듣는다. 품질 레벨이 바뀌면 + // vSync 가 되살아날 수 있으므로 여기서 못박는다. + QualitySettings.vSyncCount = 0; + } + + void Update() + { + if (IsBusy()) activeUntil = Time.unscaledTime + Mathf.Max(0f, activeLinger); + + bool active = !dropWhenIdle || Time.unscaledTime < activeUntil; + Apply(active ? activeFrameRate : idleFrameRate); + } + + /// 지금 부드러워야 하는 상황인지. + bool IsBusy() + { + // 커서가 캐릭터나 채팅창 위에 있다 — 곧 뭔가 할 참이다. + if (hitTest != null && (hitTest.IsOverCharacter || hitTest.IsOverUi)) return true; + + if (dragger != null && dragger.IsDragging) return true; + + // 채팅창이 열려 있으면 글자가 흘러나오는 중일 수 있다. + if (chat != null && chat.IsOpen) return true; + + // 캐릭터가 제자리에 서 있지 않다면 움직이는 중이다. + if (climber != null && climber.State != ClimbState.Standing) return true; + + return false; + } + + void Apply(int frameRate) + { + if (applied == frameRate) return; + applied = frameRate; + Application.targetFrameRate = frameRate; + } +} diff --git a/Assets/02_Scripts/Desktop/FrameRateLimiter.cs.meta b/Assets/02_Scripts/Desktop/FrameRateLimiter.cs.meta new file mode 100644 index 0000000..b12af5b --- /dev/null +++ b/Assets/02_Scripts/Desktop/FrameRateLimiter.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: dae22a3bc9e51854088f14115dc44aec \ No newline at end of file diff --git a/Assets/02_Scripts/Desktop/PlatformDebugOverlay.cs b/Assets/02_Scripts/Desktop/PlatformDebugOverlay.cs index 746ed13..c11d545 100644 --- a/Assets/02_Scripts/Desktop/PlatformDebugOverlay.cs +++ b/Assets/02_Scripts/Desktop/PlatformDebugOverlay.cs @@ -10,13 +10,13 @@ [RequireComponent(typeof(DesktopPlatformScanner))] public class PlatformDebugOverlay : MonoBehaviour { - [SerializeField] bool show = true; + [SerializeField] bool show = false; [SerializeField] float lineThickness = 3f; [SerializeField] Color windowColor = new Color(0.2f, 0.9f, 1f, 0.85f); [SerializeField] Color floorColor = new Color(1f, 0.8f, 0.2f, 0.85f); [Tooltip("각 발판에 창 제목을 함께 표시")] - [SerializeField] bool showTitles = true; + [SerializeField] bool showTitles = false; DesktopPlatformScanner scanner; Texture2D pixel; diff --git a/Assets/02_Scripts/Desktop/TrayIcon.cs b/Assets/02_Scripts/Desktop/TrayIcon.cs index cf22516..fd43808 100644 --- a/Assets/02_Scripts/Desktop/TrayIcon.cs +++ b/Assets/02_Scripts/Desktop/TrayIcon.cs @@ -38,6 +38,7 @@ void Awake() const int ID_TOGGLE = 1; const int ID_EXIT = 2; + const int ID_AUTOCLIMB = 3; delegate IntPtr WndProcDelegate(IntPtr hWnd, uint msg, IntPtr wParam, IntPtr lParam); @@ -191,6 +192,15 @@ void ShowContextMenu() if (menu == IntPtr.Zero) return; AppendMenuW(menu, MF_STRING, ID_TOGGLE, characterVisible ? "캐릭터 숨기기" : "캐릭터 보이기"); + + // 자동 올라타기는 켜고 끄는 항목이라 체크 표시로 현재 상태를 보여준다. + var climber = FindFirstObjectByType(); + if (climber != null) + { + uint flags = MF_STRING | (climber.AutoClimb ? MF_CHECKED : 0); + AppendMenuW(menu, flags, ID_AUTOCLIMB, "자동으로 창에 올라가기"); + } + AppendMenuW(menu, MF_SEPARATOR, 0, null); AppendMenuW(menu, MF_STRING, ID_EXIT, "종료"); @@ -216,6 +226,15 @@ void ShowContextMenu() { ToggleCharacter(); } + else if (cmd == ID_AUTOCLIMB) + { + var target = FindFirstObjectByType(); + if (target != null) + { + target.AutoClimb = !target.AutoClimb; + Debug.Log($"[TrayIcon] 자동으로 창에 올라가기 = {target.AutoClimb}"); + } + } } void ToggleCharacter() @@ -288,6 +307,7 @@ void Cleanup() const uint MF_STRING = 0x0000; const uint MF_SEPARATOR = 0x0800; + const uint MF_CHECKED = 0x0008; // 항목 앞에 체크 표시 const uint TPM_RIGHTBUTTON = 0x0002; const uint TPM_RETURNCMD = 0x0100; diff --git a/Assets/02_Scripts/Desktop/Win32.cs b/Assets/02_Scripts/Desktop/Win32.cs index 1d6a855..40b4447 100644 --- a/Assets/02_Scripts/Desktop/Win32.cs +++ b/Assets/02_Scripts/Desktop/Win32.cs @@ -47,6 +47,7 @@ internal static class Win32 public const int SW_SHOW = 5; public const int VK_LBUTTON = 0x01; + public const int VK_RBUTTON = 0x02; public const int VK_ESCAPE = 0x1B; public const int VK_SHIFT = 0x10; public const int VK_CONTROL = 0x11; diff --git a/Assets/02_Scripts/Editor/CharacterMotionSetupMenu.cs b/Assets/02_Scripts/Editor/CharacterMotionSetupMenu.cs index e10c861..cfc5283 100644 --- a/Assets/02_Scripts/Editor/CharacterMotionSetupMenu.cs +++ b/Assets/02_Scripts/Editor/CharacterMotionSetupMenu.cs @@ -13,22 +13,49 @@ public static class CharacterMotionSetupMenu { const string ObjectName = "CharacterMotion"; const string ControllerPath = "Assets/99_Settings/CharacterMotion.controller"; + const string OccluderShaderPath = "Assets/03_Shaders/PlatformOccluder.shader"; [MenuItem("Tools/Desktop Overlay/10. Add Character Motion To Scene")] public static void AddMotionDirector() { + // 이미 있어도 그냥 돌아가지 않는다. 나중에 추가된 컴포넌트(가림, 포즈 메뉴)가 + // 빠져 있을 수 있으므로, 있는 것은 두고 없는 것만 채워 넣는다. var existing = Object.FindFirstObjectByType(); + GameObject go; + if (existing != null) { - Selection.activeGameObject = existing.gameObject; - EditorGUIUtility.PingObject(existing.gameObject); - Debug.Log($"[CharacterMotionSetupMenu] 이미 있습니다: {existing.gameObject.name}"); - return; + go = existing.gameObject; + } + else + { + go = new GameObject(ObjectName); + Undo.RegisterCreatedObjectUndo(go, "Add Character Motion"); + go.AddComponent(); } - var go = new GameObject(ObjectName); - Undo.RegisterCreatedObjectUndo(go, "Add Character Motion"); - go.AddComponent(); + // 발판 뒤로 숨기기용 사각형. 셰이더는 직접 참조해야 빌드에서 빠지지 않는다. + if (Object.FindFirstObjectByType() == null) + { + var occluder = Undo.AddComponent(go); + var shader = AssetDatabase.LoadAssetAtPath(OccluderShaderPath); + if (shader != null) + { + var so = new SerializedObject(occluder); + so.FindProperty("occluderShader").objectReferenceValue = shader; + so.ApplyModifiedProperties(); + } + else + { + Debug.LogWarning($"[CharacterMotionSetupMenu] 셰이더를 찾지 못했습니다: {OccluderShaderPath}"); + } + } + + // 우클릭 포즈 목록. + if (Object.FindFirstObjectByType() == null) + { + Undo.AddComponent(go); + } Selection.activeGameObject = go; EditorSceneManager.MarkSceneDirty(go.scene); @@ -39,6 +66,41 @@ public static void AddMotionDirector() "메뉴 11 로 빈 컨트롤러를 만들고 클립을 채워 넣으세요."); } + [MenuItem("Tools/Desktop Overlay/12. Add Frame Rate Limiter To Scene")] + public static void AddFrameRateLimiter() + { + var existing = Object.FindFirstObjectByType(); + if (existing != null) + { + Selection.activeGameObject = existing.gameObject; + EditorGUIUtility.PingObject(existing.gameObject); + Debug.Log($"[CharacterMotionSetupMenu] 이미 있습니다: {existing.gameObject.name}"); + return; + } + + // 카메라(TransparentWindow) 옆에 두는 게 자연스럽다. 없으면 새 오브젝트. + var window = Object.FindFirstObjectByType(); + GameObject host; + if (window != null) + { + host = window.gameObject; + Undo.AddComponent(host); + } + else + { + host = new GameObject("FrameRateLimiter"); + Undo.RegisterCreatedObjectUndo(host, "Add Frame Rate Limiter"); + host.AddComponent(); + } + + Selection.activeGameObject = host; + EditorSceneManager.MarkSceneDirty(host.scene); + + Debug.Log( + "[CharacterMotionSetupMenu] FrameRateLimiter 를 추가했습니다. 씬을 저장(Ctrl+S)하세요.\n" + + "이게 없으면 상시 구동 중에 낼 수 있는 최대 FPS 로 계속 렌더링합니다."); + } + [MenuItem("Tools/Desktop Overlay/11. Create Motion Controller")] public static void CreateController() { diff --git a/Assets/03_Shaders.meta b/Assets/03_Shaders.meta new file mode 100644 index 0000000..447f35d --- /dev/null +++ b/Assets/03_Shaders.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 2676fa1457091e545902dbb7008ed460 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/03_Shaders/PlatformOccluder.shader b/Assets/03_Shaders/PlatformOccluder.shader new file mode 100644 index 0000000..6a8a645 --- /dev/null +++ b/Assets/03_Shaders/PlatformOccluder.shader @@ -0,0 +1,61 @@ +// 색을 하나도 쓰지 않고 깊이만 기록하는 사각형. +// +// 캐릭터보다 "먼저"(Queue 를 앞당겨) 그리고 캐릭터보다 카메라에 "가깝게" 두면, +// 이 사각형이 덮은 픽셀에서 캐릭터가 깊이 테스트에 탈락해 아예 그려지지 않는다. +// 그리면서 색을 안 쓰므로(ColorMask 0) 창의 알파도 건드리지 않는다 — +// 그 자리는 투명한 채로 남고, 실제 데스크톱 창이 그대로 비친다. +// 결과적으로 캐릭터가 창 "뒤"에 있는 것처럼 보인다. +// +// 알파를 0 으로 덮어쓰는 방법도 있지만, 그건 그 자리에 그려진 다른 것까지 같이 +// 지운다. 깊이 마스크는 캐릭터만 가리므로 부작용이 없다. +Shader "MyCharacterAgent/PlatformOccluder" +{ + SubShader + { + // Geometry(2000) 보다 앞이라 캐릭터보다 먼저 그려진다. + Tags + { + "RenderType" = "Opaque" + "Queue" = "Geometry-100" + "RenderPipeline" = "UniversalPipeline" + } + + Pass + { + ColorMask 0 // 색 버퍼에 아무것도 쓰지 않는다 + ZWrite On // 깊이만 남긴다 + ZTest LEqual + Cull Off // 사각형이 뒤집혀도 동작하게 + + HLSLPROGRAM + #pragma vertex vert + #pragma fragment frag + #include "Packages/com.unity.render-pipelines.universal/ShaderLibrary/Core.hlsl" + + struct Attributes + { + float4 positionOS : POSITION; + }; + + struct Varyings + { + float4 positionCS : SV_POSITION; + }; + + Varyings vert(Attributes input) + { + Varyings output; + output.positionCS = TransformObjectToHClip(input.positionOS.xyz); + return output; + } + + half4 frag(Varyings input) : SV_Target + { + return 0; // ColorMask 0 이라 어차피 버려진다 + } + ENDHLSL + } + } + + Fallback Off +} diff --git a/Assets/03_Shaders/PlatformOccluder.shader.meta b/Assets/03_Shaders/PlatformOccluder.shader.meta new file mode 100644 index 0000000..99f4c92 --- /dev/null +++ b/Assets/03_Shaders/PlatformOccluder.shader.meta @@ -0,0 +1,9 @@ +fileFormatVersion: 2 +guid: bec1d474e5384404eab44f2400f6d7b8 +ShaderImporter: + externalObjects: {} + defaultTextures: [] + nonModifiableTextures: [] + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/04_Animations/Fall.anim b/Assets/04_Animations/Fall.anim index e821839..1b12036 100644 --- a/Assets/04_Animations/Fall.anim +++ b/Assets/04_Animations/Fall.anim @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:0cceec9efb26a9b5ba5889f54d2f3ca8f96154822b97de2a6f4bcceac4e3ceef -size 591910 +oid sha256:8a5de01c2b08ab7b4bf5c7bbc14b118d1c444f585cb58c1f8739b6a9925326e7 +size 620152 diff --git a/Assets/04_Animations/FlySitIdle.anim b/Assets/04_Animations/FlySitIdle.anim index 1a90526..f8715af 100644 --- a/Assets/04_Animations/FlySitIdle.anim +++ b/Assets/04_Animations/FlySitIdle.anim @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:4f13303d6378e5a6a8e84faee717949e59592ef6aee2c2126053ceba6e27617a -size 2257456 +oid sha256:e13ed7ab10f1548289b0f8fe5ce733d9c6a18b8be491d8eb1d5b326a845d52b9 +size 2287306 diff --git a/Assets/04_Animations/Origin/Fall.FBX.meta b/Assets/04_Animations/Origin/Fall.FBX.meta index bc7843d..3517e74 100644 --- a/Assets/04_Animations/Origin/Fall.FBX.meta +++ b/Assets/04_Animations/Origin/Fall.FBX.meta @@ -43,7 +43,7 @@ ModelImporter: cycleOffset: 0 loop: 0 hasAdditiveReferencePose: 0 - loopTime: 0 + loopTime: 1 loopBlend: 0 loopBlendOrientation: 0 loopBlendPositionY: 0 diff --git a/Assets/04_Animations/Origin/FlySitIdle.FBX.meta b/Assets/04_Animations/Origin/FlySitIdle.FBX.meta index a31bb68..172d856 100644 --- a/Assets/04_Animations/Origin/FlySitIdle.FBX.meta +++ b/Assets/04_Animations/Origin/FlySitIdle.FBX.meta @@ -43,7 +43,7 @@ ModelImporter: cycleOffset: 0 loop: 0 hasAdditiveReferencePose: 0 - loopTime: 0 + loopTime: 1 loopBlend: 0 loopBlendOrientation: 1 loopBlendPositionY: 1 diff --git a/Assets/04_Animations/Origin/Walk.FBX.meta b/Assets/04_Animations/Origin/Walk.FBX.meta index f3f68e7..e966081 100644 --- a/Assets/04_Animations/Origin/Walk.FBX.meta +++ b/Assets/04_Animations/Origin/Walk.FBX.meta @@ -43,7 +43,7 @@ ModelImporter: cycleOffset: 0 loop: 0 hasAdditiveReferencePose: 0 - loopTime: 0 + loopTime: 1 loopBlend: 0 loopBlendOrientation: 1 loopBlendPositionY: 1 diff --git a/Assets/04_Animations/Walk.anim b/Assets/04_Animations/Walk.anim index 5d8e931..bcaa83f 100644 --- a/Assets/04_Animations/Walk.anim +++ b/Assets/04_Animations/Walk.anim @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:d76d054a8bdf34abc5d6f7764955edfcbf16ddbf638f4b2c8fd752199c4c6137 -size 1537871 +oid sha256:c3891fa538cd118e9273f9f8cc3ea4fc7c2033f6124c8f2abac19016375675d3 +size 1568123