มาลองสร้างเกมงูด้วย HTML กันดีกว่า (Snake Game)

ใน Tutorial นี้เราจะมาเรียนรู้วิธีสร้างเกม Snake แบบง่ายๆ ด้วย HTML, CSS และ JavaScript กันครับ โดยจะแบ่งเป็นขั้นตอนต่างๆ เพื่อให้เข้าใจง่าย
Youtube video
สำหรับสาย Video ผมได้ทำ Tutorial แบบ video ไว้เช่นกัน สามารถดูได้ที่ video คลิปด้านล่างได้เลย
ก่อนอื่น หากใครไม่รู้จัก Canvas สามารถอ่านเพิ่มเติมได้ที่บทความก่อนหน้าที่ผมเขียนไว้ได้ครับ

ขั้นตอนที่ 1: สร้างโครง HTML พื้นฐาน
เริ่มต้นด้วยการสร้างหน้า HTML และ Canvas สำหรับวาดเกม ตัว Canvas มีขนาด 400x400 พิกเซล
<!DOCTYPE html><html lang="th"> <head> <meta charset="UTF-8" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" /> <title>Snake Game Tutorial by Devahoy</title> <style> body { display: flex; justify-content: center; align-items: center; min-height: 100vh; margin: 0; background-color: #333; font-family: Arial, sans-serif; } #gameCanvas { border: 2px solid #fff; background-color: #000; } </style> </head> <body> <canvas id="gameCanvas" width="400" height="400"></canvas>
<script> const canvas = document.getElementById('gameCanvas') const ctx = canvas.getContext('2d')
console.log('Canvas พร้อมใช้งาน!') </script> </body></html>
ขั้นตอนที่ 2: กำหนดตัวแปรพื้นฐาน
กำหนดตัวแปรที่จำเป็นสำหรับเกม โดยเราจะแบ่งตาราง 20x20 ช่อง และกำหนดตำแหน่งเริ่มต้นของงูและอาหาร ตามตัวอย่าง
const canvas = document.getElementById('gameCanvas')const ctx = canvas.getContext('2d')
// ขนาดของแต่ละช่องconst gridSize = 20
// จำนวนช่องในแต่ละแถวconst tileCount = canvas.width / gridSize
// ตำแหน่งของงู (เริ่มต้นที่กลาง Canvas)let snake = [{ x: 10, y: 10 }]
// ตำแหน่งของอาหารlet food = { x: 15, y: 15 }
// ทิศทางการเคลื่อนไหว แนวตั้ง แนวนอนlet dx = 0let dy = 0
// คะแนนlet score = 0
ขั้นตอนที่ 3: สร้างฟังก์ชันวาดงู
ต่อมาเราจะสร้างงูกันครับ ตัวงู ตอนนี้ เราให้เป็นสี่เหลี่ยมสีเขียว (rectangle)
เราเก็บเป็น array ในแต่ละ array เราจะมี x, y ระบุอยู่ (ลองคิด ถ้างูมีความยาว 5 ส่วน เราก็ต้องวน loop 5 รอบ เพื่อวาดสี่เหลี่ยม นั่นเอง)
function drawSnake() { ctx.fillStyle = '#0f0'
// วาดทุกส่วนของงู for (let segment of snake) { ctx.fillRect(segment.x * gridSize, segment.y * gridSize, gridSize - 2, gridSize - 2) }}
// ทดสอบวาดงูdrawSnake()
สังเกตมั้ย ทำไมขนาด ของงูเป็น gridSize - 2
? ได้ขนาด 18x18 เอง เพราะ 1 pixel เป็นขอบในแต่ละด้านของ grid
ขั้นตอนที่ 4: สร้างฟังก์ชันวาดอาหาร
ต่อมาเราจะวาดอาหารกัน โดยอาหาร เราใช้วิธีการ วาดเป็นวงกลม สีแดง
function drawFood() { ctx.fillStyle = '#fdd255' ctx.beginPath() ctx.arc( food.x * gridSize + gridSize / 2, food.y * gridSize + gridSize / 2, gridSize / 2 - 2, 0, Math.PI * 2 ) ctx.fill()}
// ทดสอบวาดอาหารdrawFood()
ขั้นตอนที่ 5: สร้างฟังก์ชันล้างหน้าจอและวาดใหม่
ขั้นตอนนี้ เราจะทำการสร้าง function สำหรับ clear canvas และ วาดภาพเกม โดยใช้ชื่อ drawGame()
โดยเอาการวาดอาหาร และงู จากขั้นตอนก่อน มาใส่ที่นี่
function clearCanvas() { ctx.fillStyle = '#000' // สีดำ ctx.fillRect(0, 0, canvas.width, canvas.height)}
// จะเห็นว่าเรา ทำการ clear canvas ก่อนที่จะวาดfunction drawGame() { clearCanvas() drawSnake() drawFood()}
// ทดสอบวาดเกมdrawGame()
ขั้นตอนที่ 6: ทำให้งูเคลื่อนไหว
ขั้นตอนนี้ เราลองมาทำให้งูเคลื่อนไหวกัน หลักการคือ
- สร้างหัวใหม่ของงู และเพิ่มหัวใหม่ที่ด้านหน้า
- ตัดหางออก (ถ้าไม่กินอาหาร) ขั้นตอนการเช็คว่ากินอาหารหรือยัง ทำให้ขั้นตอนต่อไปครับ ข้ามไปก่อน
ทำการทดสอบการเคลื่อนไหว ด้วยการใช้ setInterval
function moveSnake() { // 1. สร้างหัวใหม่ของงู const head = { x: snake[0].x + dx, y: snake[0].y + dy }
// 2. เพิ่มหัวใหม่ที่ด้านหน้า snake.unshift(head)
// 3. ตัดหางออก (ถ้าไม่กินอาหาร) snake.pop()}
// ทดสอบการเคลื่อนไหวdx = 1 // เคลื่อนไหวไปทางขวาsetInterval(() => { moveSnake() drawGame()}, 200)
ขั้นตอนที่ 7: จัดการการกดปุ่ม
หลังจากที่เราสามารถทำให้งูเคลื่อนไหวได้แล้ว แต่มันถูกกำหนด ว่าเคลื่อนทางขวา อย่างเดียว ทีนี้ เราจะมารับ input ของ user ผ่านทาง keyboard ให้กด ปุ่ม ขึ้น ลง ซ้าย ขวา เพื่อขยับงู (ลบตรง dx = 1
ออกด้วย)
โดยเราใช้ document.addEventListener()
เพื่อรับ input จาก keyboard ใช้ event keydown
มีการเช็ค input จาก keyboard ว่าเป็นปุ่มอะไร และกำหนด dx, dy ตามที่ user กด และมีการป้องกันการเคลื่อนที่ไปทางตรงข้ามไม่ให้งูถอยหลังชนตัวเอง
document.addEventListener('keydown', (e) => { switch (e.key) { case 'ArrowUp': if (dy !== 1) { // ป้องกันการถอยหลัง dx = 0 dy = -1 } break case 'ArrowDown': if (dy !== -1) { dx = 0 dy = 1 } break case 'ArrowLeft': if (dx !== 1) { dx = -1 dy = 0 } break case 'ArrowRight': if (dx !== -1) { dx = 1 dy = 0 } break }})
// ลบตรงนี้ออก// dx = 1
ขั้นตอนที่ 8: ตรวจจับการกินอาหาร
มาถึงขั้นตอนการตรวจจับการกินอาหาร ซึ่งเป็นส่วนสำคัญของเกมส์งู เพราะการกินอาหารจะทำให้งูเติบโตและเพิ่มคะแนน โดยเรา ต้องทำการเช็ค ตำแหน่ง หัวงู เทียบกับตำแหน่งของอาหาร
- ถ้าตำแหน่งตรงกัน แสดงว่างูกินอาหาร ให้ทำการสร้างอาหารใหม่
- ถ้าไม่ตรงกัน แสดงว่างูไม่ได้กินอาหาร
ส่วน function ในการสร้างอาหาร generateFood()
เราจะทำการสุ่มตำแหน่ง x, y ของอาหาร รวมถึงวนลูป ตรวจสอบ ไม่ให้อาหารไปสุ่มอยู่ในตัวงูด้วย
function checkFoodCollision() { const head = snake[0]
// ถ้าหัวงูชนอาหาร if (head.x === food.x && head.y === food.y) { score += 10 console.log('คะแนน:', score)
// สร้างอาหารใหม่ generateFood()
return true // กินอาหารแล้ว } return false // ไม่ได้กินอาหาร}
function generateFood() { food = { x: Math.floor(Math.random() * tileCount), y: Math.floor(Math.random() * tileCount) }
// ตรวจสอบไม่ให้อาหารปรากฏบนตัวงู for (let segment of snake) { if (segment.x === food.x && segment.y === food.y) { generateFood() // สร้างใหม่ถ้าชนกับงู return } }}
เมื่อเรามีการตรวจจับการกินอาหารแล้ว เราก็กลับมาที่ moveSnake()
เพื่อทำการเพิ่ม condition เช็คว่า มีการกินอาหารมั้ย ถ้าไม่กินอาหาร ก็ snake.pop()
ออก
// อัปเดตฟังก์ชัน moveSnakefunction moveSnake() { const head = { x: snake[0].x + dx, y: snake[0].y + dy }
snake.unshift(head)
// 3. ตัดหางออก (ถ้าไม่กินอาหาร) if (!checkFoodCollision()) { snake.pop() }}
ขั้นตอนที่ 9: ตรวจจับการชน
ส่วนต่อมาเป็นการตรวจจับการชนตัวเอง และชนกำแพง การเช็คการชนตัวเอง เราใช้ slice(1)
เพื่อตัดหัวอออก แล้วถึงเช็คการชนตัวเอง ป้องกัน ตอนเริ่มเกม
function checkCollision() { const head = snake[0]
// ชนกำแพง if (head.x < 0 || head.x >= tileCount || head.y < 0 || head.y >= tileCount) { return true }
// ชนตัวเอง for (let body of snake.slice(1)) { if (head.x === body.x && head.y === body.y) { return true } }
return false}
จากนั้น ที่ drawGame()
เราเพิ่มเช็ค checkCollision
ถ้าใช่ ก็เรียก function gameOver
function drawGame() { clearCanvas()
if (checkCollision()) { gameOver() return }
drawSnake() drawFood()}
ฟังค์ชั่น gameOver ให้ alert และทำการ reset game state.
function gameOver() { alert(`จบเกมแล้ว! คะแนนของคุณ: ${score}`)
// รีเซ็ตเกม snake = [{ x: 10, y: 10 }] dx = 0 dy = 0 score = 0 generateFood()}
ขั้นตอนที่ 10: สร้าง Game Loop
ส่วนสุดท้าย เราจะสร้าง game loop กัน รวมถึงสร้างตัวแปรใหม่ ชื่อ isGameRunning
เพื่อเอาไว้เช็คว่าเกม กำลังรันอยู่หรือไม่
โดยเราจะสร้าง gameLoop
และใช้ setTimeout
มาแทนที่ setInterval
ส่วน game loop จริงๆ หลักการมันคือ
- Update
- Render
- Next frame
let isGameRunning = true
function gameLoop() { if (!isGameRunning) return
// ถ้างูยังไม่ตาย if (!checkCollision()) { moveSnake() drawGame() } else { gameOver() }
setTimeout(gameLoop, 200)}
generateFood()
// start gamegameLoop()
ขั้นตอนที่ 11: requestAnimationFrame และ delta time
ซึ่งจริงๆ แล้ว canvas แนะนำใช้ requestAnimationFrame
แทน setTimeout
เพื่อให้เกมทำงานได้อย่างราบรื่น โดย requestAnimationFrame
จะเรียกฟังก์ชัน gameLoop
ทุกเฟรมของหน้าจอ 60 FPS (เฟรมต่อวินาที)
เมื่อเปลี่ยน requestAnimationFrame
เราอาจจะต้องคำนวณ เวลาใหม่ โดยใช้ delta time เพื่อให้เกมทำงานได้อย่างราบรื่น
ตัวอย่าง การใช้ delta time
let lastUpdateTime = 0const targetFPS = 10const frameInterval = 1000 / targetFPS // 100ms
function gameLoop(timestamp) { if (!isGameRunning) return
const deltaTime = timestamp - lastUpdateTime if (deltaTime >= frameInterval) { // ถ้างูยังไม่ตาย if (!checkCollision()) { moveSnake() drawGame() } else { gameOver() }
lastUpdateTime = timestamp }
// 3. Next frame requestAnimationFrame(gameLoop)}
// เริ่มเกมgenerateFood()gameLoop()
ขั้นตอนที่ 12: ปรับแต่ง UI เพิ่ม Game Over popup
ปรับแต่ง UI เล็กน้อย และเพิ่ม popup เวลาที่เรา game over รวมถึงใส่ score
<body> <style> .game-over { position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%); background: rgba(255, 0, 0, 0.9); padding: 20px; border-radius: 10px; font-size: 24px; display: none; } </style>
<div class="game-container"> <div class="score">Score: <span id="score">0</span></div> <canvas id="gameCanvas" width="400" height="400"></canvas> <div class="controls"> <p>ใช้ปุ่มลูกศรเพื่อเล่น</p> <button onclick="startGame()">Restart Game</button> </div> <div class="game-over" id="gameOver"> Game Over!<br /> <button onclick="startGame()">Play Again</button> </div> </div>
<script> const scoreElement = document.getElementById('score') const gameOverElement = document.getElementById('gameOver')
function updateScore() { scoreElement.textContent = score }
// อัปเดตในฟังก์ชัน checkFoodCollision function checkFoodCollision() { const head = snake[0]
if (head.x === food.x && head.y === food.y) { score += 10 updateScore() // อัปเดตคะแนนบนหน้าจอ generateFood() return true } return false }
function gameOver() { isGameRunning = false gameOverElement.style.display = 'block'
// reset game state resetGame() }
function resetGame() { snake = [{ x: 10, y: 10 }] dx = 0 dy = 0 score = 0 }
function initializeGameState() { resetGame() generateFood()
gameOverElement.style.display = 'none' isGameRunning = true }
function startGame() { initializeGameState() gameLoop() }
startGame() </script></body>
Final Code
สรุปโค๊ดทั้งหมดของไฟล์ index.html
ที่มีการปรับแต่ง CSS เพิ่มเติม เป็นไฟล์เดียวนะครับ ไม่ได้แบ่งไฟล์ css, javascript
<!doctype html><html lang="en"> <head> <meta charset="UTF-8" /> <link rel="icon" type="image/svg+xml" href="/vite.svg" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" /> <title>Snake Game Tutorial by Devahoy</title>
<link rel="preconnect" href="https://fonts.googleapis.com" /> <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin /> <link href="https://fonts.googleapis.com/css2?family=Itim&display=swap" rel="stylesheet" />
<style> * { margin: 0; padding: 0; box-sizing: border-box; font-family: 'Itim', cursive; font-weight: 400; font-style: normal; }
body { display: flex; justify-content: center; align-items: center; min-height: 100vh; margin: 0; background-color: #333;
color: #fff; background: linear-gradient(to right top, #051937, #004d7a, #008793, #00bf72, #a8eb12); }
.game-container { text-align: center; background: rgba(255, 255, 255, 0.1); backdrop-filter: blur(10px); border-radius: 20px; padding: 30px; box-shadow: 0 8px 32px rgba(0, 0, 0, 0.3); }
.score { font-size: 24px; margin-bottom: 10px; color: #fff; }
#gameCanvas { border: 3px solid white; border-radius: 10px; box-shadow: 0 4px 15px rgba(0, 0, 0, 0.2); background: #2a2a2a; }
.game-over { position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%); background: rgba(0, 0, 0, 0.85); color: white; padding: 30px; border-radius: 15px; text-align: center; display: none; }
.controls { margin-top: 16px; }
button { background: #667eea; color: white; border: none; padding: 12px 24px; border-radius: 8px; font-size: 1.1em; cursor: pointer; margin-top: 15px; transition: background 0.3s; }
button:hover { background: #5a6fd8; } </style> </head> <body> <div class="game-container"> <div class="score">Score: <span id="score">0</span></div> <canvas id="gameCanvas" width="400" height="400"></canvas> <div class="controls"> <p>ใช้ปุ่มลูกศรเพื่อเล่น</p> <button onclick="startGame()">Restart Game</button> </div> <div class="game-over" id="gameOver"> Game Over!<br /> <button onclick="startGame()">Play Again</button> </div> </div> <script> const canvas = document.getElementById('gameCanvas') const ctx = canvas.getContext('2d') const scoreElement = document.getElementById('score') const gameOverElement = document.getElementById('gameOver')
const gridSize = 20 const tileCount = canvas.width / gridSize
let snake = [{ x: 10, y: 10 }]
let food = { x: 15, y: 15 }
let dx = 0 let dy = 0
let score = 0 let isGameRunning = true
let lastUpdateTime = 0 const targetFPS = 10 const frameInterval = 1000 / targetFPS // 100ms
function drawSnake() { ctx.fillStyle = '#0f0'
for (let body of snake) { ctx.fillRect(body.x * gridSize, body.y * gridSize, gridSize - 2, gridSize - 2) } }
function drawFood() { ctx.fillStyle = '#f00'
ctx.beginPath() ctx.arc( food.x * gridSize + gridSize / 2, food.y * gridSize + gridSize / 2, gridSize / 2, // radius 0, Math.PI * 2 )
ctx.fill() }
function clearCanvas() { ctx.fillStyle = 'f00' ctx.clearRect(0, 0, canvas.width, canvas.height) }
function moveSnake() { // x=10, y=10 const head = { x: snake[0].x + dx, y: snake[0].y + dy }
snake.unshift(head)
if (!checkFoodCollision()) { snake.pop() } }
function checkFoodCollision() { const head = snake[0]
// งูกินอาหาร if (head.x === food.x && head.y === food.y) { score += 10
updateScore() generateFood()
return true }
return false }
function generateFood() { food = { x: Math.floor(Math.random() * tileCount), y: Math.floor(Math.random() * tileCount) }
for (let body of snake) { if (body.x == food.x && body.y === food.y) { generateFood() return } } }
function checkCollision() { const head = snake[0]
// ชนกำแพง if (head.x < 0 || head.x >= tileCount || head.y < 0 || head.y >= tileCount) { return true }
// ชนตัวเอง for (let body of snake.slice(1)) { if (head.x === body.x && head.y === body.y) { return true } }
return false }
function gameOver() { alert('Game Over!')
// reset game snake = [{ x: 10, y: 10 }] dx = 0 dy = 0 food = { x: Math.floor(Math.random() * tileCount), y: Math.floor(Math.random() * tileCount) } }
function drawGame() { clearCanvas()
if (checkCollision()) { gameOver() return }
drawSnake() drawFood() }
function gameLoop(timestamp) { if (!isGameRunning) return
const deltaTime = timestamp - lastUpdateTime if (deltaTime >= frameInterval) { // ถ้างูยังไม่ตาย if (!checkCollision()) { moveSnake() drawGame() } else { gameOver() }
lastUpdateTime = timestamp }
requestAnimationFrame(gameLoop) }
function updateScore() { scoreElement.textContent = score }
function gameOver() { isGameRunning = false gameOverElement.style.display = 'block'
// reset game state resetGame() }
function resetGame() { snake = [{ x: 10, y: 10 }] dx = 0 dy = 0 score = 0 }
function initializeGameState() { resetGame() generateFood()
gameOverElement.style.display = 'none' isGameRunning = true updateScore() }
function startGame() { initializeGameState() gameLoop() }
startGame()
document.addEventListener('keydown', (event) => { event.preventDefault() if (event.key === 'ArrowUp') { if (dy !== 1) { dy = -1 dx = 0 } } else if (event.key === 'ArrowDown') { if (dy !== -1) { dy = 1 dx = 0 } } else if (event.key === 'ArrowLeft') { if (dx !== 1) { dx = -1 dy = 0 } } else if (event.key === 'ArrowRight') { if (dx !== -1) { dx = 1 dy = 0 } } }) </script> </body></html>
สรุป
ตัวเกมงู ก็สร้างเสร็จเรียบร้อย สามารถเล่นได้ ทีนี้ก็ลองปรับแต่ง UI และเพิ่มเติมฟีเจอร์ให้เกมน่าสนใจยิ่งขึ้นลองดูครับ
หรือไอเดีย สำหรับลอง challenge ตัวเอง เช่น
- แยก function จัดการไฟล์ ให้เป็นระเบียบ หรือ function ซ้ำ เรา reuse มันได้มั้ย?
- ปรับระดับความเร็วได้
- ใส่เสียงประกอบ
- เพิ่มระบบ High Score
- ลองเปลี่ยน theme เปลี่ยนสีตัวละคร หรือจะใช้ asset เป็น image แทนสี่เหลี่ยม วงกลม
- ระบบ Power-ups ทะลุกำแพงได้ ทะลุตัวเองได้ เป็นต้น
Happy Coding ❤️
- Authors
-
Chai Phonbopit
เป็น Web Dev ในบริษัทแห่งหนึ่ง ทำงานมา 10 ปีกว่าๆ ด้วยภาษาและเทคโนโลยี เช่น JavaScript, Node.js, React, Vue และปัจจุบันกำลังสนใจในเรื่องของ Blockchain และ Crypto กำลังหัดเรียนภาษา Rust