오류수정
This commit is contained in:
BIN
Assets/01_Scenes/MainScene.unity
LFS
BIN
Assets/01_Scenes/MainScene.unity
LFS
Binary file not shown.
@@ -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
|
||||
/// </summary>
|
||||
public event System.Action Clicked;
|
||||
|
||||
/// <summary>
|
||||
/// 캐릭터 위에서 오른쪽 버튼을 누른 순간. 포즈 바꾸기 등에 쓴다.
|
||||
///
|
||||
/// 왼쪽 버튼과 같은 방식으로 OS 에 직접 묻는다. 이 창은 포커스를 받지 않아
|
||||
/// Unity 입력을 신뢰하기 어렵고, 무엇보다 판정에 쓰는 커서 좌표를 이미
|
||||
/// GetCursorPos 로 얻고 있어 두 경로를 섞을 이유가 없다.
|
||||
/// </summary>
|
||||
public event System.Action RightClicked;
|
||||
|
||||
void Awake()
|
||||
{
|
||||
hitTest = GetComponent<ClickThroughHitTest>();
|
||||
@@ -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;
|
||||
|
||||
// 눌린 "순간"만 잡는다. 이미 누른 채로 커서가 캐릭터 위로 지나가는 경우
|
||||
// (다른 창을 끌고 오다가 캐릭터를 스치는 등)에 잡히면 안 된다.
|
||||
// 클릭으로 채팅창이 토글되면서부터는 이 오작동이 눈에 띄게 된다.
|
||||
|
||||
@@ -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<CharacterMotionKind, string> BuildNames() => new Dictionary<CharacterMotionKind, string>
|
||||
{
|
||||
@@ -76,6 +81,27 @@ void Awake()
|
||||
{
|
||||
if (loader == null) loader = FindFirstObjectByType<VrmCharacterLoader>();
|
||||
if (climber == null) climber = FindFirstObjectByType<WindowClimber>();
|
||||
if (dragger == null) dragger = FindFirstObjectByType<CharacterDragger>();
|
||||
if (occluder == null) occluder = FindFirstObjectByType<PlatformOccluder>();
|
||||
if (poseMenu == null) poseMenu = FindFirstObjectByType<PoseMenuUI>();
|
||||
|
||||
WarnIfOccluderMissing();
|
||||
}
|
||||
|
||||
/// <summary>가림을 켜둔 포즈가 있는데 가림 컴포넌트가 없으면, 조용히 안 되는 대신 알린다.</summary>
|
||||
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()
|
||||
/// <summary>
|
||||
/// 캐릭터를 우클릭하면 포즈 목록을 띄운다.
|
||||
///
|
||||
/// 서 있을 때만 받는다. 걷거나 떨어지는 중에 눕힐 수는 없다.
|
||||
/// </summary>
|
||||
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<TransparentWindow>();
|
||||
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()
|
||||
/// <summary>
|
||||
/// 이동 상태에 맞는 몸짓으로 간다. 서 있는 상태만 "포즈"로 취급해
|
||||
/// 목록의 첫 항목(가림 설정 포함)을 쓰고, 나머지는 그냥 이동 몸짓이다.
|
||||
/// </summary>
|
||||
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));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 고른 포즈를 재생하고, 그 포즈의 가림 설정도 함께 반영한다.
|
||||
/// 포즈를 바꾸는 곳이 여러 군데라 한 곳으로 모은다 — 따로 두면 가림만 남는 사고가 난다.
|
||||
/// </summary>
|
||||
void PlayPose(PoseOption pose)
|
||||
{
|
||||
motion?.Play(pose.kind);
|
||||
if (occluder != null) occluder.Active = pose.hideBehindPlatform;
|
||||
}
|
||||
|
||||
/// <summary>이동 중의 몸짓. 걷거나 떨어지는 동안에는 가리지 않는다.</summary>
|
||||
void PlayMotion(CharacterMotionKind kind)
|
||||
{
|
||||
motion?.Play(kind);
|
||||
if (occluder != null) occluder.Active = false;
|
||||
}
|
||||
|
||||
/// <summary>발판에 섰을 때의 기본 자세. 목록의 첫 항목이다.</summary>
|
||||
void PlayDefaultPose()
|
||||
{
|
||||
if (poses != null && poses.Length > 0) PlayPose(poses[0]);
|
||||
else PlayMotion(CharacterMotionKind.Idle);
|
||||
}
|
||||
|
||||
static CharacterMotionKind ToMotionKind(ClimbState state)
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
using UnityEngine;
|
||||
|
||||
/// <summary>
|
||||
/// 캐릭터가 지을 수 있는 몸짓.
|
||||
///
|
||||
@@ -15,6 +17,28 @@ public enum CharacterMotionKind
|
||||
LieDown, // 눕기
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 우클릭 메뉴에 나올 포즈 하나. 표시 이름과 가림 여부를 함께 들고 다닌다.
|
||||
///
|
||||
/// 예전에는 "메뉴에 넣을 포즈 목록"과 "가려야 할 포즈 목록"을 따로 뒀는데,
|
||||
/// 둘을 손으로 맞춰야 했고 어긋나면(가림 목록에만 있고 메뉴 목록엔 없으면)
|
||||
/// 그 포즈에 도달할 방법이 없어 조용히 아무 일도 안 일어났다.
|
||||
/// 한 곳에 묶으면 그 상태 자체가 만들어질 수 없다.
|
||||
/// </summary>
|
||||
[System.Serializable]
|
||||
public struct PoseOption
|
||||
{
|
||||
[Tooltip("어떤 몸짓인지")]
|
||||
public CharacterMotionKind kind;
|
||||
|
||||
[Tooltip("메뉴에 표시할 이름. 비우면 기본 이름을 쓴다")]
|
||||
public string label;
|
||||
|
||||
[Tooltip("켜면 이 포즈일 때 발판 아래를 가린다 — 캐릭터가 창 뒤에 있는 것처럼 보인다. " +
|
||||
"예: 창 밑에서 머리만 내미는 포즈")]
|
||||
public bool hideBehindPlatform;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 캐릭터 몸짓을 재생하는 계층의 창구.
|
||||
///
|
||||
|
||||
136
Assets/02_Scripts/Character/PlatformOccluder.cs
Normal file
136
Assets/02_Scripts/Character/PlatformOccluder.cs
Normal file
@@ -0,0 +1,136 @@
|
||||
using UnityEngine;
|
||||
|
||||
/// <summary>
|
||||
/// 발판 아래에 깊이만 기록하는 사각형을 깔아 캐릭터를 가린다.
|
||||
/// "발판(=실제 창) 뒤에 서 있는" 그림을 만들 때 쓴다. 예: 창 밑에서 머리만 내미는 포즈.
|
||||
///
|
||||
/// 왜 렌더 순서로 해결되지 않는가:
|
||||
/// 발판은 Unity 오브젝트가 아니라 진짜 데스크톱 창이고, 우리 창은 그 위에 떠 있는
|
||||
/// 최상위 오버레이다. 그래서 "창 뒤에 그린다"가 물리적으로 불가능하다.
|
||||
/// 대신 그 영역에서 캐릭터를 아예 그리지 않으면, 우리 창은 그 자리에 투명한 채로
|
||||
/// 남고 진짜 창이 비쳐 보인다. 결과가 같다.
|
||||
///
|
||||
/// 방법은 깊이 마스크다. 색을 안 쓰고(ColorMask 0) 깊이만 쓰는 사각형을
|
||||
/// 캐릭터보다 카메라 가까이, 캐릭터보다 먼저(Queue Geometry-100) 그리면
|
||||
/// 그 뒤의 캐릭터 픽셀이 깊이 테스트에서 탈락한다. 알파를 0 으로 덮는 방법과 달리
|
||||
/// 같은 자리에 그려진 다른 것(채팅창 등)을 건드리지 않는다.
|
||||
/// </summary>
|
||||
[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;
|
||||
|
||||
/// <summary>지금 가려야 하는지. CharacterMotionDirector 가 포즈에 따라 켜고 끈다.</summary>
|
||||
public bool Active { get; set; }
|
||||
|
||||
MeshFilter meshFilter;
|
||||
MeshRenderer meshRenderer;
|
||||
Mesh mesh;
|
||||
readonly Vector3[] corners = new Vector3[4];
|
||||
|
||||
void Awake()
|
||||
{
|
||||
if (climber == null) climber = FindFirstObjectByType<WindowClimber>();
|
||||
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);
|
||||
}
|
||||
|
||||
/// <summary>발판의 X 범위 × (발판선 아래 ~ 화면 아래)를 덮는 사각형으로 갱신한다.</summary>
|
||||
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<MeshFilter>();
|
||||
if (meshFilter == null) meshFilter = gameObject.AddComponent<MeshFilter>();
|
||||
meshFilter.sharedMesh = mesh;
|
||||
|
||||
meshRenderer = gameObject.GetComponent<MeshRenderer>();
|
||||
if (meshRenderer == null) meshRenderer = gameObject.AddComponent<MeshRenderer>();
|
||||
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;
|
||||
}
|
||||
}
|
||||
2
Assets/02_Scripts/Character/PlatformOccluder.cs.meta
Normal file
2
Assets/02_Scripts/Character/PlatformOccluder.cs.meta
Normal file
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: b3a860401aad71342b84131203d2e1d5
|
||||
222
Assets/02_Scripts/Character/PoseMenuUI.cs
Normal file
222
Assets/02_Scripts/Character/PoseMenuUI.cs
Normal file
@@ -0,0 +1,222 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using UnityEngine;
|
||||
using UnityEngine.UI;
|
||||
|
||||
/// <summary>
|
||||
/// 캐릭터를 우클릭하면 뜨는 포즈 목록. 항목을 누르면 그 포즈가 된다.
|
||||
///
|
||||
/// 이전에는 우클릭할 때마다 다음 포즈로 넘어갔는데, 그러면 어떤 포즈가 있는지
|
||||
/// 보이지 않고 원하는 것까지 여러 번 눌러야 한다. 무엇보다 목록에 없는 포즈는
|
||||
/// 도달할 방법이 아예 없어서(예: 가림 포즈만 따로 설정한 경우) 조용히 안 되는
|
||||
/// 상황이 생긴다. 목록을 눈에 보이게 하면 그 문제가 사라진다.
|
||||
///
|
||||
/// 채팅창과 같은 방식으로 만든다 — 런타임 uGUI, 클릭 통과 히트테스트에 사각형 등록.
|
||||
/// 등록하지 않으면 클릭이 뒤 창으로 새어나가 버튼이 눌리지 않는다.
|
||||
///
|
||||
/// 키보드 포커스는 필요 없다(마우스만 쓴다). 그래서 채팅창과 달리
|
||||
/// WS_EX_NOACTIVATE 를 건드리지 않는다.
|
||||
/// </summary>
|
||||
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<PoseOption> onPick;
|
||||
Func<Vector2, bool> hitRegion;
|
||||
bool prevLeftDown;
|
||||
|
||||
public bool IsOpen => panel != null && panel.gameObject.activeSelf;
|
||||
|
||||
void Awake()
|
||||
{
|
||||
if (hitTest == null) hitTest = FindFirstObjectByType<ClickThroughHitTest>();
|
||||
|
||||
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<TransparentWindow>() : null;
|
||||
return window != null ? window.Hwnd : IntPtr.Zero;
|
||||
}
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ 조작
|
||||
|
||||
/// <summary>커서 위치에 목록을 띄운다. 이미 열려 있으면 다시 그린다.</summary>
|
||||
public void Show(Vector2 screenPos, IReadOnlyList<PoseOption> poses,
|
||||
Action<PoseOption> 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>();
|
||||
canvas.renderMode = RenderMode.ScreenSpaceOverlay;
|
||||
canvas.sortingOrder = 210; // 채팅창(200) 위에
|
||||
|
||||
var scaler = canvasGo.GetComponent<CanvasScaler>();
|
||||
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<Image>();
|
||||
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<PoseOption> 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);
|
||||
}
|
||||
|
||||
/// <summary>목록에 보여줄 이름. 열거형 이름을 그대로 쓰면 사용자에게 불친절하다.</summary>
|
||||
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();
|
||||
}
|
||||
}
|
||||
}
|
||||
2
Assets/02_Scripts/Character/PoseMenuUI.cs.meta
Normal file
2
Assets/02_Scripts/Character/PoseMenuUI.cs.meta
Normal file
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: c7e8cef4b39adce44b6dc1df6a6f4908
|
||||
@@ -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
|
||||
/// <summary>이동 상태가 바뀔 때. 애니메이션 전환이 이걸 듣는다.</summary>
|
||||
public event System.Action<ClimbState> StateChanged;
|
||||
|
||||
const string AutoClimbPrefKey = "MyCharacterAgent.AutoClimb";
|
||||
|
||||
/// <summary>
|
||||
/// 스스로 창을 찾아 올라갈지. 런타임에 바꿀 수 있고 다음 실행에도 유지된다.
|
||||
///
|
||||
/// 저장은 PlayerPrefs 로 한다. 모델 경로와 같은 방식이고, 채팅 설정 파일에
|
||||
/// 캐릭터 동작을 섞지 않으려는 이유도 있다.
|
||||
///
|
||||
/// 끄더라도 진행 중인 이동은 끝까지 간다. 공중에서 갑자기 멈추면 더 어색하다.
|
||||
/// </summary>
|
||||
public bool AutoClimb
|
||||
{
|
||||
get => autoClimb;
|
||||
set
|
||||
{
|
||||
if (autoClimb == value) return;
|
||||
autoClimb = value;
|
||||
|
||||
PlayerPrefs.SetInt(AutoClimbPrefKey, value ? 1 : 0);
|
||||
PlayerPrefs.Save();
|
||||
|
||||
AutoClimbChanged?.Invoke(value);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>자동 올라타기 설정이 바뀔 때. 트레이 메뉴 표시 갱신 등에 쓴다.</summary>
|
||||
public event System.Action<bool> AutoClimbChanged;
|
||||
|
||||
Transform character;
|
||||
float depth; // 카메라로부터의 깊이. 드래그와 같은 평면을 유지한다.
|
||||
Vector2 screenPos; // 캐릭터 발밑의 화면 좌표
|
||||
@@ -120,6 +165,16 @@ private set
|
||||
System.IntPtr standingHwnd;
|
||||
float offsetFromWindowLeft;
|
||||
bool hasStanding;
|
||||
DesktopPlatform standingPlatform;
|
||||
|
||||
/// <summary>
|
||||
/// 지금 서 있는 발판. 서 있지 않으면 null.
|
||||
/// 발판 아래를 가리는 처리(PlatformOccluder)가 이 사각형을 쓴다.
|
||||
/// </summary>
|
||||
public DesktopPlatform? CurrentPlatform => hasStanding ? standingPlatform : (DesktopPlatform?)null;
|
||||
|
||||
/// <summary>카메라로부터 캐릭터까지의 깊이. 가림 사각형을 같은 평면 근처에 두는 데 쓴다.</summary>
|
||||
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<DesktopPlatformScanner>();
|
||||
@@ -141,6 +200,12 @@ void Awake()
|
||||
if (loader == null) loader = FindFirstObjectByType<VrmCharacterLoader>();
|
||||
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();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 걷거나 뛰는 방향으로 몸을 돌린다.
|
||||
///
|
||||
/// 정면을 본 채 옆으로 미끄러지면 게처럼 보인다. 다만 완전히 옆(90도)으로 돌리면
|
||||
/// 얼굴이 안 보여서 데스크톱 펫으로서는 손해다. 기본값을 70도로 둬서 걷는 티는 나되
|
||||
/// 얼굴은 살짝 이쪽을 향하게 한다. 머리는 HeadLookAt 이 커서 쪽으로 되돌리므로
|
||||
/// 몸만 돌아가고 시선은 사용자를 따라오는 그림이 된다.
|
||||
///
|
||||
/// 회전은 캐릭터 루트에 건다. 컨트롤 리그는 이미 만들어진 뒤이므로(로더 참고)
|
||||
/// 자식으로서 통째로 같이 돈다.
|
||||
/// </summary>
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
using UnityEngine;
|
||||
using UnityEngine.EventSystems;
|
||||
using UnityEngine.InputSystem.UI;
|
||||
using UnityEngine.UI;
|
||||
|
||||
/// <summary>
|
||||
@@ -9,6 +11,37 @@
|
||||
/// </summary>
|
||||
public static class ChatUiBuilder
|
||||
{
|
||||
/// <summary>
|
||||
/// 씬에 EventSystem 이 없으면 만든다. 런타임 UI 를 쓰는 쪽은 모두 이걸 먼저 부른다.
|
||||
///
|
||||
/// Active Input Handling 이 신규 Input System 전용이므로 모듈은
|
||||
/// InputSystemUIInputModule 이어야 한다. StandaloneInputModule 은 레거시
|
||||
/// UnityEngine.Input 을 쓰기 때문에 이 설정에서 예외를 던진다.
|
||||
/// </summary>
|
||||
public static void EnsureEventSystem()
|
||||
{
|
||||
var existing = Object.FindFirstObjectByType<EventSystem>();
|
||||
if (existing == null)
|
||||
{
|
||||
var go = new GameObject("EventSystem");
|
||||
existing = go.AddComponent<EventSystem>();
|
||||
var module = go.AddComponent<InputSystemUIInputModule>();
|
||||
// 런타임에 붙이면 액션 에셋이 비어 있어 아무 입력도 받지 못한다.
|
||||
if (module.actionsAsset == null) module.AssignDefaultActions();
|
||||
}
|
||||
|
||||
// InputField 가 IME 상태를 물을 때 레거시 Input 으로 새지 않게 막는다.
|
||||
// BaseInputModule 은 BaseInput 을 "상속한" 컴포넌트를 기본값으로 고르지 않으므로
|
||||
// inputOverride 에 직접 넣어야 한다.
|
||||
var baseModule = existing.GetComponent<BaseInputModule>();
|
||||
if (baseModule != null && baseModule.inputOverride == null)
|
||||
{
|
||||
var safeInput = existing.GetComponent<SafeBaseInput>();
|
||||
if (safeInput == null) safeInput = existing.gameObject.AddComponent<SafeBaseInput>();
|
||||
baseModule.inputOverride = safeInput;
|
||||
}
|
||||
}
|
||||
|
||||
public static RectTransform NewRect(string name, Transform parent)
|
||||
{
|
||||
var go = new GameObject(name, typeof(RectTransform));
|
||||
|
||||
@@ -72,7 +72,7 @@ public class ChatWindowUI : MonoBehaviour
|
||||
|
||||
void Awake()
|
||||
{
|
||||
EnsureEventSystem();
|
||||
ChatUiBuilder.EnsureEventSystem();
|
||||
Build();
|
||||
panel.gameObject.SetActive(false);
|
||||
}
|
||||
@@ -336,37 +336,6 @@ IEnumerator ScrollToBottomNextFrame()
|
||||
|
||||
// ------------------------------------------------------------------ 계층 만들기
|
||||
|
||||
/// <summary>
|
||||
/// 씬에 EventSystem 이 없으면 만든다.
|
||||
///
|
||||
/// Active Input Handling 이 신규 Input System 전용이므로 모듈은
|
||||
/// InputSystemUIInputModule 이어야 한다. StandaloneInputModule 은 레거시
|
||||
/// UnityEngine.Input 을 쓰기 때문에 이 설정에서 예외를 던진다.
|
||||
/// </summary>
|
||||
static void EnsureEventSystem()
|
||||
{
|
||||
var existing = FindFirstObjectByType<EventSystem>();
|
||||
if (existing == null)
|
||||
{
|
||||
var go = new GameObject("EventSystem");
|
||||
existing = go.AddComponent<EventSystem>();
|
||||
var module = go.AddComponent<InputSystemUIInputModule>();
|
||||
// 런타임에 붙이면 액션 에셋이 비어 있어 아무 입력도 받지 못한다.
|
||||
if (module.actionsAsset == null) module.AssignDefaultActions();
|
||||
}
|
||||
|
||||
// InputField 가 IME 상태를 물을 때 레거시 Input 으로 새지 않게 막는다.
|
||||
// BaseInputModule 은 BaseInput 을 "상속한" 컴포넌트를 기본값으로 고르지 않으므로
|
||||
// inputOverride 에 직접 넣어야 한다.
|
||||
var baseModule = existing.GetComponent<BaseInputModule>();
|
||||
if (baseModule != null && baseModule.inputOverride == null)
|
||||
{
|
||||
var safeInput = existing.GetComponent<SafeBaseInput>();
|
||||
if (safeInput == null) safeInput = existing.gameObject.AddComponent<SafeBaseInput>();
|
||||
baseModule.inputOverride = safeInput;
|
||||
}
|
||||
}
|
||||
|
||||
void Build()
|
||||
{
|
||||
var canvasGo = new GameObject("ChatCanvas", typeof(Canvas), typeof(CanvasScaler), typeof(GraphicRaycaster));
|
||||
|
||||
@@ -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;
|
||||
|
||||
86
Assets/02_Scripts/Desktop/FrameRateLimiter.cs
Normal file
86
Assets/02_Scripts/Desktop/FrameRateLimiter.cs
Normal file
@@ -0,0 +1,86 @@
|
||||
using UnityEngine;
|
||||
|
||||
/// <summary>
|
||||
/// 프레임레이트를 제한한다. 상시 구동 앱에서는 이게 없으면 안 된다.
|
||||
///
|
||||
/// 기본 상태의 Unity 는 vSync 가 꺼져 있고 targetFrameRate 가 -1 이라
|
||||
/// "낼 수 있는 만큼" 렌더링한다. 거의 빈 씬이면 수백~수천 FPS 가 나오고,
|
||||
/// 그동안 CPU 코어 하나와 GPU 를 계속 물고 있는다. 게임이라면 상관없지만
|
||||
/// 이 앱은 사용자가 다른 일을 하는 내내 떠 있으므로 그대로 두면 안 된다.
|
||||
/// runInBackground = true 라 창이 뒤로 가도 멈추지 않는다는 점이 더 나쁘다.
|
||||
///
|
||||
/// 한 걸음 더: 아무 일도 없을 때는 더 낮춘다. 캐릭터가 발판에 가만히 서 있고
|
||||
/// 커서도 근처에 없다면 초당 몇 장만 그려도 눈에 띄지 않는다. 사용자가 손을
|
||||
/// 가져가거나 캐릭터가 움직이기 시작하면 즉시 올린다.
|
||||
/// </summary>
|
||||
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<ClickThroughHitTest>();
|
||||
if (dragger == null) dragger = FindFirstObjectByType<CharacterDragger>();
|
||||
if (climber == null) climber = FindFirstObjectByType<WindowClimber>();
|
||||
if (chat == null) chat = FindFirstObjectByType<ChatController>();
|
||||
|
||||
// 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);
|
||||
}
|
||||
|
||||
/// <summary>지금 부드러워야 하는 상황인지.</summary>
|
||||
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;
|
||||
}
|
||||
}
|
||||
2
Assets/02_Scripts/Desktop/FrameRateLimiter.cs.meta
Normal file
2
Assets/02_Scripts/Desktop/FrameRateLimiter.cs.meta
Normal file
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: dae22a3bc9e51854088f14115dc44aec
|
||||
@@ -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;
|
||||
|
||||
@@ -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<WindowClimber>();
|
||||
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<WindowClimber>();
|
||||
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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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<CharacterMotionDirector>();
|
||||
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<CharacterMotionDirector>();
|
||||
}
|
||||
|
||||
var go = new GameObject(ObjectName);
|
||||
Undo.RegisterCreatedObjectUndo(go, "Add Character Motion");
|
||||
go.AddComponent<CharacterMotionDirector>();
|
||||
// 발판 뒤로 숨기기용 사각형. 셰이더는 직접 참조해야 빌드에서 빠지지 않는다.
|
||||
if (Object.FindFirstObjectByType<PlatformOccluder>() == null)
|
||||
{
|
||||
var occluder = Undo.AddComponent<PlatformOccluder>(go);
|
||||
var shader = AssetDatabase.LoadAssetAtPath<Shader>(OccluderShaderPath);
|
||||
if (shader != null)
|
||||
{
|
||||
var so = new SerializedObject(occluder);
|
||||
so.FindProperty("occluderShader").objectReferenceValue = shader;
|
||||
so.ApplyModifiedProperties();
|
||||
}
|
||||
else
|
||||
{
|
||||
Debug.LogWarning($"[CharacterMotionSetupMenu] 셰이더를 찾지 못했습니다: {OccluderShaderPath}");
|
||||
}
|
||||
}
|
||||
|
||||
// 우클릭 포즈 목록.
|
||||
if (Object.FindFirstObjectByType<PoseMenuUI>() == null)
|
||||
{
|
||||
Undo.AddComponent<PoseMenuUI>(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<FrameRateLimiter>();
|
||||
if (existing != null)
|
||||
{
|
||||
Selection.activeGameObject = existing.gameObject;
|
||||
EditorGUIUtility.PingObject(existing.gameObject);
|
||||
Debug.Log($"[CharacterMotionSetupMenu] 이미 있습니다: {existing.gameObject.name}");
|
||||
return;
|
||||
}
|
||||
|
||||
// 카메라(TransparentWindow) 옆에 두는 게 자연스럽다. 없으면 새 오브젝트.
|
||||
var window = Object.FindFirstObjectByType<TransparentWindow>();
|
||||
GameObject host;
|
||||
if (window != null)
|
||||
{
|
||||
host = window.gameObject;
|
||||
Undo.AddComponent<FrameRateLimiter>(host);
|
||||
}
|
||||
else
|
||||
{
|
||||
host = new GameObject("FrameRateLimiter");
|
||||
Undo.RegisterCreatedObjectUndo(host, "Add Frame Rate Limiter");
|
||||
host.AddComponent<FrameRateLimiter>();
|
||||
}
|
||||
|
||||
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()
|
||||
{
|
||||
|
||||
8
Assets/03_Shaders.meta
Normal file
8
Assets/03_Shaders.meta
Normal file
@@ -0,0 +1,8 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 2676fa1457091e545902dbb7008ed460
|
||||
folderAsset: yes
|
||||
DefaultImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
61
Assets/03_Shaders/PlatformOccluder.shader
Normal file
61
Assets/03_Shaders/PlatformOccluder.shader
Normal file
@@ -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
|
||||
}
|
||||
9
Assets/03_Shaders/PlatformOccluder.shader.meta
Normal file
9
Assets/03_Shaders/PlatformOccluder.shader.meta
Normal file
@@ -0,0 +1,9 @@
|
||||
fileFormatVersion: 2
|
||||
guid: bec1d474e5384404eab44f2400f6d7b8
|
||||
ShaderImporter:
|
||||
externalObjects: {}
|
||||
defaultTextures: []
|
||||
nonModifiableTextures: []
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
BIN
Assets/04_Animations/Fall.anim
LFS
BIN
Assets/04_Animations/Fall.anim
LFS
Binary file not shown.
Binary file not shown.
@@ -43,7 +43,7 @@ ModelImporter:
|
||||
cycleOffset: 0
|
||||
loop: 0
|
||||
hasAdditiveReferencePose: 0
|
||||
loopTime: 0
|
||||
loopTime: 1
|
||||
loopBlend: 0
|
||||
loopBlendOrientation: 0
|
||||
loopBlendPositionY: 0
|
||||
|
||||
@@ -43,7 +43,7 @@ ModelImporter:
|
||||
cycleOffset: 0
|
||||
loop: 0
|
||||
hasAdditiveReferencePose: 0
|
||||
loopTime: 0
|
||||
loopTime: 1
|
||||
loopBlend: 0
|
||||
loopBlendOrientation: 1
|
||||
loopBlendPositionY: 1
|
||||
|
||||
@@ -43,7 +43,7 @@ ModelImporter:
|
||||
cycleOffset: 0
|
||||
loop: 0
|
||||
hasAdditiveReferencePose: 0
|
||||
loopTime: 0
|
||||
loopTime: 1
|
||||
loopBlend: 0
|
||||
loopBlendOrientation: 1
|
||||
loopBlendPositionY: 1
|
||||
|
||||
BIN
Assets/04_Animations/Walk.anim
LFS
BIN
Assets/04_Animations/Walk.anim
LFS
Binary file not shown.
Reference in New Issue
Block a user