모바일 친화적인 웹 게임
모바일은 가장 큰 게임 플랫폼입니다. 몇 가지만 손보면 웹 게임이 휴대폰과 태블릿에서 잘 작동합니다.
1) 뷰포트 설정
확대를 막고 적절한 스케일링을 보장합니다.
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">2) 전체 화면 canvas
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
html, body {
width: 100%;
height: 100%;
overflow: hidden;
background: #000;
touch-action: none; /* 브라우저 제스처 방지 */
}
#game {
display: block;
width: 100%;
height: 100%;
}UI 크기를 height: 100% 대신 뷰포트 높이를 기준으로 잡는다면, vh보다 동적 뷰포트 단위인 dvh, svh, lvh를 쓰는 게 좋습니다. 이 단위들은 모바일 브라우저의 접혔다 펴지는 툴바를 반영하는데, 100vh는 이를 무시합니다 (iOS에서 100vh는 툴바가 숨겨진 높이라서 레이아웃 하단이 잘립니다). 이 단위들은 Baseline Widely Available에 도달했고 Chrome/Edge 108+, Firefox 101+, Safari/iOS Safari 15.4+에서 지원되며, 대략 사용자의 92%에 해당합니다.
3) 터치 이벤트 처리
const canvas = document.getElementById('game')
// Prevent default touch behaviors
canvas.addEventListener('touchstart', (e) => e.preventDefault(), { passive: false })
canvas.addEventListener('touchmove', (e) => e.preventDefault(), { passive: false })
// Track touches
const touches = new Map()
canvas.addEventListener('touchstart', (e) => {
for (const touch of e.changedTouches) {
touches.set(touch.identifier, {
x: touch.clientX,
y: touch.clientY,
startX: touch.clientX,
startY: touch.clientY,
})
}
})
canvas.addEventListener('touchmove', (e) => {
for (const touch of e.changedTouches) {
const t = touches.get(touch.identifier)
if (t) {
t.x = touch.clientX
t.y = touch.clientY
}
}
})
canvas.addEventListener('touchend', (e) => {
for (const touch of e.changedTouches) {
touches.delete(touch.identifier)
}
})게임이 마우스나 스타일러스도 지원해야 한다면 터치 이벤트 대신 Pointer Events(pointerdown/pointermove/pointerup)를 고려하세요. MDN은 이제 이를 기기에 구애받지 않는 단일 입력 모델로 권장합니다. 하나의 코드 경로로 터치, 마우스, 펜을 모두 처리하고, pointerId(위에서 쓴 touch.identifier에 해당)를 통해 멀티터치도 여전히 작동합니다. 터치 전용 게임이라면 터치 이벤트도 전혀 문제없습니다.
4) 가상 조이스틱
class VirtualJoystick {
constructor(x, y, radius) {
this.baseX = x
this.baseY = y
this.radius = radius
this.knobRadius = radius * 0.4
this.active = false
this.touchId = null
this.dx = 0
this.dy = 0
}
handleTouchStart(touch) {
const dist = Math.hypot(touch.clientX - this.baseX, touch.clientY - this.baseY)
if (dist < this.radius) {
this.active = true
this.touchId = touch.identifier
this.updatePosition(touch.clientX, touch.clientY)
}
}
handleTouchMove(touch) {
if (touch.identifier === this.touchId) {
this.updatePosition(touch.clientX, touch.clientY)
}
}
handleTouchEnd(touch) {
if (touch.identifier === this.touchId) {
this.active = false
this.touchId = null
this.dx = 0
this.dy = 0
}
}
updatePosition(x, y) {
let dx = x - this.baseX
let dy = y - this.baseY
const dist = Math.hypot(dx, dy)
if (dist > this.radius) {
dx = (dx / dist) * this.radius
dy = (dy / dist) * this.radius
}
this.dx = dx / this.radius
this.dy = dy / this.radius
}
draw(ctx) {
// Base circle
ctx.beginPath()
ctx.arc(this.baseX, this.baseY, this.radius, 0, Math.PI * 2)
ctx.fillStyle = 'rgba(255, 255, 255, 0.2)'
ctx.fill()
// Knob
const knobX = this.baseX + this.dx * this.radius
const knobY = this.baseY + this.dy * this.radius
ctx.beginPath()
ctx.arc(knobX, knobY, this.knobRadius, 0, Math.PI * 2)
ctx.fillStyle = 'rgba(255, 255, 255, 0.5)'
ctx.fill()
}
getInput() {
return { x: this.dx, y: this.dy }
}
}5) 터치 버튼
class TouchButton {
constructor(x, y, radius, label) {
this.x = x
this.y = y
this.radius = radius
this.label = label
this.pressed = false
this.touchId = null
}
contains(px, py) {
return Math.hypot(px - this.x, py - this.y) < this.radius
}
handleTouchStart(touch) {
if (this.contains(touch.clientX, touch.clientY)) {
this.pressed = true
this.touchId = touch.identifier
}
}
handleTouchEnd(touch) {
if (touch.identifier === this.touchId) {
this.pressed = false
this.touchId = null
}
}
draw(ctx) {
ctx.beginPath()
ctx.arc(this.x, this.y, this.radius, 0, Math.PI * 2)
ctx.fillStyle = this.pressed ? 'rgba(255, 255, 255, 0.5)' : 'rgba(255, 255, 255, 0.2)'
ctx.fill()
ctx.font = 'bold 24px sans-serif'
ctx.fillStyle = '#fff'
ctx.textAlign = 'center'
ctx.textBaseline = 'middle'
ctx.fillText(this.label, this.x, this.y)
}
}6) 화면 방향 처리
function checkOrientation() {
const isLandscape = window.innerWidth > window.innerHeight
if (!isLandscape) {
showRotatePrompt()
} else {
hideRotatePrompt()
resizeCanvas()
}
}
window.addEventListener('resize', checkOrientation)
window.addEventListener('orientationchange', checkOrientation)
function showRotatePrompt() {
document.getElementById('rotate-prompt').style.display = 'flex'
}
function hideRotatePrompt() {
document.getElementById('rotate-prompt').style.display = 'none'
}7) 성능 최적화
// Reduce resolution on low-end devices
function getDeviceScale() {
const dpr = window.devicePixelRatio || 1
const isLowEnd = navigator.hardwareConcurrency <= 2
return isLowEnd ? Math.min(dpr, 1.5) : Math.min(dpr, 2)
}
// Throttle expensive operations
let lastHeavyUpdate = 0
function maybeDoHeavyWork(timestamp) {
if (timestamp - lastHeavyUpdate > 100) { // 10 fps for heavy stuff
doExpensiveCalculations()
lastHeavyUpdate = timestamp
}
}
// Use lower quality assets
function getAssetQuality() {
const memory = navigator.deviceMemory || 4
if (memory <= 2) return 'low'
if (memory <= 4) return 'medium'
return 'high'
}8) 배터리 고려 사항
// Check battery and reduce effects
async function checkBattery() {
if ('getBattery' in navigator) {
const battery = await navigator.getBattery()
if (battery.level < 0.2 && !battery.charging) {
enableBatterySaver()
}
battery.addEventListener('levelchange', () => {
if (battery.level < 0.2 && !battery.charging) {
enableBatterySaver()
}
})
}
}
function enableBatterySaver() {
game.particleCount = Math.floor(game.particleCount * 0.5)
game.targetFps = 30
console.log('Battery saver enabled')
}9) Fullscreen API
async function requestFullscreen() {
const elem = document.documentElement
try {
if (elem.requestFullscreen) {
await elem.requestFullscreen()
} else if (elem.webkitRequestFullscreen) {
await elem.webkitRequestFullscreen()
}
// Lock orientation
if (screen.orientation?.lock) {
await screen.orientation.lock('landscape')
}
} catch (err) {
console.warn('Fullscreen failed:', err)
}
}
// Call on user interaction
document.getElementById('play-btn').addEventListener('click', () => {
requestFullscreen()
startGame()
})한 가지 주의할 점이 있습니다. iPhone에서는 Fullscreen API가 일반 요소에는 작동하지 않습니다. iOS Safari는 네이티브 video 요소에만 전체 화면을 노출하므로(비표준 webkitEnterFullscreen 사용), documentElement.requestFullscreen()은 iPhone에서 거부됩니다. 위의 try/catch가 이를 무리 없이 처리하니, iPhone에서는 진짜 전체 화면 없이도 플레이할 수 있게 게임을 설계하고, 지원되는 iPad, Android, 데스크톱에서는 전체 화면을 부가 기능으로 다루세요. screen.orientation.lock() 호출도 iPhone에서는 같은 제약이 있으니 여기에 의존하지 마세요.
10) 테스트 체크리스트
출시 전 점검 사항:
- 터치 반응성 — 에뮬레이터만이 아니라 실제 기기에서 테스트하세요
- 다양한 화면 크기 — 휴대폰, 태블릿, iPad Pro
- 두 방향 모두 — 또는 하나로 고정
- 저사양 기기 — 2~3년 된 휴대폰에서 테스트
- 느린 네트워크 — 3G 시뮬레이션으로 테스트
- Safari iOS — 오디오, 전체 화면에서 특이한 동작이 있음
- Chrome Android — 가장 흔한 브라우저
- 방해 요소 — 전화, 알림
- 메모리 압박 — 백그라운드 탭에서 돌아온 뒤 복구되나요?
// Debug touch visualization
function drawTouchDebug(ctx) {
for (const [id, touch] of touches) {
ctx.beginPath()
ctx.arc(touch.x, touch.y, 30, 0, Math.PI * 2)
ctx.fillStyle = 'rgba(255, 0, 0, 0.5)'
ctx.fill()
ctx.fillStyle = '#fff'
ctx.fillText(id.toString(), touch.x, touch.y)
}
}관련 글
- 게임 입력 처리
- 반응형 게임 캔버스
- 오프라인 게임을 위한 PWA
- 게임용 Web Audio API — iOS 오디오 제약 처리
- 빠르게 로드되는 웹 게임 출시하기 — 모바일 연결을 위한 로드 시간 최적화
- Iframe 게임 임베딩 — 모바일 특유의 임베드 고려 사항
외부 자료
- MDN: Touch events — 멀티터치 처리
- MDN: Screen Orientation API — 방향 고정
- MDN: Fullscreen API — 모바일에서 전체 화면 진입
- web.dev: Responsive design — 게임 UI에도 적용되는 반응형 패턴