Canvas 2D만으로 브라우저 퍼즐 게임 만들기
텅텅은 게임 엔진도, 외부 라이브러리도, 빌드 도구도 쓰지 않습니다. 브라우저에 원래 들어 있는 기능만으로 만들었습니다. 왜 그렇게 했고, 어디서 막혔고, 어떻게 풀었는지 기록해 둡니다.
왜 DOM이 아니라 Canvas인가
사과 144개를 div로 만들면 코드는 훨씬 쉽습니다. CSS로 꾸미고 클래스만 갈아끼우면 되니까요.
실제로 처음엔 그 방향을 고민했습니다.
문제는 드래그입니다. 이 게임은 마우스를 끄는 내내 어떤 사과가 선택됐는지 실시간으로 갱신해야 합니다.
DOM으로 하면 매 mousemove마다 144개 엘리먼트의 클래스를 확인하고 바꿔야 하고,
그때마다 브라우저가 레이아웃과 페인트를 다시 계산합니다. 손이 빠른 사람은 초당 수십 번 이벤트를 발생시킵니다.
Canvas는 반대입니다. 매 프레임 전체를 처음부터 다시 그립니다. 비효율적으로 들리지만, 144개 원과 숫자를 그리는 건 요즘 브라우저에 아무 부담이 아닙니다. 대신 "무엇이 바뀌었는지 추적하는" 복잡함이 통째로 사라집니다. 상태를 바꾸고 다시 그리면 끝입니다.
사과를 이미지 없이 그리기
이미지 파일을 쓰지 않은 이유는 두 가지입니다. 화면 크기에 따라 사과 지름이 20px에서 80px까지 변하는데 래스터 이미지는 확대하면 뭉개집니다. 그리고 로딩할 파일이 하나도 없으면 첫 화면이 즉시 뜹니다.
그래서 사과는 매번 코드로 그립니다. 구성은 다섯 겹입니다.
- 바닥에 눌린 타원 그림자
- 줄기 —
quadraticCurveTo로 살짝 휜 선 - 잎 — 회전시킨 타원
- 몸통 — 왼쪽 위에서 시작하는
createRadialGradient - 광택과 눈, 그리고 숫자
핵심은 그라디언트의 중심을 원의 중심이 아니라 왼쪽 위로 밀어둔 것입니다. 이것만으로 평평한 원이 구체로 보입니다. 조명이 왼쪽 위에 있다고 눈이 착각하기 때문입니다.
드래그 판정 — 칸이 아니라 사과에 맞춘다
처음에는 드래그한 사각형이 걸치는 칸을 전부 선택하도록 만들었습니다. 그랬더니 조작감이 이상했습니다. 사과 사이 빈 공간을 살짝 스쳤을 뿐인데 원하지 않은 사과가 딸려 들어옵니다.
고친 방법은 간단합니다. 판정 영역을 칸 전체가 아니라 안쪽으로 10% 줄인 사각형으로 잡았습니다.
이러면 "사과에 실제로 닿았을 때만" 선택됩니다. 눈에 보이는 것과 판정이 일치하니 조작이 예측 가능해집니다. 반대로 너무 많이 줄이면 정확히 중앙을 노려야 해서 답답해집니다. 10%가 그 사이의 타협점이었습니다.
교차 판정 자체는 축 정렬 사각형끼리라 단순합니다. 다만 전체 144칸을 매번 훑지 않고, 드래그 사각형이 걸치는 행·열 범위만 계산해서 그 안만 순회합니다.
절대 못 깨는 판을 줄이기
1~9를 그냥 무작위로 뿌리면 문제가 하나 생깁니다. 전체 합이 10의 배수가 아니면 아무리 잘해도 사과를 전부 지우는 게 수학적으로 불가능합니다. 10씩 빼나가는 게임이니 총합이 10으로 나누어떨어져야 0이 되기 때문입니다.
무작위로 채우면 이 조건을 만족할 확률이 10분의 1입니다. 그래서 판을 만든 뒤, 남는 값만큼 2 이상인 칸을 골라 1씩 줄여 총합을 맞춥니다. 1은 더 줄일 수 없으니 건너뜁니다.
다만 이건 필요조건일 뿐 충분조건이 아닙니다. 합이 맞아도 배치 때문에 끝까지 못 지우는 판은 여전히 나옵니다. 진짜로 풀 수 있는 판만 내주려면 생성할 때마다 솔버를 돌려야 하는데, 탐색 공간이 커서 100초짜리 캐주얼 게임에 넣을 비용은 아니라고 판단했습니다.
효과음을 파일 없이 만들기
mp3를 넣으면 용량이 늘고 로딩이 생깁니다. 대신 Web Audio API로 그때그때 소리를 합성합니다. 오실레이터를 만들고, 볼륨을 순간적으로 올렸다가 지수적으로 떨어뜨리면 짧은 "뿅" 소리가 납니다.
재미있는 부분은 연속 성공할수록 음이 올라가게 한 것입니다. 기본 주파수에 연속 횟수를 곱해 더하면, 잘 풀릴 때 소리가 점점 높아져서 잘하고 있다는 느낌이 강해집니다. 코드 두 줄인데 체감 차이가 컸습니다.
주의할 점은 브라우저가 사용자 조작 없이는 오디오를 차단한다는 것입니다.
그래서 AudioContext를 페이지 로드 시점이 아니라 첫 소리를 낼 때 생성합니다.
그때는 이미 사용자가 클릭한 뒤라 차단되지 않습니다.
가장 오래 붙잡은 문제 — 화면 맞추기
16 × 9 판을 어떤 화면에서도 스크롤 없이 보여주는 게 제일 까다로웠습니다.
처음에는 게임판 최대 폭을 860px로 고정했습니다. 노트북에서는 괜찮았지만 27인치 모니터에서는 화면이 남아도는데 판만 작았습니다.
그래서 가로와 세로 중 더 빡빡한 쪽에 맞추도록 바꿨습니다. 가로만 보면 큰 모니터에서 판이 너무 커져 세로로 넘치기 때문입니다.
여기서 두 번째 함정에 빠졌습니다. 게임판 밖에서 쓰는 세로 공간을 280px 상수로 박아둔 겁니다. 휴대폰을 가로로 눕히면 화면 높이가 390px 남짓입니다. 거기서 280을 빼면 9줄에 110px밖에 안 남고, 셀이 최소값으로 고정되면서 판이 320px로 쪼그라들었습니다.
해법은 상수를 버리고 상단바·HUD·타임바의 실제 높이를 매번 재는 것이었습니다. 이걸 고치자 가로 모드 게임판이 320px에서 630px로 넓어졌습니다.
덤으로 하나 더 있었습니다. 점수판 폭을 게임판 폭에 묶어놨더니, 판이 좁아지면 점수판이 두 줄로 벌어지고, 그만큼 높이를 빼앗겨 판이 더 좁아지는 악순환이 생겼습니다. 좁은 화면에서는 점수판을 화면 전체 폭으로 풀어서 끊었습니다.
작은 화면에서는 귀여움을 포기한다
사과에는 눈이 달려 있습니다. 드래그로 잡히면 눈이 커지면서 위를 쳐다봅니다. 색깔로만 알려주는 것보다 훨씬 직관적이라 마음에 드는 장치였습니다.
그런데 휴대폰에서는 사과 지름이 20px 남짓입니다. 그 안에 눈 두 개와 숫자를 넣으면 숫자가 8px까지 줄어들고, 검은 외곽선이 획을 잡아먹어 3과 8이 구분되지 않습니다.
그래서 칸이 30px보다 작으면 눈을 아예 그리지 않습니다. 대신 숫자를 사과 가운데로 옮기고 40% 키우며, 외곽선은 얇게 줄입니다. 귀여움과 가독성 중에 골라야 한다면 가독성이 이깁니다. 숫자가 안 보이면 게임 자체가 성립하지 않으니까요.
정리
전체 코드는 700줄이 안 됩니다. 의존성은 0개입니다. 오래 걸린 곳은 알고리즘이 아니라 전부 "실제 기기에서 어떻게 보이는가"였습니다.
브라우저 게임을 만들 계획이 있으시다면, 로직보다 화면 맞추기에 시간을 더 잡아두시길 권합니다. 그리고 상수로 박아둔 픽셀 값은 언젠가 반드시 발목을 잡습니다.