크리스탈 가디언즈 제작기 — 빌드 도구 없이 만든 Canvas 타워디펜스
브라우저에서 바로 하는 타워디펜스 게임을 하나 만들었습니다. 이름은 크리스탈 가디언즈이고, 지금 k0.co.kr/games/crystal에서 설치 없이 플레이할 수 있습니다.
이 글은 "무엇을 만들었나"보다 "만들면서 부딪힌 문제를 어떻게 풀었나"에 집중합니다. 특히 두 가지가 기억에 남습니다. 하나는 후반 스테이지가 수학적으로 깰 수 없게 되어 있던 밸런스 버그이고, 다른 하나는 브라우저 없이 Node에서 Canvas 게임을 통째로 테스트한 방법입니다.
게임 소개
- 16 스테이지 — 숲, 사막, 설원, 마계 4개 월드에 월드마다 4 스테이지. 클리어 후 하드 모드와 무한 모드가 열립니다.
- 타워 6종 → 최종 12종 — 궁수, 대포, 서리 마법사, 번개 드루이드, 저격수, 성직자 토템. 3단계 강화 뒤 두 갈래로 전직합니다(예: 궁수 → 연사 궁수 / 독화살 궁수).
- 몬스터 12종 + 보스 4종 — 킹 슬라임, 사막 웜, 서리 거인, 마왕. 보스는 체력에 따라 페이즈가 바뀝니다.
- 직접 조작하는 영웅 3종 — 빈 땅을 클릭하면 영웅이 이동해서 길을 막고, Q/W로 스킬을 씁니다.
- 영구 업그레이드 30노드 — 스테이지에서 얻은 별로 삽니다. 진행 상황은 브라우저에 저장됩니다.
몬스터마다 약점이 달라서 한 가지 타워만 쌓으면 막힙니다. 방패병은 물리 방어가 높고 망령은 마법 저항이 높으며, 박쥐는 길을 무시하고 크리스탈로 곧장 날아옵니다(대포는 공중을 못 칩니다). 그림자 도적의 은신은 저격수와 영웅만 볼 수 있고, 골렘은 둔화에 면역입니다.
설계 원칙: 빌드 도구 0, 외부 에셋 0
만든 것 = 배포되는 것
webpack이나 Vite 같은 번들러를 쓰지 않았습니다. 브라우저가 기본으로 지원하는 <script type="module">과 ES 모듈 import만으로 약 9,000줄을 나눠 관리합니다. 빌드 단계가 없으니 저장소의 src/ 폴더가 그대로 배포 결과물이 되고, 배포 이미지는 nginx에 정적 파일을 얹은 것뿐입니다.
그림은 전부 Canvas 도형으로
이미지 파일이 하나도 없습니다. 타워, 몬스터, 영웅, 지형 모두 arc, ellipse, bezierCurveTo 같은 Canvas 2D 명령으로 그립니다. 라이선스 걱정이 없고, 색이나 크기를 코드 한 줄로 바꿀 수 있고, 전직하면 모양이 달라지는 것도 같은 함수에 파라미터만 주면 됩니다.
소리는 WebAudio로 실시간 합성
효과음과 배경음악도 파일이 없습니다. 오실레이터와 게인 노드로 즉석에서 만들고, 배경음악은 작은 스텝 시퀀서가 조금 앞선 시각에 음을 예약합니다. 이렇게 해야 메인 스레드가 바쁠 때도 박자가 밀리지 않습니다. 브라우저 정책상 오디오는 첫 클릭 이후에만 켤 수 있어서, 첫 입력 때 AudioContext를 만들도록 했습니다.
밸런스 버그: 후반 월드가 수학적으로 불가능했다
처음 완성한 버전을 자동 플레이 시뮬레이터로 돌려보니 16 스테이지 중 5개만 클리어됐습니다. 배치 AI가 멍청해서가 아니었습니다. 원인은 경제 구조에 있었습니다.
무엇이 문제였나
- 스테이지마다 적 체력 배율
hpMul이 오릅니다. 첫 스테이지 1.0에서 마지막 스테이지 5.4까지. - 그런데 적을 잡았을 때 주는 골드는 스테이지와 상관없이 같았습니다.
- 타워는 골드 1당 살 수 있는 DPS가 거의 일정합니다. 비싼 타워는 그만큼 셉니다.
- 결과적으로 후반으로 갈수록 "적이 가진 총 체력 ÷ 내가 살 수 있는 총 DPS"가 계속 커집니다. 어떻게 배치해도 화력이 모자라는 구간이 반드시 옵니다.
어떻게 고쳤나
처치 골드가 스테이지 난이도를 따라 오르게 했습니다. 핵심은 한 줄입니다.
// battle.js
const stageScale = Math.pow(this.stage.hpMul, 0.88);
지수를 1.0이 아니라 0.88로 둔 것이 포인트입니다. 1.0이면 체력과 골드가 같은 비율로 올라 후반 난이도가 초반과 똑같아지고, 0.88이면 골드가 체력보다 조금 덜 올라 후반이 적당히 더 어렵습니다. 여기에 스테이지 안에서는 웨이브마다 체력이 1.055배씩 복리로 오르고, 보스는 이 보정에서 뺐습니다.
고친 뒤 시뮬레이터 결과는 업그레이드 없이 14/16, 영구 업그레이드를 산 상태에서 15/16입니다. 마지막 마왕전만 자동 플레이로 못 깹니다. 사람이 영웅 스킬과 아이템을 제때 쓰면 깰 수 있는, 의도한 난이도입니다.
교훈: 타워디펜스에서 "적이 세지는 속도"와 "플레이어가 강해지는 속도"는 따로 조정하면 안 됩니다. 둘의 비율을 하나의 변수로 관리해야 합니다.
Node에서 Canvas 게임 테스트하기
Canvas 게임은 테스트하기가 까다롭습니다. 그림이 틀려도 에러가 나지 않고 빈 화면이 될 뿐이고, 헤드리스 브라우저를 쓰자니 테스트 환경이 무거워집니다. 그래서 반대로 접근했습니다. 게임 코드가 쓰는 DOM과 Canvas API만 가짜로 만들어서, 실제 게임 코드를 Node에서 그대로 돌립니다.
Proxy로 만든 가짜 Canvas
return new Proxy(target, {
get: (t, k) => (k in t ? t[k] : undefined),
set: (t, k, v) => { t[k] = v; return true; },
});
fillRect, arc 같은 알려진 메서드는 아무것도 하지 않는 함수이고, 모르는 이름은 undefined를 돌려줍니다. 그래서 코드에서 ctx.fillRectt()처럼 오타를 내면 "not a function" 에러가 나서 테스트가 실패합니다. fillStyle 같은 속성 대입은 조용히 받아줍니다.
무엇을 검사하나
- 메뉴, 월드맵, 업그레이드, 도감 등 모든 화면에 들어가서
update()와draw()를 몇 프레임 돌립니다. - 16개 스테이지 전부 경로, 지형, 웨이브가 만들어지는지 확인합니다.
- 한 스테이지를 빠른 속도로 끝까지 돌리면서 타워를 짓고 강화하고 12가지 전직을 모두 거칩니다.
- 보스 4종의 페이즈 전환, 저장 → 불러오기 왕복, 성능 가드(적 200마리 + 타워 30개에서
update()한 프레임 약 0.8ms).
draw()까지 호출하는 게 중요합니다. 그리기 코드의 버그는 브라우저에서는 빈 화면으로만 보이지만 여기서는 예외로 잡힙니다. 다만 가짜 Canvas는 Proxy 오버헤드가 있어서 그리기 속도 측정은 의미가 없고, 성능 가드는 update()만 봅니다.
같은 가짜 DOM 위에서 앞에서 말한 밸런스 시뮬레이터도 돌아갑니다. 게임 규칙을 따로 흉내 낸 모델이 아니라 실제 게임 코드로 16 스테이지를 자동 플레이하기 때문에, 수치를 바꾸면 결과가 바로 반영됩니다.
배포: 푸시하면 테스트부터 라이브 확인까지
집에 있는 k3s 서버 한 대에서 돌아갑니다. 저장소에 푸시하면 자체 호스팅 Gitea Actions가 다음 순서로 진행하고, 한 단계라도 실패하면 배포가 멈춥니다.
- 헤드리스 테스트
- 밸런스 시뮬레이션 (참고용, 결과만 기록)
- 컨테이너 이미지 빌드 (nginx:alpine +
src/) - Trivy 보안 스캔 — 고칠 수 있는 CRITICAL 취약점이 있으면 배포 중단
- 쿠버네티스에 롤링 배포 후 실제 주소로 접속해 확인
Trivy는 실제로 한 번 배포를 막았습니다. 베이스 이미지 nginx:1.27-alpine에 들어 있던 openssl에 CRITICAL 취약점이 있었고, Dockerfile에 apk upgrade를 넣어서 해결했습니다. 게임 코드와는 상관없는 문제라 스캔이 없었으면 몰랐을 겁니다.
배포 직후 확인에서도 함정이 하나 있었습니다. kubectl rollout status는 새 파드가 준비됐다고 보고하자마자 끝나지만, 그 순간에는 서비스 연결이 아직 갱신 중이라 접속이 거부될 수 있습니다. 한 번만 확인하면 가끔 멀쩡한 배포가 실패로 찍혀서, 몇 초 간격으로 재시도하도록 바꿨습니다.
마무리
빌드 도구와 에셋 없이도 꽤 큰 게임을 만들 수 있었습니다. 가장 도움이 된 것은 그래픽이나 사운드가 아니라 실제 게임 코드를 그대로 돌리는 테스트와 시뮬레이터였습니다. 밸런스를 감으로 맞췄다면 "후반이 좀 어렵네" 정도로 넘어갔을 문제를 숫자로 찾아낼 수 있었습니다.
게임은 k0.co.kr/games/crystal에서 해볼 수 있습니다. PC에서는 F 키로 전체화면을 켜면 편하고, 모바일은 가로 모드로 하면 됩니다. 저장은 브라우저에 되므로 브라우저 데이터를 지우면 진행 상황도 사라집니다.
댓글 0
로그인 후 댓글을 작성할 수 있습니다.