wan의 개발일기

RoomCraft #12. 룸/가구 툴바 분리 + 색상 피커 만들기 본문

1인 게임개발 활동/가구배치 시뮬레이터

RoomCraft #12. 룸/가구 툴바 분리 + 색상 피커 만들기

wan_ 2026. 7. 5. 12:41
소스코드 → github.com/Jywan/RoomCraft

들어가며

지난 편에서 에디터 하단 EditorToolbar에 시점전환/그리드/회전/삭제/저장 버튼을 다 몰아넣었었다. 근데 쓰면 쓸수록 이게 불편했다 — 룸을 만질 때 쓰는 버튼(시점, 줌, 저장)이랑 가구를 만질 때 쓰는 버튼(회전, 삭제)이 한 툴바에 뒤섞여 있으니, 가구를 선택하지도 않았는데 회전/삭제 버튼이 항상 떠 있는 게 어색했다.

이번엔 그 툴바를 RoomToolbarFurnitureToolbar로 나눴다. 그리고 하는 김에 가구 생성 UI의 색상 선택도 프리셋 버튼 6개짜리에서 벗어나서, 포토샵처럼 원하는 색을 직접 고를 수 있는 색상 피커를 만들었다. 결과물은 며칠 만에 나왔는데, 과정은 솔직히 순탄치 않았다.


툴바를 다시 나눈 이유

가구를 선택했을 때만 회전/삭제 버튼이 뜨게 하려고 했는데, 처음엔 그냥 EditorToolbar 안에 패널 하나 더 만들어서 SetActive로 껐다 켰다 하면 될 줄 알았다. 근데 그러다 보니 스크립트 하나가 룸 담당인지 가구 담당인지 점점 애매해져서, 아예 책임을 나누기로 했다.

RoomToolbar — 룸 전용, 보일러플레이트 최소화

룸 툴바는 버튼 6개(메인메뉴/시점전환/그리드/줌인/줌아웃/저장)가 전부 “다른 컴포넌트 메서드 하나 그대로 호출”하는 수준이라, private 포워딩 메서드를 따로 만들지 않고 AddListener에 메서드 그룹/람다를 바로 넘겼다.

private void Start()
{
    mainMenuButton.onClick.AddListener(OnMainMenu);
    viewToggleButton.onClick.AddListener(cameraController.ToggleView);
    gridToggleButton.onClick.AddListener(gridSnap.ToggleSnap);
    saveButton.onClick.AddListener(editorSaveUI.OpenSavePanel);
    zoomInButton.onClick.AddListener(() => cameraController.Zoom(1f));
    zoomOutButton.onClick.AddListener(() => cameraController.Zoom(-1f));
}
SceneManager.LoadScene는 static 메서드라 메서드 그룹으로 못 넘긴다. 그래서 OnMainMenu() 하나만 자체 메서드로 남겨뒀다.

FurnitureToolbar — 가구 선택 시에만 표시

가구 툴바(회전 좌/우, 삭제)는 DimensionDisplay랑 똑같은 패턴을 썼다. 매 프레임 선택 상태를 확인해서 패널을 켜고 끈다.

private void Update()
{
    bool hasSelection = furnitureInteraction != null && furnitureInteraction.GetSelectedFurniture() != null;
    panel.SetActive(hasSelection);
}
이 스크립트를 패널 자기 자신에 붙이면 안 된다. SetActive(false)로 꺼지는 순간 그 오브젝트의 Update()도 같이 멈춰서 다시 켜지지 않는다. 그래서 DimensionDisplay처럼 Canvas에 붙이고, 대상 패널은 별도 필드로 참조해야 한다.
screenshot
가구를 선택하면 회전/삭제 버튼만 따로 나타난다

색상을 내 맘대로 — 원형 컬러 피커

가구 색은 원래 프리셋 버튼 6개 중에 고르는 방식이었다. 근데 원하는 색이 그 6개 안에 없으면 방법이 없었다. 그래서 포토샵 색상환처럼 Hue(각도)+Saturation(반지름)을 원 안에서 드래그로 고르고, Value(명도)는 슬라이더, 정확한 값은 RGB 입력으로도 넣을 수 있게 만들었다.

휠은 각도가 Hue, 거리가 Saturation

uGUI엔 색상환 컴포넌트가 없어서 직접 만들어야 했다. 원리는 간단하다 — 텍스처의 각 픽셀마다 중심에서의 각도를 Hue로, 중심에서의 거리를 Saturation으로 계산해서 색을 채운다. 원 밖은 완전 투명 처리.

private void BuildWheelTexture()
{
    wheelTexture = new Texture2D(wheelTextureSize, wheelTextureSize, TextureFormat.RGBA32, false);
    float radius = wheelTextureSize / 2f;
    Color32[] pixels = new Color32[wheelTextureSize * wheelTextureSize];

    for (int y = 0; y < wheelTextureSize; y++)
    {
        float dy = y + 0.5f - radius;
        for (int x = 0; x < wheelTextureSize; x++)
        {
            float dx = x + 0.5f - radius;
            float dist = Mathf.Sqrt(dx * dx + dy * dy) / radius;

            if (dist > 1f)
            {
                pixels[y * wheelTextureSize + x] = new Color32(0, 0, 0, 0); // 원 밖은 투명
                continue;
            }

            float angle = Mathf.Atan2(dy, dx);
            float h = angle / (Mathf.PI * 2f);
            if (h < 0f) h += 1f;

            pixels[y * wheelTextureSize + x] = Color.HSVToRGB(h, dist, 1f);
        }
    }

    wheelTexture.SetPixels32(pixels);
    wheelTexture.Apply();
}

드래그 입력은 IPointerDownHandler/IDragHandler로 직접 받아서, 클릭 좌표를 극좌표로 변환한다.

private void HandleDrag(PointerEventData eventData)
{
    Vector2 localPoint;
    if (!RectTransformUtility.ScreenPointToLocalPointInRectangle(rect, eventData.position,
            eventData.pressEventCamera, out localPoint)) return;

    float radius = rect.rect.width / 2;
    float dist = localPoint.magnitude / radius;
    float angle = Mathf.Atan2(localPoint.y, localPoint.x);
    float hue = angle / (Mathf.PI * 2f);
    if (hue < 0f) hue += 1f;

    OnDragPolar?.Invoke(hue, Mathf.Clamp01(dist));
}

Value 슬라이더 + RGB 직접 입력

Value(명도)는 원 안에 넣으면 너무 복잡해질 것 같아서 별도 가로 슬라이더로 뺐다. 그리고 정확한 값이 필요할 때를 위해 R/G/B 입력창 3개도 추가했다. 휠을 드래그하면 RGB 숫자가 갱신되고, RGB를 직접 입력하면 휠 핸들 위치도 같이 움직이는 양방향 동기화 구조다.

screenshot
완성된 색상 피커 — 휠 + Value 슬라이더 + RGB 입력창

삽질 기록

이번 편은 코드보다 Rect Transform이랑 Layout Group 때문에 진짜 오래 헤맸다. 체감상 3일 정도를 UI 배치 하나 붙잡고 씨름한 것 같다.

Layout Group은 자식을 배치만 하지, 자기 크기는 안 키운다

휠 크기를 키우고, 그 옆에 RGB 입력창을 세로로 놓고, 그 밑에 Value 슬라이더를 두는 구조로 가려고 TopRow(휠+RGB)와 ColorPickerPanel(TopRow+슬라이더)을 각각 Horizontal/Vertical Layout Group으로 감쌌다. 근데 아무리 정렬을 만져도 슬라이더가 휠 밑이 아니라 휠 한가운데에 겹쳐서 나타났다.

한참 스크린샷 주고받으면서 찾아보니, TopRow의 실제 Rect Transform 크기가 Unity 기본값인 100x100 그대로였다. 안에 200x200짜리 휠이 들어있는데도 말이다. Horizontal/Vertical Layout Group은 자식들을 “배치”만 하지, 자기 자신의 크기를 내용물에 맞춰 키워주지는 않는다 — 그래서 부모 쪽 Layout Group은 TopRow가 100밖에 안 된다고 착각하고 그 바로 밑에 슬라이더를 붙여버린 거였다.

해결: Layout Group이 붙은 컨테이너가 또 다른 Layout Group의 자식이 될 땐, Content Size Fitter(Horizontal/Vertical Fit — Preferred Size)를 반드시 같이 붙여야 한다. 이 프로젝트에서는 FurnitureCreatorPanelInfoColumn/ColorPickerPanelTopRow/RGBColumn까지 중첩되는 모든 단계에 다 붙여줘야 했다.

Slider의 Handle Rect가 앵커를 통째로 잠근다

Value 슬라이더 핸들(동그란 부분) 크기를 줄이려고 HandleRect Transform을 열었는데, Width 필드가 아예 없고 Min X/Max X가 회색으로 비활성화되어 있었다. 알고 보니 Handle Rect로 지정된 오브젝트는 Slider 컴포넌트가 앵커 전체를 “내가 관리한다”고 잠가버린다. 실제로는 한 축만 움직이는데, Unity가 앵커 값 네 개를 통째로 락 걸어버리는 것.

해결: Handle 자체를 건드리지 말고, 그 부모인 Handle Slide Area의 크기를 조절한다. Handle은 부모 폭에 꽉 차게 늘어나는 구조라, 부모를 좁히면 결과적으로 Handle도 같이 작아진다.

그 외 자잘한 것들

  • 스크립트를 EditorToolbar.csRoomToolbar.cs로 리네임할 때, Finder에서 파일명만 바꾸면 .meta의 GUID 매칭이 깨져서 씬에 연결해둔 참조가 다 날아간다. 반드시 Unity 프로젝트 창 안에서 Rename.
  • RGB 입력창 3개를 ColorPickerUI에 연결하는 걸 깜빡해서 NullReferenceException이 계속 떴다. 슬롯 하나라도 비어있으면 Awake()가 중간에 멈춰서 그 뒤 초기화가 통째로 스킵된다는 걸 다시 확인.
  • Slider의 Handle Rect가 비어있으면 인스펙터에 Direction 필드 자체가 안 보인다. Handle을 연결해야 그제서야 나타난다.

혼자 코드만 봤으면 훨씬 오래 걸렸을 텐데, 스크린샷 주고받으면서 하나씩 짚어가니까 그나마 빨리 끝났다. 그래도 Layout Group 중첩은 진짜 다음부턴 처음부터 Content Size Fitter를 세트로 붙이고 시작해야겠다.


정리

기능핵심 API / 구조
룸 툴바 분리RoomToolbar.cs — 메서드 그룹/람다로 AddListener 직접 연결, 보일러플레이트 제거
가구 툴바 분리FurnitureToolbar.cs — Canvas에 부착 + 선택 상태 폴링으로 SetActive 토글
원형 색상환ColorWheelDragHandler — 클릭 좌표를 극좌표(각도→Hue, 반지름→Saturation)로 변환
휠 텍스처 생성Texture2D.SetPixels32 + Color.HSVToRGB, 원 밖은 알파 0으로 투명 처리
RGB 직접 입력TMP_InputField 3개, Color.RGBToHSV로 휠/슬라이더와 양방향 동기화
Layout Group 중첩중첩되는 모든 컨테이너에 Content Size Fitter 필수
Slider 핸들 크기 조절Handle 대신 부모 Handle Slide Area 크기 조절