기여도 그래프 위를 기어다니는 지렁이 배경 만들기
WhoIsLoafing 메인 화면 뒤에는 GitHub 잔디처럼 생긴 칸들이 반짝이고, 지렁이 몇 마리가 칸을 먹으며 한마디씩 해요. 캔버스로 칸과 지렁이를 그리고, 말풍선은 HTML로 띄운 방법을 정리했어요.
WhoIsLoafing 메인 화면은 타이틀 하나와 입력창 하나뿐이에요. 디자인 원칙이 "여백 위주의 미니멀"이라 장식을 거의 쓰지 않아요. 대신 배경에 작은 장난을 하나 넣었어요. GitHub 기여도 그래프, 흔히 잔디라고 부르는 칸들이 아주 옅게 반짝이고, 그 위를 파란 지렁이 몇 마리가 기어다니면서 켜진 칸을 먹어요. 가끔 지렁이 머리 위에 "누가 제일 많이 커밋했을까요?" 같은 말풍선이 떠요.
장식이지만 서비스가 무엇을 하는지 알려 주는 역할도 해요. 이걸 어떻게 만들었는지 정리했어요.
칸은 한 번 정하고 계속 그려요
칸은 14픽셀 정사각형이고 5픽셀씩 띄워요. 화면 크기에 맞춰 몇 칸이 들어가는지 계산하고, 남는 여백은 양쪽에 나눠서 격자가 가운데 오게 해요.
cols = Math.floor(width / STEP)
rows = Math.floor(height / STEP)
offsetX = (width - cols * STEP + GAP) / 2
offsetY = (height - rows * STEP + GAP) / 2
칸마다 반짝일 세기와 속도를 정해 둬요. 전체의 30%만 켜져 있고, 나머지는 거의 보이지 않는 회색이에요. 실제 잔디도 모든 칸이 채워져 있지는 않으니까요.
const active = Math.random() < 0.3
cells.set(key(col, row), {
x: offsetX + col * STEP,
y: offsetY + row * STEP,
level: active ? 0.25 + Math.random() * 0.75 : 0,
phase: Math.random() * Math.PI * 2,
speed: 0.25 + Math.random() * 0.55,
eatenUntil: 0,
})
매 프레임 사인 함수로 밝기를 오르내려요. 칸마다 시작 위치와 속도가 달라서 다 같이 깜빡이지 않고 물결치듯 반짝여요.
지렁이는 칸의 목록이에요
지렁이는 머리부터 꼬리까지 일곱 칸의 좌표로 이뤄져요. 움직일 때는 앞에 새 칸을 하나 붙이고 꼬리 칸을 하나 떼요.
function advance(worm: Worm, delta: number, now: number) {
worm.progress += delta / WORM_STEP_MS
while (worm.progress >= 1) {
worm.progress -= 1
worm.body.unshift(worm.next)
worm.body.pop()
const eaten = cells.get(key(worm.next.col, worm.next.row))
if (eaten) eaten.eatenUntil = now + 9000 + Math.random() * 6000
pickNext(worm)
}
}
머리가 들어간 칸은 "먹힌" 칸이 돼요. 9초에서 15초 동안 꺼져 있다가 다시 반짝여요. 지렁이가 지나간 자리에 어두운 길이 남았다가 천천히 다시 채워져요.
한 칸 움직이는 데 320밀리초가 걸리는데, 칸 단위로 뚝뚝 끊기면 지렁이가 아니라 뱀 게임처럼 보여요. 그래서 progress로 다음 칸까지 얼마나 갔는지를 0에서 1 사이로 들고 있다가, 그릴 때 머리를 그만큼 앞으로 당겨 그려요.
const tip = { x: head.x + (next.x - head.x) * worm.progress, y: head.y + (next.y - head.y) * worm.progress }
꼬리 끝도 같은 비율로 한 칸 앞으로 줄여서 그려요. 머리는 늘어나고 꼬리는 줄어드니, 몸길이가 일정하게 유지되면서 부드럽게 기어가요.
몸통은 칸의 중심을 이은 굵은 선 하나예요. 선 끝과 꺾이는 곳을 둥글게 처리하면 그것만으로 지렁이 모양이 나와요. 머리에는 하얀 점 두 개로 눈을 그려요. 눈의 위치는 지렁이가 가는 방향을 기준으로 양옆에 찍어서, 방향을 바꾸면 눈도 따라 돌아가요.
대체로 직진하고 가끔 꺾어요
다음 칸은 상하좌우 네 방향 중에서 골라요. 뒤로 돌아가는 방향과 화면 밖으로 나가는 방향은 빼요.
const options = DIRECTIONS.filter(
(direction) =>
!(direction.col === -worm.direction.col && direction.row === -worm.direction.row) &&
inside(head.col + direction.col, head.row + direction.row),
)
const straight = options.find(
(direction) => direction.col === worm.direction.col && direction.row === worm.direction.row,
)
const direction =
straight && Math.random() < 0.72 ? straight : options[Math.floor(Math.random() * options.length)] ?? worm.direction
갈 수 있는 방향 중에서 무작위로 고르면 지렁이가 제자리에서 꼬불거리기만 해요. 72% 확률로 가던 방향을 유지하게 하니, 한동안 쭉 가다가 가끔 꺾는 자연스러운 움직임이 나왔어요.
헤더 뒤로는 들어가지 않아요
헤더는 반투명이라 그 뒤로 지렁이가 지나가면 흐릿하게 보여요. 머리 위 말풍선까지 생각하면 꽤 높은 공간이 필요해요. 그래서 지렁이가 다닐 수 있는 가장 윗줄을 헤더 높이와 말풍선 높이만큼 아래로 내렸어요.
minRow = Math.max(0, Math.ceil((HEADER_HEIGHT + BUBBLE_ROOM - CELL / 2 - offsetY) / STEP))
칸은 헤더 뒤에도 그려지지만, 지렁이는 그 줄 아래에서만 움직여요.
말풍선은 HTML로
말풍선까지 캔버스에 그리면 글자를 직접 배치하고 둥근 테두리와 꼬리를 그려야 해요. 글자 폭을 재고 줄바꿈을 처리하는 것도 번거로워요. 그래서 말풍선은 평범한 HTML 요소로 만들고, 위치만 그리기 루프에서 바꿔요.
const half = bubble.offsetWidth / 2
const x = Math.min(width - half - 8, Math.max(half + 8, tip.x))
bubble.style.transform = `translate(${x - half}px, ${tip.y - 18}px) translateY(-100%)`
const tailX = Math.min(half * 2 - 14, Math.max(14, tip.x - (x - half)))
tail.style.left = `${tailX}px`
말풍선은 지렁이 머리 바로 위에 뜨지만, 화면 가장자리에 가면 밖으로 나가지 않게 안쪽으로 밀어요. 이때 말풍선 꼬리는 가운데에 그대로 두지 않고 머리 쪽으로 따라가서, 누가 말하는지 헷갈리지 않아요.
위치는 리액트 상태로 관리하지 않고 요소의 스타일을 직접 바꿔요. 매 프레임 상태를 바꾸면 초당 60번 컴포넌트를 다시 그리게 돼요.
한 번에 한 마리만 말해요
여러 마리가 동시에 말하면 화면이 시끄러워요. 그래서 누군가 말하고 있으면 다음 말을 미루고, 한 마리가 3.8초 말한 뒤 1.6초 쉬었다가 다른 지렁이가 말해요.
할 말은 서비스가 해 주는 일을 알려 주는 문장과, "잔디가 참 맛있게 심어졌네요" 같은 지렁이다운 혼잣말을 섞어 뒀어요. 들어올 때마다 순서를 섞고 한 바퀴를 다 돌 때까지 같은 말을 반복하지 않아요.
const lines = [...WORM_LINES]
for (let index = lines.length - 1; index > 0; index--) {
const swap = Math.floor(Math.random() * (index + 1))
;[lines[index], lines[swap]] = [lines[swap], lines[index]]
}
가운데는 비워 둬요
배경이 타이틀과 입력창을 방해하면 안 돼요. 두 가지 장치를 뒀어요.
첫째, 캔버스에 가운데가 옅어지는 타원형 마스크를 씌웠어요. 가운데는 30% 밝기로만 보이고 바깥으로 갈수록 선명해져요. 입력창은 반투명에 블러가 걸려 있어서, 그 뒤로 지렁이가 흐릿하게 지나가요.
둘째, 지렁이가 가운데 근처에 있을 때는 말풍선을 숨겨요. 말은 계속하고 있지만, 타이틀 위에 글자가 겹치지 않게 잠깐 투명하게 해요.
const nearCenter = Math.abs(tip.x - width / 2) < Math.min(340, width * 0.46) && Math.abs(tip.y - height / 2) < 130
움직임 줄이기 설정에서는 멈춰요
운영체제에서 움직임 줄이기를 켠 사람에게는 한 번만 그리고 멈춰요. 칸도 반짝이지 않고, 지렁이도 기어가지 않고, 말도 하지 않아요. 지렁이는 처음 놓인 자리에 가만히 있어요.
정리
- 격자는 화면 크기에 맞춰 한 번 정하고, 칸마다 다른 세기와 속도로 반짝이게 해요.
- 지렁이는 칸 좌표의 목록이고, 머리를 붙이고 꼬리를 떼며 움직여요. 칸 사이의 진행도를 들고 있다가 그릴 때 보간하면 부드러워져요.
- 대체로 직진하고 가끔 꺾게 하면 자연스럽게 기어다녀요.
- 글자가 들어가는 말풍선은 HTML로 띄우고 위치만 직접 바꿔요.
- 장식은 핵심 화면을 가리지 않게, 가운데를 옅게 하고 말풍선을 숨겨요.