오류수정

This commit is contained in:
2026-09-01 21:36:23 +09:00
parent 7ec587353a
commit 527dc54218
27 changed files with 922 additions and 80 deletions

View File

@@ -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;
// 눌린 "순간"만 잡는다. 이미 누른 채로 커서가 캐릭터 위로 지나가는 경우
// (다른 창을 끌고 오다가 캐릭터를 스치는 등)에 잡히면 안 된다.
// 클릭으로 채팅창이 토글되면서부터는 이 오작동이 눈에 띄게 된다.

View File

@@ -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)

View File

@@ -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>
/// 캐릭터 몸짓을 재생하는 계층의 창구.
///

View 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;
}
}

View File

@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: b3a860401aad71342b84131203d2e1d5

View 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();
}
}
}

View File

@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: c7e8cef4b39adce44b6dc1df6a6f4908

View File

@@ -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);
}
}