채팅 추가

This commit is contained in:
2026-09-01 14:40:02 +09:00
parent cab0d772b1
commit a5a1f1d9e9
50 changed files with 3843 additions and 1124 deletions

View File

@@ -1,3 +1,5 @@
using System;
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.InputSystem;
@@ -13,6 +15,10 @@
/// 투명도와 공존하며, SetLayeredWindowAttributes 호출은 필요하지 않았다.
///
/// LAYERED 는 init 시 한 번만 걸고, 매 프레임 토글하는 것은 TRANSPARENT 뿐이다.
///
/// 캐릭터 말고도 클릭을 받아야 하는 것이 생기면(채팅창 같은 UI) 그 영역을
/// RegisterInteractiveRegion 으로 등록한다. 히트테스트는 3D 콜라이더와 등록된
/// 영역을 모두 본다.
/// </summary>
[RequireComponent(typeof(TransparentWindow))]
public class ClickThroughHitTest : MonoBehaviour
@@ -32,6 +38,9 @@ public class ClickThroughHitTest : MonoBehaviour
// 정상 수신된다. 사용자가 쓰던 앱의 포커스를 뺏지 않는 쪽이 데스크톱 비서로서
// 명백히 낫기 때문에 항상 켠다.
//
// 예외는 채팅창이다. 키보드 입력은 포커스가 없으면 들어오지 않으므로
// 채팅창이 열려 있는 동안만 SetNoActivate(false) 로 잠시 내린다.
//
// SerializeField 가 아닌 이유: 씬에 예전 false 값이 직렬화돼 있어
// 인스펙터 값이 코드 기본값을 이겨버린다. 런타임 토글은 Ctrl+Alt+N 으로 유지.
bool useNoActivate = true;
@@ -53,9 +62,15 @@ public class ClickThroughHitTest : MonoBehaviour
Vector3 hoveredBaseScale;
GUIStyle hudStyle;
/// <summary>캐릭터 외에 클릭을 받아야 하는 화면 영역들. 채팅창 등이 등록한다.</summary>
readonly List<Func<Vector2, bool>> interactiveRegions = new List<Func<Vector2, bool>>();
/// <summary>현재 OS 커서가 캐릭터 위에 있는지.</summary>
public bool IsOverCharacter { get; private set; }
/// <summary>현재 OS 커서가 등록된 UI 영역 위에 있는지.</summary>
public bool IsOverUi { get; private set; }
/// <summary>
/// true 인 동안 커서 위치와 무관하게 클릭을 받는다.
///
@@ -66,7 +81,9 @@ public class ClickThroughHitTest : MonoBehaviour
public bool ForceInteractive { get; set; }
int clickCount;
uint currentExStyle;
// Windows 빌드에서만 갱신된다. 에디터에서는 0 으로 남으므로 명시적으로 초기화해
// "한 번도 대입되지 않았다"는 경고를 없앤다.
uint currentExStyle = 0;
string lastAction = "-";
void Awake()
@@ -76,26 +93,48 @@ void Awake()
if (hitTestCamera == null) hitTestCamera = Camera.main;
}
/// <summary>
/// 화면 좌표를 받아 "여기는 클릭을 받아야 한다"를 판정하는 함수를 등록한다.
/// 등록한 쪽이 사라질 때 반드시 해제한다.
/// </summary>
public void RegisterInteractiveRegion(Func<Vector2, bool> region)
{
if (region != null && !interactiveRegions.Contains(region)) interactiveRegions.Add(region);
}
public void UnregisterInteractiveRegion(Func<Vector2, bool> region)
{
if (region != null) interactiveRegions.Remove(region);
}
/// <summary>
/// NOACTIVATE 를 켜고 끈다. 끄면 창이 포커스를 받을 수 있게 되어 키보드 입력이 들어온다.
/// 채팅창이 열고 닫을 때 부른다.
/// </summary>
public void SetNoActivate(bool enable)
{
if (useNoActivate == enable) return;
useNoActivate = enable;
lastAction = $"NOACTIVATE = {useNoActivate}";
#if !UNITY_EDITOR && UNITY_STANDALONE_WIN
// true = 클릭이 뒤 창으로 통과하는 상태
bool clickThrough = true;
bool styleInitialized;
bool prevN;
if (window != null && window.IsReady && window.Hwnd != IntPtr.Zero) ApplyBaseStyle();
#endif
}
void Update()
{
if (!window.IsReady || window.Hwnd == System.IntPtr.Zero) return;
IntPtr hwnd = window != null ? window.Hwnd : IntPtr.Zero;
if (!styleInitialized)
{
ApplyBaseStyle();
styleInitialized = true;
}
// 좌표 변환은 DesktopCursor 에 모아뒀다. 시선 추적도 같은 변환을 쓴다.
// 에디터에서는 일반 마우스 위치로 대체되므로 플레이 모드에서도 히트테스트가 돈다.
bool haveCursor = DesktopCursor.TryGetScreenPosition(hwnd, out Vector2 cursor);
PollNoActivateToggle();
// UI 가 캐릭터를 가리고 있으면 UI 가 이긴다. 채팅창 위에서 드래그가 시작되면 곤란하다.
IsOverUi = haveCursor && IsInsideInteractiveRegion(cursor);
IsOverCharacter = HitTest(out var hit);
SetClickThrough(!IsOverCharacter && !ForceInteractive);
RaycastHit hit = default;
IsOverCharacter = haveCursor && !IsOverUi && HitTest(cursor, out hit);
// 드래그 중에는 커서가 캐릭터를 벗어나도 호버 표시를 유지한다.
// 화면 끝까지 끌면 캐릭터는 제한에 걸려 멈추고 커서만 더 나가는데,
@@ -109,9 +148,52 @@ void Update()
{
clickCount++;
lastAction = $"클릭: {hit.transform.name}";
Debug.Log($"[ClickThroughHitTest] 캐릭터 클릭됨: {hit.transform.name}");
}
#if !UNITY_EDITOR && UNITY_STANDALONE_WIN
UpdateWindowStyle();
#endif
}
bool IsInsideInteractiveRegion(Vector2 screenPos)
{
for (int i = 0; i < interactiveRegions.Count; i++)
{
var region = interactiveRegions[i];
if (region != null && region(screenPos)) return true;
}
return false;
}
bool HitTest(Vector2 screenPos, out RaycastHit hit)
{
hit = default;
if (hitTestCamera == null) return false;
var ray = hitTestCamera.ScreenPointToRay(new Vector3(screenPos.x, screenPos.y, 0f));
return Physics.Raycast(ray, out hit, maxRayDistance, interactableLayers);
}
#if !UNITY_EDITOR && UNITY_STANDALONE_WIN
// true = 클릭이 뒤 창으로 통과하는 상태
bool clickThrough = true;
bool styleInitialized;
bool prevN;
void UpdateWindowStyle()
{
if (!window.IsReady || window.Hwnd == IntPtr.Zero) return;
if (!styleInitialized)
{
ApplyBaseStyle();
styleInitialized = true;
}
PollNoActivateToggle();
SetClickThrough(!IsOverCharacter && !IsOverUi && !ForceInteractive);
currentExStyle = Win32.GetWindowLongW(window.Hwnd, Win32.GWL_EXSTYLE);
}
@@ -127,9 +209,7 @@ void PollNoActivateToggle()
bool now = Win32.IsKeyDown(Win32.VK_N);
if (now && !prevN)
{
useNoActivate = !useNoActivate;
ApplyBaseStyle();
lastAction = $"NOACTIVATE = {useNoActivate}";
SetNoActivate(!useNoActivate);
Debug.Log($"[ClickThroughHitTest] NOACTIVATE = {useNoActivate}");
}
prevN = now;
@@ -153,17 +233,6 @@ void ApplyBaseStyle()
Win32.SWP_NOMOVE | Win32.SWP_NOSIZE | Win32.SWP_NOACTIVATE | Win32.SWP_FRAMECHANGED);
}
bool HitTest(out RaycastHit hit)
{
hit = default;
if (hitTestCamera == null) return false;
// 좌표 변환은 DesktopCursor 에 모아뒀다. 시선 추적도 같은 변환을 쓴다.
if (!DesktopCursor.TryGetScreenPosition(window.Hwnd, out Vector2 sp)) return false;
var ray = hitTestCamera.ScreenPointToRay(new Vector3(sp.x, sp.y, 0f));
return Physics.Raycast(ray, out hit, maxRayDistance, interactableLayers);
}
void SetClickThrough(bool enable)
{
if (enable == clickThrough) return;
@@ -206,7 +275,7 @@ void OnGUI()
string text =
$"NOACTIVATE: {useNoActivate} [Ctrl+Alt+N 전환]\n" +
$"ExStyle: 0x{currentExStyle:X8} LAYERED={layered} TRANSPARENT={transparent}\n" +
$"OverCharacter: {IsOverCharacter} 클릭 횟수: {clickCount}\n" +
$"OverCharacter: {IsOverCharacter} OverUI: {IsOverUi} 클릭 횟수: {clickCount}\n" +
$"마지막: {lastAction}\n" +
$"[Ctrl+Alt+Q 종료]";

View File

@@ -45,6 +45,10 @@ public class DesktopPlatformScanner : MonoBehaviour
[Tooltip("비우면 씬에서 탐색")]
[SerializeField] TransparentWindow window;
// minPlatformWidth 는 빌드에서만, editorFloorY 는 에디터에서만 쓰인다.
// 반대쪽 변형에서는 "할당했지만 안 씀" 경고가 뜬다.
#pragma warning disable 0414
[Header("스캔")]
[Tooltip("창 목록을 다시 훑는 주기(초). 매 프레임 돌 필요가 없다")]
[SerializeField] float scanInterval = 0.25f;
@@ -55,6 +59,12 @@ public class DesktopPlatformScanner : MonoBehaviour
[Tooltip("작업 영역 바닥(작업표시줄 위)을 항상 발판으로 포함한다")]
[SerializeField] bool includeFloor = true;
[Tooltip("에디터에서 쓸 바닥 높이(화면 좌표, 0 이 맨 아래). " +
"빌드에서는 작업표시줄 위를 직접 찾으므로 이 값을 쓰지 않는다")]
[SerializeField] float editorFloorY = 0f;
#pragma warning restore 0414
readonly List<DesktopPlatform> platforms = new List<DesktopPlatform>();
float nextScanTime;
@@ -97,6 +107,26 @@ public bool TryFindPlatformBelow(float x, float y, float tolerance, out DesktopP
return any;
}
/// <summary>
/// 화면 좌표로 바닥 발판 하나를 직접 만든다. 창 목록에서 바닥을 얻지 못했을 때의 보루.
///
/// 바닥이 하나도 없으면 캐릭터가 끝없이 떨어지다 화면 가운데로 되돌아오기를
/// 반복한다(WindowClimber.TickFalling). 그 상태로는 아무것도 확인할 수 없다.
/// </summary>
void AddScreenFloor(float y, string title)
{
platforms.Add(new DesktopPlatform
{
Y = y,
XMin = 0f,
XMax = Screen.width,
Title = title,
IsFloor = true,
Hwnd = System.IntPtr.Zero,
WindowLeft = 0f,
});
}
#if !UNITY_EDITOR && UNITY_STANDALONE_WIN
// EnumWindows 콜백은 네이티브가 호출하므로 GC 되지 않게 붙잡아 둔다.
@@ -223,12 +253,18 @@ static void Subtract(List<(float a, float b)> segments, float cutA, float cutB)
void AddFloor(DesktopCursor.Mapping map)
{
// 작업 영역은 작업표시줄을 제외한 범위다. 그 바닥에 서면 작업표시줄 위에 선다.
if (!Win32.SystemParametersInfoW(Win32.SPI_GETWORKAREA, 0, out Win32.RECT work, 0)) return;
if (Win32.SystemParametersInfoW(Win32.SPI_GETWORKAREA, 0, out Win32.RECT work, 0) &&
AddPlatform(map, work.left, work.right, work.bottom, "바닥", true, IntPtr.Zero, work.left))
{
return;
}
AddPlatform(map, work.left, work.right, work.bottom, "바닥", true, IntPtr.Zero, work.left);
// 작업 영역을 못 구했거나 화면 밖으로 잘려나갔을 때. 바닥 없이 두면 안 된다.
AddScreenFloor(0f, "바닥(대체)");
}
void AddPlatform(DesktopCursor.Mapping map, float desktopLeft, float desktopRight,
/// <returns>실제로 발판이 추가됐는지. 너무 좁거나 화면 밖이면 false.</returns>
bool AddPlatform(DesktopCursor.Mapping map, float desktopLeft, float desktopRight,
float desktopTop, string title, bool isFloor,
IntPtr hwnd, float desktopWindowLeft)
{
@@ -239,8 +275,8 @@ void AddFloor(DesktopCursor.Mapping map)
// 화면 밖으로 벗어난 부분은 잘라낸다.
xMin = Mathf.Max(xMin, 0f);
xMax = Mathf.Min(xMax, Screen.width);
if (xMax - xMin < minPlatformWidth) return;
if (y < 0f || y > Screen.height) return;
if (xMax - xMin < minPlatformWidth) return false;
if (y < 0f || y > Screen.height) return false;
platforms.Add(new DesktopPlatform
{
@@ -252,12 +288,22 @@ void AddFloor(DesktopCursor.Mapping map)
Hwnd = hwnd,
WindowLeft = map.ToScreenX(desktopWindowLeft),
});
return true;
}
#else
/// <summary>
/// 에디터에는 열거할 창이 없다. 그래서 발판이 하나도 없고, 캐릭터는 끝없이
/// 떨어졌다가 화면 가운데로 되돌아오기를 반복한다.
///
/// 빌드에서 작업표시줄이 해주던 역할을 대신할 바닥 하나만 깔아준다.
/// 창에 올라타는 동작 자체는 여전히 빌드에서만 확인할 수 있다.
/// </summary>
void Scan()
{
// 에디터에서는 창 열거를 하지 않는다. 빌드해서 확인할 것.
platforms.Clear();
if (includeFloor) AddScreenFloor(editorFloorY, "바닥(에디터)");
Rescanned?.Invoke();
}
#endif
}

View File

@@ -77,10 +77,26 @@ void Start()
#endif
}
// 마지막으로 Esc 종료가 막힌 프레임. 채팅창이 열려 있는 동안 매 프레임 갱신된다.
int escapeSuppressedFrame = -10;
/// <summary>
/// 이번 프레임의 Esc 를 종료로 해석하지 않는다.
///
/// 채팅창처럼 Esc 에 자기 의미가 있는 UI 는 열려 있는 동안 매 프레임 이걸 부른다.
/// 한 프레임 유예를 두는 이유: 채팅창이 Esc 로 닫히면서 억제를 멈춘 바로 그 프레임에
/// 여기서 같은 Esc 를 다시 읽으면 앱이 꺼져버린다. 스크립트 실행 순서에 기대지 않도록
/// 프레임 번호로 판단한다.
/// </summary>
public void SuppressEscapeQuit()
{
escapeSuppressedFrame = Time.frameCount;
}
void Update()
{
// 포커스가 있을 때만 동작하는 보조 탈출구
if (quitOnEscape)
if (quitOnEscape && Time.frameCount - escapeSuppressedFrame > 1)
{
var kb = Keyboard.current;
if (kb != null && kb.escapeKey.wasPressedThisFrame) Application.Quit();

View File

@@ -47,6 +47,7 @@ internal static class Win32
public const int SW_SHOW = 5;
public const int VK_LBUTTON = 0x01;
public const int VK_ESCAPE = 0x1B;
public const int VK_SHIFT = 0x10;
public const int VK_CONTROL = 0x11;
public const int VK_MENU = 0x12; // Alt
@@ -105,6 +106,47 @@ public struct RECT
[DllImport("user32.dll")] public static extern bool SetLayeredWindowAttributes(IntPtr hWnd, uint crKey, byte bAlpha, uint dwFlags);
[DllImport("dwmapi.dll")] public static extern int DwmExtendFrameIntoClientArea(IntPtr hWnd, ref MARGINS pMarInset);
// --- 포커스 ---
[DllImport("user32.dll")] public static extern bool SetForegroundWindow(IntPtr hWnd);
[DllImport("user32.dll")] public static extern IntPtr SetFocus(IntPtr hWnd);
[DllImport("user32.dll")] public static extern bool IsWindow(IntPtr hWnd);
[DllImport("user32.dll")] public static extern uint GetWindowThreadProcessId(IntPtr hWnd, out uint lpdwProcessId);
[DllImport("user32.dll")] public static extern bool AttachThreadInput(uint idAttach, uint idAttachTo, bool fAttach);
[DllImport("kernel32.dll")] public static extern uint GetCurrentThreadId();
public static bool IsKeyDown(int vKey) => (GetAsyncKeyState(vKey) & 0x8000) != 0;
/// <summary>
/// 창을 확실히 활성화한다.
///
/// SetForegroundWindow 는 그냥 부르면 자주 무시된다. 윈도우는 포그라운드를
/// 가로채는 것을 막으려고 "마지막 입력을 받은 프로세스"만 허용하는데, 우리 창은
/// WS_EX_NOACTIVATE 라 그 판정에서 밀리는 경우가 있다. 현재 포그라운드 창의
/// 입력 큐에 잠깐 붙었다 떼면 같은 스레드로 취급돼 통과한다.
/// </summary>
public static void ForceForeground(IntPtr hwnd)
{
if (hwnd == IntPtr.Zero) return;
IntPtr foreground = GetForegroundWindow();
if (foreground == hwnd)
{
SetFocus(hwnd);
return;
}
uint currentThread = GetCurrentThreadId();
uint foregroundThread = foreground != IntPtr.Zero
? GetWindowThreadProcessId(foreground, out _)
: currentThread;
bool attached = foregroundThread != 0 && foregroundThread != currentThread &&
AttachThreadInput(currentThread, foregroundThread, true);
SetForegroundWindow(hwnd);
SetFocus(hwnd);
if (attached) AttachThreadInput(currentThread, foregroundThread, false);
}
}
#endif