C 언어를 배우면서 처음 만들어 본 게임이 Snake였다. 먹이를 먹으면 뱀이 길어지고, 벽이나 자기 몸에 닿으면 끝나는 게임. 규칙은 몇 줄로 설명할 수 있는데, 직접 만드는 일은 달랐다. 그때는 이것 하나를 만드는 것도 힘들어서 밤새 디버깅했던 기억이 난다.
바이브코딩으로 고전게임을 만들어 보기로 하면서 첫 순서로 Snake를 골랐다. 처음 만들어 봤던 게임을 이번에는 AI와 다시 만들어 보는 셈이다. 예전에 붙잡고 있던 게임이 지금은 어떤 과정을 거쳐 만들어지는지 남겨 보기로 했다.
이번에는 Claude Code의 Sonnet을 사용했다. Sonnet을 고른 이유는 토큰 사용 부담 때문이다. 구독 한도 안에서 게임을 만들고 여러 번 수정하려면 사용량도 신경 쓰게 된다. 내 사용 방식에서는 Sonnet이 가격 대비 효율이 좋다고 판단했다. 먼저 만들고 싶은 게임과 실행 방식을 설명하고, 나온 결과를 확인하면서 요청을 구체화해 갔다. 처음부터 끝까지 한 번에 맡기기보다는, 움직이는 화면을 보고 하나씩 고쳐 보기로 했다.
설치 없이, HTML 파일 하나로
조건은 분명했다. PC에서 HTML 파일을 열면 실행되는 게임. 설치나 인터넷 연결이 필요 없어야 했다. 로그인이나 온라인 순위표는 넣지 않았다. 방향키로 움직여 먹이를 먹고, 부딪히면 다시 시작할 수 있는 Snake부터 만들기로 했다.
첫 제작 요청에는 20×20 게임판, 먹이, 점수, 벽과 몸통의 충돌 판정을 넣었다. 화면은 크림색 바탕에 짙은 녹색으로 부탁했다. 기본 동작을 먼저 받아 보고, 그 결과를 확인하며 고치는 순서로 진행했다.
첫 요청의 요지
외부 라이브러리 없이 HTML·CSS·JavaScript로 Snake를 만들어 줘. 방향키와 WASD로 조작하고, 먹이를 먹으면 길이와 점수가 늘어나게 해 줘. 게임 규칙과 화면 코드는 나누어 나중에 오류를 확인할 수 있게 해 줘.
시작을 눌렀는데 안내문이 남았다
첫 화면은 갖춰졌지만, 실행해 보니 바로 고칠 부분이 보였다. 시작 안내가 게임판을 계속 가리고 있었다. 그 뒤에서는 뱀이 움직였고, 방향을 바꾸지 않자 벽에 닿아 0점으로 끝났다. 그런데도 가운데에는 시작 안내와 버튼이 남아 있었다.
안내를 숨기는 코드가 없었던 것은 아니다. 숨김 속성을 적용했지만, 화면을 배치하는 CSS의 표시 규칙이 우선하고 있었다. 후속 버전에서는 숨긴 요소가 실제로 보이지 않도록 스타일을 정리했다. 수정 뒤에는 시작 버튼을 누르면 안내가 사라지고 게임판이 드러났다.
코드에 기능이 적혀 있다는 것과 화면에서 제대로 동작한다는 것은 달랐다. 시작 화면만 보고 넘어갔다면 놓쳤을 문제다.
방향키 두 번에 게임이 끝나는 이유
입력 처리에서도 오류가 나왔다. 오른쪽으로 가는 도중, 다음 이동 전에 위쪽과 왼쪽 입력을 연달아 넣는 조건을 자동 테스트로 재현했다. 위로 한 번 꺾여야 하는데 마지막 왼쪽 입력이 앞선 입력을 덮어썼다. 결과는 오른쪽에서 왼쪽으로 바로 반전, 그리고 자기 몸과 충돌이었다.
여기서는 수정 요청을 구체적으로 바꿨다. “방향키가 이상하다” 대신 어떤 순서로 입력했을 때 무엇이 일어났는지 전달했다.
수정 요청의 요지
오른쪽으로 이동 중 한 번의 이동 간격 안에 위쪽, 왼쪽 입력을 넣으면 즉시 게임 오버가 된다. 한 번 이동할 때 유효한 방향 전환을 하나만 받아 줘. 같은 방향을 다시 누른 입력은 방향 전환 기회를 소모하지 않게 해 줘.
수정 뒤 같은 조건을 넣었을 때 뱀은 위로 이동했고, 게임은 계속됐다. 이 부분은 화면을 보고 추측한 결과가 아니라, 입력과 다음 상태를 비교한 테스트 결과다. 먹이를 먹을 때 길이와 점수가 늘어나는지, 비워지는 꼬리 칸을 충돌로 오해하지 않는지, 벽과 몸통 충돌 및 일시정지가 처리되는지도 함께 확인했다. 게임 규칙 테스트 11개가 통과했다.
잠깐 멈출 수 있어야 게임답다
기본 동작을 확인한 뒤에는 일시정지와 계속하기를 더했다. Space나 P를 누르거나 화면의 버튼을 누르면 멈춘다. 다른 창으로 이동했을 때도 자동으로 멈추고, 돌아왔을 때 직접 이어서 하도록 구성했다. 화면을 보지 않는 사이 게임이 끝나지 않게 하려는 조정이다.
마지막에는 기능을 더 늘리기보다 화면을 정리했다. 한글이 단어 중간에서 어색하게 끊기는 부분을 줄이고, PC 화면에서 제목과 조작 안내를 함께 볼 수 있도록 여백을 조절했다.
완성본을 내 PC에서 실행하는 방법
최종 게임은 snake.html 한 파일로 묶었다. 게임에 필요한 스타일과 코드를 안에 넣어 외부 파일을 불러오지 않는다. 아래의 전체 코드를 복사해 일반 텍스트 편집기에 붙여넣고, UTF-8 형식의 snake.html로 저장하면 된다. 파일명이 snake.html.txt가 되지 않도록 확인한 뒤 브라우저로 연다.
- 이동: 방향키 또는 W·A·S·D
- 일시정지와 재개: Space, P 또는 화면 버튼
- 점수: 먹이 하나당 10점
- 다시 시작: 게임 오버 화면의 다시하기 버튼
최고 점수는 브라우저의 로컬 저장 기능을 사용한다. 저장이 제한된 환경에서도 게임은 실행되지만, 최고 점수는 다음에 열었을 때 남지 않을 수 있다.
검증 범위: 게임 규칙 자동 테스트와 브라우저 미리보기에서 시작·일시정지·재시작을 확인했다. 제작 도구의 로컬 파일 주소 접근 제한 때문에 화면 캡처는 PC 내부의 임시 미리보기로 진행했다. 게임 파일에 서버 기능이나 외부 연결이 필요한 것은 아니며, 각 PC의 파일 열기 환경까지 모두 검증한 것은 아니다.
Snake 전체 HTML 코드 펼치기
코드 전체를 복사해 snake.html로 저장하세요.
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>스네이크 - 바이브코딩 게임 1탄</title>
<style>
:root {
--cream: #f5efd8;
--cream-dark: #e8dfbd;
--ink: #2f4a3c;
--ink-dark: #1c2f22;
--accent: #a5402d;
}
* {
box-sizing: border-box;
}
body {
margin: 0;
min-height: 100vh;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 8px;
padding: 12px;
background-color: var(--cream);
background-image: repeating-linear-gradient(
to bottom,
transparent,
transparent 27px,
rgba(47, 74, 60, 0.12) 28px
);
font-family: -apple-system, "Apple SD Gothic Neo", "Malgun Gothic", "Segoe UI", sans-serif;
color: var(--ink-dark);
word-break: keep-all;
overflow-wrap: anywhere;
}
h1 {
margin: 0;
font-size: 1.5rem;
letter-spacing: 0.1em;
color: var(--ink-dark);
border-bottom: 3px solid var(--ink-dark);
padding-bottom: 4px;
}
.num {
font-family: "Courier New", monospace;
}
.board-wrap {
position: relative;
width: 100%;
max-width: min(380px, 92vw, 52vh);
border: 4px solid var(--ink-dark);
border-radius: 6px;
box-shadow: 4px 4px 0 rgba(47, 74, 60, 0.25);
background: var(--cream);
}
canvas#board {
display: block;
width: 100%;
height: auto;
aspect-ratio: 1 / 1;
background: var(--cream);
}
.overlay {
position: absolute;
inset: 0;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 8px;
background: rgba(245, 239, 216, 0.94);
text-align: center;
padding: 10px;
}
.overlay-panel p {
margin: 0 0 8px;
max-width: 320px;
line-height: 1.5;
}
.overlay-panel .hint {
font-size: 0.85rem;
color: var(--ink);
}
button {
font-family: inherit;
font-size: 1rem;
padding: 10px 22px;
background: var(--ink-dark);
color: var(--cream);
border: 2px solid var(--ink-dark);
border-radius: 4px;
cursor: pointer;
}
button:hover {
background: var(--ink);
}
.hud {
display: flex;
flex-wrap: wrap;
gap: 12px;
align-items: center;
justify-content: center;
font-size: 0.95rem;
}
#pause-button {
padding: 4px 14px;
font-size: 0.85rem;
}
#status-message {
min-height: 1.1em;
font-size: 0.9rem;
font-weight: bold;
color: var(--accent);
text-align: center;
}
.instructions {
max-width: 380px;
font-size: 0.82rem;
text-align: center;
color: var(--ink);
}
.dpad {
display: grid;
grid-template-columns: repeat(3, 44px);
grid-template-rows: repeat(2, 44px);
gap: 6px;
justify-content: center;
}
.dpad-btn {
min-width: 44px;
min-height: 44px;
font-size: 1.1rem;
padding: 0;
line-height: 1;
}
.dpad-up { grid-column: 2; grid-row: 1; }
.dpad-left { grid-column: 1; grid-row: 2; }
.dpad-right { grid-column: 3; grid-row: 2; }
.dpad-down { grid-column: 2; grid-row: 2; }
/* 마우스/트랙패드 등 정밀 포인터를 쓰는 넓은 데스크톱 화면에서는
방향 버튼이 불필요하므로 숨긴다. 터치(coarse) 기기에서는 항상 보임. */
@media (pointer: fine) and (hover: hover) and (min-width: 640px) {
.dpad {
display: none;
}
}
[hidden] {
display: none !important;
}
</style>
</head>
<body>
<h1>스네이크</h1>
<div class="hud">
<span>점수: <strong id="score" class="num">0</strong></span>
<span>최고점수: <strong id="high-score" class="num">0</strong></span>
<button id="pause-button" type="button" hidden>일시정지</button>
</div>
<p id="status-message" role="status" aria-live="polite"></p>
<div class="board-wrap">
<canvas
id="board"
width="400"
height="400"
role="img"
aria-label="스네이크 게임판, 가로 20칸 세로 20칸"
tabindex="0"
></canvas>
<div class="overlay" id="overlay">
<div class="overlay-panel" id="panel-ready">
<p>방향키 또는 W A S D로 뱀을 움직여 먹이를 먹으세요.<br />벽이나 자기 몸에 부딪히면 게임이 끝나요.<br />Space 또는 P 키로 언제든 일시정지할 수 있어요.</p>
<button id="start-button" type="button">시작하기</button>
</div>
<div class="overlay-panel" id="panel-paused" hidden>
<p>일시정지</p>
<p class="hint">Space, P 또는 계속하기 버튼으로 다시 시작해요.</p>
<button id="resume-button" type="button">계속하기</button>
</div>
<div class="overlay-panel" id="panel-result" hidden>
<p id="result-text"></p>
<button id="restart-button" type="button">다시하기</button>
</div>
</div>
</div>
<div class="dpad">
<button class="dpad-btn dpad-up" type="button" data-dir="UP" aria-label="위로 이동">▲</button>
<button class="dpad-btn dpad-left" type="button" data-dir="LEFT" aria-label="왼쪽으로 이동">◀</button>
<button class="dpad-btn dpad-right" type="button" data-dir="RIGHT" aria-label="오른쪽으로 이동">▶</button>
<button class="dpad-btn dpad-down" type="button" data-dir="DOWN" aria-label="아래로 이동">▼</button>
</div>
<p class="instructions">20 x 20 칸 보드입니다. 방향키(↑↓←→) 또는 W/A/S/D 키로 이동, Space/P로 일시정지, 화면의 방향 버튼으로도 조작할 수 있어요.</p>
<script>
/*
* Snake 게임 순수 로직 엔진 (state/render 분리 - 이 파일은 렌더링을 전혀 하지 않음)
*
* 브라우저에서는 <script src="engine.js"><\/script> 로 불러오면
* 전역 SnakeEngine 객체로 사용할 수 있고,
* Node.js 등 CommonJS 환경에서는 require('./engine.js') 로 사용할 수 있다.
* (나중에 stage 2 이후 node 기반 테스트 코드 작성을 위해 두 방식 모두 지원)
*/
(function (root, factory) {
if (typeof module === 'object' && module.exports) {
module.exports = factory();
} else {
root.SnakeEngine = factory();
}
}(typeof globalThis !== 'undefined' ? globalThis : this, function () {
'use strict';
var GRID_SIZE_DEFAULT = 20;
var DIRECTIONS = {
UP: { x: 0, y: -1 },
DOWN: { x: 0, y: 1 },
LEFT: { x: -1, y: 0 },
RIGHT: { x: 1, y: 0 }
};
var OPPOSITE = {
UP: 'DOWN',
DOWN: 'UP',
LEFT: 'RIGHT',
RIGHT: 'LEFT'
};
function createInitialSnake(gridSize) {
var row = Math.floor(gridSize / 2);
var headCol = Math.floor(gridSize / 2);
// 머리가 배열의 첫 번째 요소. 처음엔 오른쪽을 향해 3칸짜리 몸으로 시작.
return [
{ x: headCol, y: row },
{ x: headCol - 1, y: row },
{ x: headCol - 2, y: row }
];
}
function isOnSnake(snake, x, y) {
for (var i = 0; i < snake.length; i++) {
if (snake[i].x === x && snake[i].y === y) return true;
}
return false;
}
// 뱀이 차지하지 않은 칸 중 하나를 무작위로 골라 먹이로 놓는다.
// 빈 칸이 하나도 없으면(=보드가 가득 참) null을 반환한다.
function placeFood(snake, gridSize) {
var emptyCells = [];
for (var y = 0; y < gridSize; y++) {
for (var x = 0; x < gridSize; x++) {
if (!isOnSnake(snake, x, y)) {
emptyCells.push({ x: x, y: y });
}
}
}
if (emptyCells.length === 0) return null;
var idx = Math.floor(Math.random() * emptyCells.length);
return emptyCells[idx];
}
// status: 'ready' | 'running' | 'paused' | 'gameover' | 'cleared'
function createGame(gridSize) {
gridSize = gridSize || GRID_SIZE_DEFAULT;
var snake = createInitialSnake(gridSize);
return {
gridSize: gridSize,
snake: snake,
direction: 'RIGHT',
pendingDirection: null,
food: placeFood(snake, gridSize),
score: 0,
status: 'ready'
};
}
function startGame(state) {
if (state.status === 'ready') {
state.status = 'running';
}
return state;
}
function pauseGame(state) {
if (state.status === 'running') {
state.status = 'paused';
}
return state;
}
function resumeGame(state) {
if (state.status === 'paused') {
state.status = 'running';
}
return state;
}
function togglePause(state) {
if (state.status === 'running') return pauseGame(state);
if (state.status === 'paused') return resumeGame(state);
return state;
}
// 방향 입력을 큐에 저장만 하고 실제 반영은 tick()에서 한 번만 한다.
//
// 규칙 (한 틱당 회전은 최대 한 번만 유효해야 한다):
// 1) 이번 틱에 적용될 회전이 이미 예약돼 있으면(pendingDirection) 이후에
// 들어오는 입력은 전부 무시한다. 큐에 쌓인 값을 다시 덮어쓰지 않는다.
// (버그 수정 전에는 두 번째 입력이 "직전에 예약된 방향"을 기준으로
// 반대 방향인지만 검사해서 덮어썼기 때문에, 실제로는 아직 반영되지
// 않은 실제 진행 방향(state.direction) 기준으로는 정반대인 회전이
// 통과해버려 몸으로 파고드는 오류가 있었다.)
// 2) 반대 방향 여부는 항상 "현재 실제로 이동 중인 방향"(state.direction)
// 기준으로만 판단한다.
// 3) 현재 진행 방향과 같은 키 입력은 회전을 예약하지 않는다(소모하지 않음).
function setDirection(state, dirName) {
if (!DIRECTIONS[dirName]) return state;
if (state.status !== 'running' && state.status !== 'ready') return state;
if (state.pendingDirection) return state;
if (dirName === state.direction) return state;
if (OPPOSITE[dirName] === state.direction) return state;
state.pendingDirection = dirName;
return state;
}
function tick(state) {
if (state.status !== 'running') return state;
var direction = state.pendingDirection || state.direction;
state.direction = direction;
state.pendingDirection = null;
var vector = DIRECTIONS[direction];
var head = state.snake[0];
var newHead = { x: head.x + vector.x, y: head.y + vector.y };
// 벽 충돌
if (
newHead.x < 0 ||
newHead.x >= state.gridSize ||
newHead.y < 0 ||
newHead.y >= state.gridSize
) {
state.status = 'gameover';
return state;
}
var willEat = !!state.food && newHead.x === state.food.x && newHead.y === state.food.y;
// 몸 충돌 검사: 먹이를 먹지 않으면 꼬리 칸은 이번 이동으로 비워지므로
// 충돌 판정 대상에서 제외한다 (그렇지 않으면 꼬리를 바짝 뒤따라가는
// 정상적인 이동도 충돌로 오판된다).
var bodyToCheck = willEat ? state.snake : state.snake.slice(0, -1);
if (isOnSnake(bodyToCheck, newHead.x, newHead.y)) {
state.status = 'gameover';
return state;
}
var newSnake = [newHead].concat(state.snake);
if (!willEat) {
newSnake.pop();
}
state.snake = newSnake;
if (willEat) {
state.score += 10;
}
// 보드를 완전히 채우면 게임 종료(클리어)
if (state.snake.length >= state.gridSize * state.gridSize) {
state.status = 'cleared';
state.food = null;
return state;
}
if (willEat) {
state.food = placeFood(state.snake, state.gridSize);
if (!state.food) {
// 위 길이 체크에서 대부분 걸러지지만 만약을 위한 안전망
state.status = 'cleared';
}
}
return state;
}
function resetGame(state) {
return createGame(state.gridSize);
}
return {
GRID_SIZE_DEFAULT: GRID_SIZE_DEFAULT,
DIRECTIONS: DIRECTIONS,
createGame: createGame,
startGame: startGame,
pauseGame: pauseGame,
resumeGame: resumeGame,
togglePause: togglePause,
setDirection: setDirection,
tick: tick,
resetGame: resetGame
};
}));
</script>
<script>
/*
* 화면 표시 및 입력 처리 담당 (렌더링 계층).
* 게임 규칙 자체는 engine.js 에만 있고, 여기서는 그 결과를 그리고
* 키보드/터치/버튼 입력을 engine 함수 호출로 옮기는 역할만 한다.
*/
(function () {
'use strict';
var GRID_SIZE = 20;
var TICK_MS = 150; // 고정 속도 (난이도 단계는 이번 스테이지 범위 밖)
var HIGH_SCORE_KEY = 'edyedy-vibecoding-snake-high-score';
var canvas = document.getElementById('board');
var ctx = canvas.getContext('2d');
var CELL_SIZE = canvas.width / GRID_SIZE;
var scoreEl = document.getElementById('score');
var highScoreEl = document.getElementById('high-score');
var statusEl = document.getElementById('status-message');
var pauseButton = document.getElementById('pause-button');
var overlay = document.getElementById('overlay');
var panelReady = document.getElementById('panel-ready');
var panelPaused = document.getElementById('panel-paused');
var panelResult = document.getElementById('panel-result');
var resultText = document.getElementById('result-text');
var startButton = document.getElementById('start-button');
var resumeButton = document.getElementById('resume-button');
var restartButton = document.getElementById('restart-button');
var dpadButtons = document.querySelectorAll('.dpad-btn');
var KEY_TO_DIRECTION = {
ArrowUp: 'UP',
ArrowDown: 'DOWN',
ArrowLeft: 'LEFT',
ArrowRight: 'RIGHT',
w: 'UP',
W: 'UP',
s: 'DOWN',
S: 'DOWN',
a: 'LEFT',
A: 'LEFT',
d: 'RIGHT',
D: 'RIGHT'
};
var state = SnakeEngine.createGame(GRID_SIZE);
var loopId = null;
// ---- 최고 점수 (localStorage) ----
// 개인정보/사설 브라우징 모드 등으로 접근이 막혀 있을 수 있으므로
// try/catch로 감싸고, 실패하면 최고 점수 기능만 조용히 비활성화한다.
var storageAvailable = true;
var highScore = 0;
(function loadHighScore() {
try {
var probeKey = '__snake_storage_probe__';
window.localStorage.setItem(probeKey, '1');
window.localStorage.removeItem(probeKey);
var saved = window.localStorage.getItem(HIGH_SCORE_KEY);
highScore = saved ? parseInt(saved, 10) || 0 : 0;
} catch (err) {
storageAvailable = false;
highScore = 0;
}
}());
function saveHighScoreIfNeeded() {
if (state.score <= highScore) return;
highScore = state.score;
if (!storageAvailable) return;
try {
window.localStorage.setItem(HIGH_SCORE_KEY, String(highScore));
} catch (err) {
storageAvailable = false;
}
}
function updateScoreDisplay() {
scoreEl.textContent = String(state.score);
highScoreEl.textContent = String(highScore);
}
function draw() {
// 배경 (크림색 노트 느낌)
ctx.fillStyle = '#f5efd8';
ctx.fillRect(0, 0, canvas.width, canvas.height);
// 격자선
ctx.strokeStyle = 'rgba(47, 74, 60, 0.15)';
ctx.lineWidth = 1;
for (var i = 0; i <= GRID_SIZE; i++) {
var pos = i * CELL_SIZE + 0.5;
ctx.beginPath();
ctx.moveTo(pos, 0);
ctx.lineTo(pos, canvas.height);
ctx.stroke();
ctx.beginPath();
ctx.moveTo(0, pos);
ctx.lineTo(canvas.width, pos);
ctx.stroke();
}
// 먹이
if (state.food) {
ctx.fillStyle = '#a5402d';
var fx = state.food.x * CELL_SIZE + CELL_SIZE / 2;
var fy = state.food.y * CELL_SIZE + CELL_SIZE / 2;
ctx.beginPath();
ctx.arc(fx, fy, CELL_SIZE / 2 - 3, 0, Math.PI * 2);
ctx.fill();
}
// 뱀 몸통
for (var s = state.snake.length - 1; s >= 0; s--) {
var seg = state.snake[s];
ctx.fillStyle = s === 0 ? '#1c2f22' : '#345c40';
ctx.fillRect(
seg.x * CELL_SIZE + 1,
seg.y * CELL_SIZE + 1,
CELL_SIZE - 2,
CELL_SIZE - 2
);
}
}
function showPanel(name) {
panelReady.hidden = name !== 'ready';
panelPaused.hidden = name !== 'paused';
panelResult.hidden = name !== 'result';
overlay.hidden = name === null;
}
function stopLoop() {
if (loopId !== null) {
clearInterval(loopId);
loopId = null;
}
}
// 현재 state.status 값에 맞춰 오버레이/버튼/스크린리더용 상태 문구를 동기화한다.
function syncUI() {
if (state.status === 'ready') {
showPanel('ready');
pauseButton.hidden = true;
statusEl.textContent = '';
} else if (state.status === 'running') {
showPanel(null);
pauseButton.hidden = false;
pauseButton.textContent = '일시정지';
statusEl.textContent = '';
} else if (state.status === 'paused') {
showPanel('paused');
pauseButton.hidden = false;
pauseButton.textContent = '계속하기';
statusEl.textContent = '일시정지되었습니다.';
} else if (state.status === 'gameover') {
stopLoop();
saveHighScoreIfNeeded();
resultText.textContent = '게임 오버! 점수 ' + state.score + '점.';
showPanel('result');
pauseButton.hidden = true;
statusEl.textContent = '게임 오버! 점수 ' + state.score + '점입니다. 다시하기를 눌러주세요.';
} else if (state.status === 'cleared') {
stopLoop();
saveHighScoreIfNeeded();
resultText.textContent = '축하합니다! 보드를 가득 채워 클리어했어요. 점수 ' + state.score + '점.';
showPanel('result');
pauseButton.hidden = true;
statusEl.textContent = '축하합니다! 점수 ' + state.score + '점으로 클리어했습니다.';
}
updateScoreDisplay();
}
function frame() {
SnakeEngine.tick(state);
draw();
syncUI();
}
function beginRun() {
stopLoop();
draw();
syncUI();
loopId = setInterval(frame, TICK_MS);
}
function handleStart() {
SnakeEngine.startGame(state);
beginRun();
canvas.focus({ preventScroll: true });
}
function handleRestart() {
state = SnakeEngine.createGame(GRID_SIZE);
SnakeEngine.startGame(state);
beginRun();
canvas.focus({ preventScroll: true });
}
// 일시정지/재개 토글. running -> paused 로 갈 땐 타이머를 완전히 멈추고,
// paused -> running 으로 돌아올 땐 타이머를 다시 건다.
function handleTogglePause() {
if (state.status === 'running') {
SnakeEngine.pauseGame(state);
stopLoop();
syncUI();
} else if (state.status === 'paused') {
SnakeEngine.resumeGame(state);
syncUI();
loopId = setInterval(frame, TICK_MS);
}
}
function autoPauseIfRunning() {
if (state.status === 'running') {
handleTogglePause();
}
}
startButton.addEventListener('click', handleStart);
resumeButton.addEventListener('click', handleTogglePause);
restartButton.addEventListener('click', handleRestart);
pauseButton.addEventListener('click', handleTogglePause);
for (var d = 0; d < dpadButtons.length; d++) {
dpadButtons[d].addEventListener('click', function (event) {
var dir = event.currentTarget.getAttribute('data-dir');
SnakeEngine.setDirection(state, dir);
});
}
window.addEventListener('keydown', function (event) {
var isPauseKey = event.key === ' ' || event.code === 'Space' || event.key === 'p' || event.key === 'P';
var dir = KEY_TO_DIRECTION[event.key];
if (!isPauseKey && !dir) return;
// 방향키/스페이스로 페이지가 스크롤되는 것을 막는다.
event.preventDefault();
// 키를 길게 누르고 있을 때 브라우저가 반복 발생시키는 keydown은 무시한다.
// (일시정지가 깜빡이거나 방향이 불필요하게 반복 예약되는 것을 방지)
if (event.repeat) return;
if (isPauseKey) {
if (state.status === 'running' || state.status === 'paused') {
handleTogglePause();
}
return;
}
SnakeEngine.setDirection(state, dir);
});
// 탭이 백그라운드로 가거나(visibilitychange) 창 포커스를 잃으면(blur)
// 진행 중인 게임을 자동으로 일시정지한다. 자동으로 재개하지는 않는다.
document.addEventListener('visibilitychange', function () {
if (document.hidden) autoPauseIfRunning();
});
window.addEventListener('blur', autoPauseIfRunning);
// 시작 전 초기 화면(빈 보드 + 뱀 초기 위치)을 미리 그려둔다.
draw();
syncUI();
})();
</script>
</body>
</html>
다시 만들어 보니 달라진 것
예전에는 Snake 하나를 직접 구현하는 것도 어려워 밤새 코드를 들여다봤다. 이번에는 AI가 기본 코드를 만들었지만, 첫 결과에도 안내 표시와 방향 입력 문제가 있었다. 코드를 받는 것으로 작업이 끝나지는 않았다.
이번 기록에서 남기고 싶은 것은 “AI가 게임을 만들어 줬다”는 결과만은 아니다. 시작 버튼을 눌러 보고, 빠른 입력 조건을 따로 확인하고, 재현 순서를 정리해 다시 요청하는 과정이 필요했다. 다음 게임에서도 처음 나온 화면부터 남겨 보려고 한다. 다음 차례는 지뢰찾기다.