메시지 앱은 대체로 비슷하게 생겼습니다. 둥근 사각형 카드에 왼쪽 정렬 목록, 아래에서 올라오는 새 말풍선까지. 하루에도 몇 번씩 여는 화면은 단순해야 하고 오래 써도 덜 피곤해야 하니, UI들이 직관적이고 깔끔한 건 좋은 결과입니다.
게임 UI는 그 제약에서 비교적 자유롭습니다. 화면 자체가 즐길 거리의 일부라서 편안함보다 개성을 우선할 수 있는데 Persona 5의 메시지 화면이 그런 경우입니다. 말풍선은 네 변이 전부 비스듬합니다. 인물 사진은 기울어진 프레임에 담겨 있고 대화 순서를 따라 검은 선이 지그재그로 꺾이며 내려갑니다.
이 화면을 처음 봤을 때 든 질문은 "웹에서도 만들 수 있을까"였어요. 실제로 옮겨 보니 따로 짚어볼 만한 지점이 세 군데 있었고, 그것들을 하나씩 정리했습니다.
저작권 안내
©ATLUS ©SEGA All rights reserved.
『Persona 5』의 메시지 UI를 관찰해 웹으로 다시 구현한 비상업 팬 데모입니다. 인물 사진은 게임의 아트를 그대로 사용했고 그 밖의 화면은 전부 새로 그렸으며, 모든 아트와 캐릭터 디자인의 저작권은 Atlus Co., Ltd.에 있습니다. 모든 자료는 교육 목적으로만 사용했습니다.
먼저 살펴보기
데모 시작을 누른 뒤 대화창 아무 곳이나 누르면 다음 메시지가 나오고, 열두 개를 다 보면 처음부터 다시 시작합니다. 왼쪽 위 IM 로고로는 계절을 바꿀 수 있어요. 진행하면서 아래 세 가지를 눈여겨봐 주세요. 뒤에 나올 세 절이 각각 여기서 출발합니다.
- 말풍선의 폭 — 문장마다 다름, 좌우 두 변의 기울기는 동일
- 인물 사진 — 머리는 프레임 위로 넘치고, 아래쪽은 프레임 경계선에서 잘림
- 메시지 사이의 검은 선 — 새 메시지가 올 때만 아래로 늘어나고, 이미 그려진 조각은 고정
Persona 5 메시지 UI을 불러오고 있습니다.
말풍선의 폭과 검은 선의 폭·방향은 누를 때마다 달라집니다. 미리 그려 둔 이미지를 붙여서는 만들 수 없고, 매번 계산해서 그려야 하는 화면입니다. 그리고 그 계산이 성립하려면 조건이 하나 필요합니다.
이 화면의 좌표는 전부 절대 픽셀값입니다. 아바타는 110×90이고 겹침은 18px입니다. 둘 다 컨테이너 폭과 무관하게 고정된 숫자입니다. 그래서 반응형을 요소 단위로 풀지 않습니다. 모든 것을 400×600 고정 영역에 그린 뒤 그 영역 전체를 transform: scale() 하나로 줄였습니다. 이 글에 나오는 픽셀 값이 화면 크기와 상관없이 항상 유효한 건 그 덕분이에요. 대신 영역 안에는 반응형이 없습니다. 좁은 화면에서는 레이아웃이 재배치되지 않고 글자까지 같이 작아집니다.
폭이 변하는 비스듬한 도형 그리기
말풍선은 담긴 문장이 길어지면 같이 커져야 합니다. 가장 쉬운 방법은 완성된 말풍선을 이미지 한 장으로 잘라 두는 것인데, 이미지는 정해진 크기에서만 맞습니다. 두 줄짜리 메시지가 들어오는 순간 글자가 말풍선 밖으로 넘칩니다.
이미지를 늘이는 방법도 있습니다. 반듯한 사각형이라면 늘어나도 되는 부분만 골라 늘이는 나인패치(nine-patch) 같은 기법을 쓸 수 있습니다. 하지만 네 변이 전부 기울어진 도형에는 늘여도 안전한 방향이 없습니다. 가로로 늘이든 세로로 늘이든 변의 기울기와 꼭짓점 각도가 함께 변해요.
그래서 말풍선을 크기를 받아 좌표를 돌려주는 함수로 정의했습니다.
const getEntryOuterBox = (width: number, height: number) =>
`M 31.7 3.1 L ${width} 0 L ${width - 23} ${height} L 15.6 ${height - 8} Z`;SVG 경로에서 M은 시작점입니다. L은 직선이고 Z는 도형 닫기입니다. 반듯한 직사각형의 네 꼭짓점은 (0,0)·(width,0)·(width,height)·(0,height)입니다. 여기서는 꼭짓점들이 그 위치에서 조금씩 비껴나 있어요. 비껴난 거리가 width·height와 무관한 고정값이라 폭이 달라져도 좌우 변의 기울기는 변하지 않습니다.
텍스트를 일반 DOM 요소로 렌더링하고 offsetWidth와 offsetHeight를 읽습니다. 두 값을 함수에 넘겨 흰 테두리와 검은 면을 그 크기로 다시 그립니다. 꼬리는 별도 요소로 붙이지 않고 같은 SVG 안에서 먼저 그린 뒤 본체를 그려요. 본체가 이음새를 덮기 때문에 도형 두 개를 맞붙일 때 생기는 틈이 없습니다.
다만 첫 렌더가 문제입니다. 경로를 그리려면 텍스트 크기가 필요한데 텍스트는 화면에 렌더링돼야 잴 수 있거든요. 그래서 첫 렌더에서는 임의로 정한 200×60으로 그리고, 측정이 끝나면 실측값으로 다시 그립니다. 실측값이 바로 덮어쓰기 때문에 시작값은 무엇이어도 상관없습니다.
프레임은 그대로 두고 사진만 자르기
아바타는 검정·흰색·캐릭터 색상 프레임 세 겹 위에 인물 사진을 얹은 구조입니다. 원본 그림에는 프레임에 담기는 것보다 넓은 범위가 그려져 있어서, 그대로 얹으면 인물이 프레임 위아래로 삐져나와요. 문제는 이걸 전부 잘라 내면 안 된다는 겁니다. 사진 레이어는 검정 프레임 윗변보다 14~21px 위에서 시작합니다. 머리가 프레임 위로 솟은 모습이 이 화면의 특징이라 위쪽은 남기고 아래쪽만 프레임 경계에서 끊어야 해요. 물론 프레임 세 겹은 어디도 잘리면 안 되고요.
첫 시도는 컨테이너 전체에 clip-path를 하나 거는 것이었는데, 사진과 함께 바깥 프레임까지 잘려 나갔습니다. clip-path는 요소가 그리는 것 전부에 적용되는 속성이라, 안에 무엇이 몇 겹 들어 있는지 구분하지 않기 때문입니다.
한 요소 안에서 프레임은 남기고 사진만 자를 수는 없으니, 요소를 나눴습니다.
{/* 프레임 세 겹 — 클리핑 바깥이라 항상 온전하다 */}
<svg className="absolute inset-0">
<path d={blackBox} fill="black" />
<path d={whiteBox} fill="white" />
<path d={coloredBox} fill={senderColor} />
</svg>
{/* 사진 레이어에만 클리핑을 건다 */}
<div style={{ clipPath: `path('${clipBox}')` }}>
<CharacterImage />
</div>프레임 SVG는 클리핑 바깥에 두고, 사진을 감싼 레이어에만 clip-path의 path() 값을 적용합니다. 이러면 "위는 남기고 아래만 자르기"가 클리핑 도형의 모양 문제로 바뀝니다.
clipBox: 'M 10.3 -5.6 L 114.7 -5.6 L 114.7 65.6 L 40 76.6 Z';이 도형에서 실제로 사진을 자르는 변은 왼쪽과 아래 둘뿐입니다. 위쪽 변(-5.6)과 오른쪽 변(114.7)은 아바타 박스(110×90) 바깥에 있어서 걸리는 픽셀이 없습니다. 위쪽을 자르는 변이 없으니 머리는 그대로 프레임 밖으로 남아요. 자르는 두 변은 안쪽 프레임 coloredBox의 같은 변과 기울기가 거의 같습니다. 꼭짓점 (40, 76.6)은 아예 공유합니다. 사진이 끊기는 선이 안쪽 프레임의 경계와 겹친다는 뜻이에요. 그래서 실제로는 인물 픽셀의 10~13%가 잘려 나가는데도 처음부터 프레임에 맞게 그린 사진처럼 보입니다.
아쉬운 점은 이 맞물림이 코드에 드러나지 않는다는 것입니다. 프레임 두께를 바꾸면 clipBox도 같이 고쳐야 하는데 그 사실을 알려 주는 장치가 없습니다. 프레임 세 겹의 여백도 꼭짓점마다 다릅니다(오른쪽 아래 4.5px·왼쪽 위 16.8px). 형태를 바꾸려면 결국 네 경로의 꼭짓점 열여섯 개를 손으로 다시 맞춰야 합니다.
스크롤해도 어긋나지 않는 검은 선 그리기
검은 선은 메시지마다 하나씩 정해진 기준점을 잇습니다. 폭은 44~60px입니다. 캐릭터 메시지는 아바타 중심이 기준점이고 주인공 답장은 오른쪽 끝에서 60px 안쪽 지점이 기준점입니다. 새 메시지가 들어올 때마다 선이 위쪽 두 점에서 아래쪽 두 점으로 늘어납니다. 목록을 스크롤해도 양 끝은 각자의 메시지에 붙어 있어야 합니다.
처음에는 배경에 캔버스 한 장을 깔고 선 전체를 거기 그리는 구조로 풀려고 했습니다. 그런데 대화 목록은 스크롤되는 DOM이고 배경 캔버스의 좌표계는 그 스크롤과 무관해요. 메시지가 밀릴 때마다 모든 기준점을 다시 측정해서 캔버스에 그려야 합니다. 측정이 한 프레임만 늦어도 선 끝이 말풍선에서 떨어집니다. 선을 그리는 곳과 선이 붙어야 할 곳이 서로 다른 좌표계에 있는 한, 이 동기화 문제는 사라지지 않습니다.
그래서 동기화를 잘하는 대신 동기화가 필요 없는 구조로 뒤집었습니다. 선 전체를 한 곳에서 그리지 않습니다. 각 메시지가 다음 메시지까지 이어지는 조각 하나씩만 소유합니다. 조각이 메시지 안에 있으므로 스크롤로 메시지가 움직이면 조각도 같은 만큼 움직입니다. 맞춰야 할 좌표계가 애초에 하나가 되는 셈이죠. 대신 각 항목은 메시지 내용과 함께 조각을 그리는 데 필요한 값을 전부 들고 있어야 합니다.
type EntryState = {
id: string; // 재시작 회차가 들어간 키
message: Message;
position: number; // 지그재그 방향의 근거
drawPunctuation: boolean; // 문장이 ?로 끝나는지
lineCoordinates: { leftPoint: Point; rightPoint: Point };
horizontalShift: number; // 생성 순간 한 번만 결정된다
};한 타입에 메시지 내용과 화면 좌표가 섞여 있는 게 이상해 보일 수 있지만 둘은 수명이 같습니다. 항목이 만들어질 때 같이 생기고 목록이 비워질 때 같이 사라져요. 그래서 둘을 한 상태로 묶습니다. 다음 메시지로 넘어가는 동작은 이전 상태를 받아 다음 상태를 돌려주는 리듀서 하나로 처리했습니다.
이 상태를 받아 각 조각은 ResizeObserver로 자기 크기를 읽습니다. 그런 다음 위 두 점(자기 좌표)과 아래 두 점(다음 메시지의 좌표)을 잇는 닫힌 경로를 만듭니다. 선이 아래로 늘어나는 동안에는 requestAnimationFrame 루프가 아래 두 점의 좌표를 매 프레임 선형 보간으로 다시 계산합니다. 좌우 방향은 메시지 순서의 홀짝을 따라 번갈아 바뀝니다. 밀리는 거리 16~48px와 폭 44~60px는 메시지마다 새로 뽑습니다. 다시 시작할 때마다 선 모양이 달라지는 이유예요.
방금 나온 requestAnimationFrame 루프는 사실 이 화면의 예외입니다. 등장 애니메이션 가운데 JavaScript로 움직이는 건 검은 선뿐이거든요. 프레임·사진·말풍선·글자는 한 번 재생되고 끝나는 애니메이션이라 CSS 애니메이션으로 선언했습니다. 매 프레임 경로 문자열을 새로 계산해야 하는 검은 선만 JavaScript로 그렸습니다.
이 구조에도 비용은 있습니다. 조각마다 ResizeObserver와 SVG가 하나씩 붙어서, 메시지 열두 개면 관찰자가 열한 개 생깁니다. 그리고 목록이 entries[index + 1]을 항상 메시지라고 가정하기 때문에, 날짜 구분선처럼 메시지가 아닌 항목을 끼우려면 이 가정부터 손봐야 합니다.
마치며
처음 질문으로 돌아가면 답은 "만들 수 있다"였습니다. 만드는 동안 제일 재밌었던 순간은 대화창을 누를 때마다 검은 선이 매번 다른 모양으로 내려오는 걸 처음 확인했을 때였어요. 완성된 이미지를 재생하는 게 아니라 방금 계산한 좌표가 그대로 화면이 된다는 사실은 직접 만들어 봐야 알 수 있는 재미였습니다. 앞으로도 직접 구현해 보고 싶어지는 화면을 만나면 또 하나 만들어 볼 생각이에요.
참고 자료
- clip-path — MDN —
path()문법과 브라우저 지원 현황 - Path2D — MDN — SVG 경로 문자열을 캔버스에서 재사용하는 API. 데모의 계절 파티클에 사용
- cubic-bezier.com — 데모의 등장 애니메이션에 쓴 overshoot 곡선을 직접 움직여 볼 수 있는 도구
