「네온 다이브」는 폰 세로 화면용으로 만들었다. 그런데 공개는 브라우저 버전이 먼저였다. 주소 하나만 보내면 설치 없이 바로 해 볼 수 있어서다.

WebGL 빌드 자체는 「오늘 누가 쏘냐?」 4편에서 한 번 겪은 일이라 순조로웠다. 한글 폰트를 Resources에 넣고, Brotli 압축과 압축 해제 폴백, IL2CPP 크기 최적화, 스트리핑 High를 그대로 가져왔다. 결과는 wasm 5.0MB, 데이터 2.1MB, 로더와 프레임워크를 합쳐 약 7MB다.

이번에 새로 손본 것은 세로 게임을 가로로 넓은 PC 화면과 폰 화면에 각각 맞추는 일이었다.

템플릿 하나로 PC와 폰을 나눈다

Unity 기본 WebGL 템플릿은 빌드 설정의 고정 크기(기본 960x600) 캔버스를 페이지 가운데 둔다. 세로 게임에는 맞지 않는다. 프로젝트에 Assets/WebGLTemplates/NeonDive/ 템플릿을 따로 만들고, 빌드 스크립트에서 이 템플릿을 쓰게 했다.

PlayerSettings.WebGL.template = "PROJECT:NeonDive";
// PC 브라우저에서 캔버스를 세로 비율로 (모바일 브라우저는 템플릿이 전체 화면으로 채움)
PlayerSettings.defaultWebScreenWidth = 540;
PlayerSettings.defaultWebScreenHeight = 960;
PlayerSettings.SplashScreen.show = false;

템플릿의 index.html은 사용자 에이전트로 폰과 PC를 나눈다.

PC에서는 9:16 캔버스를 브라우저 창 높이에 맞춘다. 창이 좁으면 너비 기준으로 줄인다. 창 크기가 바뀌면 다시 계산한다.

var aspect = {{{ WIDTH }}} / {{{ HEIGHT }}};
function fitCanvas() {
  var h = window.innerHeight, w = Math.round(h * aspect);
  if (w > window.innerWidth) { w = window.innerWidth; h = Math.round(w / aspect); }
  canvas.style.width = w + "px";
  canvas.style.height = h + "px";
}
fitCanvas();
window.addEventListener("resize", fitCanvas);

캔버스 양옆 빈 곳은 페이지 배경을 게임 배경과 같은 남색(#0B1020)으로 칠해 경계가 보이지 않게 했다.

PC 브라우저. 세로 캔버스가 창 높이에 맞춰 가운데에 있다

폰에서는 뷰포트 메타 태그를 넣고 캔버스를 화면 전체로 채운다. 게임 쪽 CanvasFitter가 화면 비율에 맞춰 1080x1920 기준 영역이 항상 다 보이게 조정하니, 템플릿은 크기만 넘겨주면 된다.

if (/iPhone|iPad|iPod|Android/i.test(navigator.userAgent)) {
  // ... viewport 메타 태그 추가, 캔버스를 100% x 100% 로
  // 고해상도 폰(DPR 3)에서 렌더 해상도를 2배까지만 — 프레임 유지용
  config.devicePixelRatio = Math.min(window.devicePixelRatio || 1, 2);
}

요즘 폰은 기기 픽셀 비율이 3인 경우가 많다. 그대로 두면 390px 폭 화면이 1170px로 렌더링된다. 블록과 바는 둥근 사각형과 글로뿐이라 2배로도 충분히 선명해서, 렌더 해상도를 2배까지로 묶어 그리는 픽셀 수를 절반 넘게 줄였다.

폰 브라우저. 화면 전체가 게임 캔버스다

로딩 중 캔버스가 회색 상자였다

배포하고 PC 브라우저로 열어 보니 로딩하는 몇 초 동안 화면 가운데에 회색 기둥이 서 있었다. 페이지 배경은 남색인데 캔버스 자리만 회색이었다. 로딩이 끝나 게임이 그려지면 사라지니 기능에는 문제가 없지만, 첫인상이 그 회색 상자였다.

수정 전 로딩 화면. 캔버스 자리만 회색 상자로 보인다

원인은 템플릿 CSS의 이 한 줄이었다. 기본 템플릿에서 복사해 온 그대로였다.

#unity-canvas { background: {{{ BACKGROUND_COLOR }}}; touch-action: none; outline: none }

{{{ BACKGROUND_COLOR }}}는 빌드할 때 Player Settings의 스플래시 화면 배경색으로 바뀐다. 기본값은 #231F20, 거의 검정에 가까운 회색이다. 이 게임은 스플래시 화면을 끄고 배경색을 따로 정하지 않았으니 기본값이 그대로 들어갔다. 남색 페이지 위에서는 이 회색이 또렷하게 드러난다.

게임 배경색을 Player Settings에 맞춰 넣는 방법도 있지만, 템플릿이 쓰는 색을 템플릿 안에 두는 편이 헷갈리지 않는다. 캔버스 배경을 게임 첫 팔레트의 배경색으로 고정했다.

#unity-canvas { background: #0B1020; touch-action: none; outline: none }

다시 빌드해 올린 뒤 로딩 중 캔버스의 계산된 배경색이 rgb(11, 16, 32)인 것을 확인했다.

로딩 화면 수정 전(왼쪽)과 후(오른쪽)

화면 방향 고정은 웹에서 빼다

Android 빌드에서는 시작할 때 화면을 세로로 고정한다.

#if !UNITY_WEBGL
    // 웹은 브라우저가 화면 방향을 정하므로 호출하지 않는다.
    Screen.orientation = ScreenOrientation.Portrait;
#endif

웹에서 이걸 부르면 브라우저의 화면 방향 잠금 API를 호출하는데, 데스크톱 브라우저는 이를 지원하지 않아 콘솔에 NotSupportedError가 남는다. 폰 브라우저에서도 전체 화면이 아니면 잠글 수 없다. 웹에서는 방향을 브라우저에 맡기고 이 호출을 뺐다. 엔진이 시작하면서 기본 방향으로 한 번 부르는 호출이 남아 콘솔에 같은 오류가 한 줄 찍히긴 하지만, 동작에는 영향이 없다.

파일명이 해시라 캐시를 1년으로

빌드 설정에서 nameFilesAsHashes를 켜면 Build/ 폴더의 파일 이름이 내용 해시가 된다.

Build/493f696c….loader.js
Build/0571ebf4….framework.js.unityweb
Build/77334fb5….data.unityweb
Build/af93f6e1….wasm.unityweb

내용이 바뀌면 이름도 바뀐다. 그래서 Build/ 아래는 브라우저가 1년 동안 캐시해도 된다. 대신 이 파일 이름들을 가리키는 index.html은 5분만 캐시한다. 새로 배포하면 길어야 5분 뒤에 새 index.html을 받고, 거기 적힌 새 파일 이름으로 새 빌드를 받는다. 두 번째 방문부터는 7MB를 다시 받지 않는다.

<staticContent>
  <clientCache cacheControlMode="UseMaxAge" cacheControlMaxAge="00:05:00" />
</staticContent>

<location path="Build">
  <system.webServer>
    <staticContent>
      <clientCache cacheControlMode="UseMaxAge" cacheControlMaxAge="365.00:00:00" />
    </staticContent>
  </system.webServer>
</location>

해시 파일명에는 부작용이 하나 있다. 빌드할 때마다 이름이 달라지니 출력 폴더에 이전 빌드 파일이 계속 쌓인다. 빌드 스크립트가 시작할 때 Build/ 폴더를 비우고, 배포할 때는 robocopy /MIR로 대상 폴더를 빌드 결과와 똑같이 맞춰 옛 파일을 지운다. /MIR은 대상에만 있는 파일을 지우는 옵션이라, 경로를 잘못 주면 위험하다. 대상이 드라이브 루트이거나 저장소 안이면 배포를 거부하게 했다.

web.config는 빌드가 끝날 때 빌드 스크립트가 출력 폴더에 복사한다. wasm·unityweb MIME 등록, http → https 이동, 위의 캐시 설정이 들어 있다.

첫 배치 빌드가 "6번 다시 돌렸다"며 실패했다

빌드는 에디터를 열지 않고 PowerShell 스크립트로 Unity를 배치 모드로 돌린다. 그런데 이 프로젝트의 첫 WebGL 배치 빌드가 실패했다. 로그에는 이런 줄이 있었다.

Backend has requested a buildprogram run 6 times

Unity 빌드 시스템은 빌드 도중 필요한 것이 생기면 빌드 프로그램을 다시 돌리는데, 그 횟수에 한도가 있다. 처음 하는 WebGL 빌드는 준비할 것이 많아 이 한도에 걸릴 수 있다. Library\Bee 캐시를 지우지 않고 같은 명령을 한 번 더 돌리니 앞 단계 결과를 이어 받아 통과했다.

그래서 빌드 스크립트에 이 오류일 때만 한 번 더 돌리는 재시도를 넣었다. 다른 오류는 바로 실패로 끝낸다.

for ($attempt = 1; $attempt -le 2; $attempt++) {
    $p = Start-Process -FilePath $UnityPath -ArgumentList $unityArgs -Wait -PassThru
    if ($p.ExitCode -eq 0) { break }

    $rerunLimit = (Test-Path $log) -and (Select-String -Path $log -Pattern "requested a buildprogram run" -Quiet)
    if ($attempt -lt 2 -and $rerunLimit) {
        Write-Host "[NeonDive] 빌드 프로그램 재실행 한도 초과 — 캐시를 유지한 채 한 번 더 빌드합니다."
        continue
    }
    exit $p.ExitCode
}

그 밖에 에디터에 프로젝트가 열려 있으면 배치 빌드가 실패하므로 먼저 막고, 이전 실행이 남긴 잠금 파일만 있으면 지우고 진행한다. 이 부분은 「오늘 누가 쏘냐?」 때 만든 스크립트를 그대로 썼다.

정리

  • 템플릿 하나에서 PC는 창 높이에 맞춘 9:16 캔버스, 폰은 전체 화면으로 나눈다. 폰은 기기 픽셀 비율을 2배까지만 쓴다.
  • 기본 템플릿의 캔버스 배경 {{{ BACKGROUND_COLOR }}}는 스플래시 배경색(기본 회색 #231F20)이다. 스플래시를 끈 게임이면 템플릿에서 직접 게임 배경색을 넣는다.
  • Screen.orientation은 웹 빌드에서 부르지 않는다.
  • 해시 파일명이면 Build/는 1년, index.html은 5분 캐시한다. 쌓이는 옛 파일은 빌드 전 비우기와 /MIR 배포로 정리한다.
  • 첫 WebGL 배치 빌드의 "buildprogram run 6 times" 실패는 캐시를 유지한 채 한 번 더 돌리면 통과한다. 스크립트에서 이 오류만 재시도한다.

연재를 마치며

다섯 편에 걸쳐 「네온 다이브」를 정리했다. 파티게임에서 다듬은 코드 골격 위에 이번에는 손맛을 만드는 숫자들을 올렸다. 니어미스 40px, 콤보 3초, 판정 박스 8%·15%, 페이즈 20초, 속도 상한 0.3씩. 하나하나는 작은 상수지만 이 값들이 게임의 느낌을 정한다.

남은 일은 폰에서 직접 해 보며 이 숫자들을 다시 맞추는 것이다. PC 마우스로는 드래그 감도와 니어미스 판정을 제대로 가늠하기 어려워서, 난이도 조정은 폰 터치로 해 본 뒤 한 번에 하기로 했다. 그다음이 리워드 광고 연동과 Play 스토어 등록이다.

그 전에 지금 버전은 브라우저에서 바로 해 볼 수 있다. → neondive.addsoft.co.kr