Created
April 16, 2024 22:08
-
-
Save leobetosouza/ded6b8d784ecdfe8b8aa14db29440463 to your computer and use it in GitHub Desktop.
Simple JS Snake Game on one file
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
<!DOCTYPE html> | |
<html> | |
<head> | |
<meta charset="utf-8"> | |
<meta name="viewport" content="width=device-width, initial-scale=1"> | |
<title>SNAKE GAME</title> | |
<style> | |
body { | |
margin: 0; | |
padding: 0; | |
font-family: monospace; | |
} | |
#snake_game { | |
background: black; | |
color: white; | |
padding: 10px; | |
width: fit-content; | |
margin: 0 auto; | |
display: flex; | |
flex-direction: column; | |
#meta, #footer { | |
display: flex; | |
justify-content: space-between; | |
padding: 0 5px; | |
} | |
#meta { | |
margin-bottom: 3px; | |
.key-controls { | |
display: flex; | |
flex-direction: column; | |
align-items: end; | |
} | |
} | |
#footer { | |
margin-top: 5px; | |
} | |
.record-wrapper { | |
display: none; | |
&.active { | |
display: initial; | |
} | |
} | |
.points { | |
font-weight: bold; | |
&.has-overcome { | |
color: lightgreen; | |
} | |
} | |
#board { | |
border: 5px solid white; | |
section { | |
padding: 0 0 1px 1px; | |
border: 1px solid white; | |
> div { | |
display: flex; | |
justify-content: center; | |
} | |
} | |
span { | |
display: block; | |
border: 1px solid white; | |
margin: 1px 1px 0 0; | |
font-size: 0; | |
padding: 1.2vw; | |
box-sizing: border-box; | |
} | |
.snake, | |
.food { | |
border: 1px solid black; | |
} | |
.poop { | |
background-image: linear-gradient(to bottom right, white 0, white 50%); | |
background-size: 50% 50%; | |
background-position: center center; | |
background-repeat: no-repeat; | |
&.vanishing { | |
background-size: 70% 70%; | |
} | |
} | |
.food { | |
background: red; | |
border-radius: 50%; | |
} | |
.obstacle { | |
background: white; | |
} | |
.snake { | |
background: lightgreen; | |
&.head { | |
background-repeat: no-repeat; | |
&.dir-e { | |
background-image: | |
linear-gradient(to top left, red 50%, transparent 0), | |
linear-gradient(to top right, transparent 50%, red 0); | |
background-size: 100% 50%; | |
background-position: top, bottom; | |
} | |
&.dir-w { | |
background-image: | |
linear-gradient(to right top, red 50%, transparent 0), | |
linear-gradient(to left top, transparent 50%, red 0); | |
background-size: 100% 50%; | |
background-position: top, bottom; | |
} | |
&.dir-n { | |
background-image: | |
linear-gradient(to left bottom, red 50%, transparent 0), | |
linear-gradient(to right bottom, red 50%, transparent 0); | |
background-size: 50% 100%; | |
background-position: left, right; | |
} | |
&.dir-s { | |
background-image: | |
linear-gradient(to bottom right, transparent 50%, red 0), | |
linear-gradient(to top right, red 50%, transparent 0); | |
background-size: 50% 100%; | |
background-position: left, right; | |
} | |
} | |
&.tail { | |
background-color: black; | |
&.dir-w { | |
background-image: | |
linear-gradient(to top left, lightgreen 50%, transparent 0), | |
linear-gradient(to top right, transparent 50%, lightgreen 0); | |
background-size: 100% 50%; | |
background-position: top, bottom; | |
} | |
&.dir-e { | |
background-image: | |
linear-gradient(to right top, lightgreen 50%, transparent 0), | |
linear-gradient(to left top, transparent 50%, lightgreen 0); | |
background-size: 100% 50%; | |
background-position: top, bottom; | |
} | |
&.dir-s { | |
background-image: | |
linear-gradient(to left bottom, lightgreen 50%, transparent 0), | |
linear-gradient(to right bottom, lightgreen 50%, transparent 0); | |
background-size: 50% 100%; | |
background-position: left, right; | |
} | |
&.dir-n { | |
background-image: | |
linear-gradient(to bottom right, transparent 50%, lightgreen 0), | |
linear-gradient(to top right, lightgreen 50%, transparent 0); | |
background-size: 50% 100%; | |
background-position: left, right; | |
} | |
} | |
&.food-eaten { | |
background-image: linear-gradient(to bottom right, red 0, red 50%); | |
background-size: 50% 50%; | |
background-position: center center; | |
background-repeat: no-repeat; | |
} | |
} | |
} | |
.btn-false { | |
display: inline-block; | |
font-weight: bold; | |
border: 1px solid white; | |
border-radius: 3px; | |
padding: 1px 3px; | |
margin-bottom: 2px; | |
.active { | |
display: none; | |
} | |
.inactive { | |
display: unset; | |
} | |
} | |
&[data-gamemode="infinity"] { | |
#board { | |
border-color: black; | |
} | |
#infinity_control { | |
.active { | |
display: unset; | |
} | |
.inactive { | |
display: none; | |
} | |
} | |
} | |
&[data-controlmode="relative"] { | |
#relative_control { | |
.active { | |
display: unset; | |
} | |
.inactive { | |
display: none; | |
} | |
} | |
} | |
&[data-gamestate="ready"] { | |
.pre-game-controls { | |
display: initial; | |
} | |
.reset-control { | |
display: none; | |
} | |
.label-on-ready { | |
display: initial; | |
} | |
.label-on-started, | |
.label-on-paused, | |
.label-on-ended { | |
display: none; | |
} | |
} | |
&[data-gamestate="started"] { | |
.pre-game-controls { | |
display: none; | |
} | |
.reset-control { | |
display: initial; | |
} | |
.label-on-started { | |
display: initial; | |
} | |
.label-on-ready, | |
.label-on-paused, | |
.label-on-ended { | |
display: none; | |
} | |
} | |
&[data-gamestate="paused"] { | |
.pre-game-controls { | |
display: none; | |
} | |
.reset-control { | |
display: initial; | |
} | |
.label-on-paused { | |
display: initial; | |
} | |
.label-on-ready, | |
.label-on-started, | |
.label-on-ended { | |
display: none; | |
} | |
} | |
&[data-gamestate="ended"] { | |
.pre-game-controls { | |
display: none; | |
} | |
.reset-control { | |
display: initial; | |
} | |
.label-on-ended { | |
display: initial; | |
} | |
.label-on-ready, | |
.label-on-started, | |
.label-on-paused { | |
display: none; | |
} | |
#board { | |
border-color: red !important; | |
.snake { | |
border-color: red !important; | |
} | |
} | |
} | |
#controls { | |
text-align: center; | |
padding-top: 10px; | |
} | |
} | |
</style> | |
</head> | |
<body> | |
<main id="snake_game" data-gamestate="ready"> | |
<aside id="meta"> | |
<span> | |
<span class="points">0</span> | |
<span class="record-wrapper">/ <span class="record">0</span></span> | |
</span> | |
<span class="key-controls"> | |
<span class="btn-false action-control"> | |
enter: | |
<span class="label-on-ready">start game</span> | |
<span class="label-on-started">pause game</span> | |
<span class="label-on-paused">resume game</span> | |
<span class="label-on-ended">reset game</span> | |
</span> | |
<span class="btn-false reset-control">esc: reset game</span> | |
<span class="pre-game-controls"> | |
<span id="infinity_control" class="btn-false">i: <span class="active">open</span><span class="inactive">walled</span> grid</span> | |
<span id="relative_control" class="btn-false">u: <span class="active">relative</span><span class="inactive">absolute</span> controls</span> | |
</span> | |
</span> | |
</aside> | |
<div id="board"></div> | |
<aside id="footer"> | |
<span>Snake Game JS by @leobetosouza</span> | |
<span> | |
<span class="walled-record">0</span> | <span class="infinity-record">0</span>i | |
</span> | |
</aside> | |
</main> | |
</body> | |
<script> | |
let walledRecord = +localStorage.getItem('snake.walledRecord') || 0; | |
let infinityRecord = +localStorage.getItem('snake.infinityRecord') || 0; | |
let infinityGameMode = Boolean(+localStorage.getItem('snake.isInfinityGame')); | |
let relativeControlsMode = Boolean(+localStorage.getItem('snake.isRelativeControls')); | |
const board = document.createElement('section'); | |
const REDRAW_TIME = 200; | |
const BOARD_WIDTH = 27; | |
const BOARD_HEIGHT = 21; | |
const positions = Array.from( | |
{ length: BOARD_HEIGHT }, | |
(_, y) => Array.from( | |
{ length: BOARD_WIDTH }, | |
(_, x) => ({ x, y }) | |
) | |
); | |
let defaultFoodValue = 5; | |
let eatingCount = 0; | |
let defaultFoodsToPlace = 3; | |
let direction = 'E'; | |
const snake = [{ x: 0, y: 0, direction }]; | |
const foods = []; | |
const movementBuffer = []; | |
const poops = []; | |
const obstacles = []; | |
const oppositeDirection = { | |
N : 'S', | |
S : 'N', | |
E : 'W', | |
W : 'E' | |
}; | |
const relativeDirections = { | |
N: { | |
'arrowright': 'E', | |
'arrowleft': 'W' | |
}, | |
S: { | |
'arrowright': 'W', | |
'arrowleft': 'E' | |
}, | |
E: { | |
'arrowright': 'S', | |
'arrowleft': 'N' | |
}, | |
W: { | |
'arrowright': 'N', | |
'arrowleft': 'S' | |
} | |
}; | |
let willReset = false; | |
let hasGameOver = false; | |
let initialized = false; | |
let paused = false; | |
let nextHead = null; | |
window.addEventListener('DOMContentLoaded', prepareGame); | |
window.addEventListener('keydown', handleInput); | |
const gameWrapper = document.getElementById('snake_game'); | |
const pointsCounters = gameWrapper.getElementsByClassName('points'); | |
const recordCounters = gameWrapper.getElementsByClassName('record'); | |
const recordWrappers = gameWrapper.getElementsByClassName('record-wrapper'); | |
const walledRecordCounters = gameWrapper.getElementsByClassName('walled-record'); | |
const infinityRecordCounters = gameWrapper.getElementsByClassName('infinity-record'); | |
function resetGame() { | |
gameWrapper.setAttribute('data-gamestate', 'ready'); | |
direction = 'E'; | |
snake.length = 0; | |
snake.push({ x: 0, y: 0, direction }); | |
renderPoints(); | |
movementBuffer.length = 0; | |
poops.length = 0; | |
obstacles.length = 0; | |
foods.length = 0; | |
nextHead = null; | |
direction = 'E'; | |
paused = false; | |
initialized = false; | |
hasGameOver = false; | |
willReset = false; | |
drawBoard(); | |
} | |
function prepareGame() { | |
document.getElementById('board').appendChild(board); | |
setGridGameMode(); | |
setControlMode(); | |
renderRecords(); | |
drawBoard(); | |
} | |
function initGame() { | |
initialized = true; | |
gameWrapper.setAttribute('data-gamestate', 'started'); | |
gameLoop(); | |
} | |
async function gameLoop() { | |
do { | |
if (paused) { | |
console.warn("PAUSOU"); | |
await delay(REDRAW_TIME/2); | |
continue; | |
} | |
renderPoints(); | |
createNextHead(); | |
if (!foods.length) createFood(); | |
drawBoard(); | |
moveSnake(); | |
cleanPoop(); | |
if (checkGameOver()) { | |
gameOver(); | |
break; | |
} | |
if (willReset) { | |
resetGame(); | |
break; | |
} | |
await delay(REDRAW_TIME); | |
} while(true); | |
} | |
function drawBoard() { | |
board.innerHTML = ''; | |
const head = snake[0]; | |
const tail = snake[snake.length - 1]; | |
const grid = positions.reduce((frag, lines, y, linesLimit) => { | |
const gridLines = lines.reduce((line, _, x, columnsLimit) => { | |
const cell = document.createElement('span'); | |
const cellClasses = cell.classList; | |
cell.innerHTML = ' '; | |
const pos = { x, y }; | |
if (includes(foods, pos)) | |
cellClasses.add('food'); | |
else { | |
const bodySegment = snake.find((c) => equals(c, pos)); | |
if (bodySegment) { | |
cellClasses.add('snake'); | |
if (equals(head, bodySegment)) | |
cellClasses.add('head', `dir-${ head.direction.toLowerCase() }`); | |
else if (equals(tail, bodySegment)) | |
cellClasses.add('tail', `dir-${ oppositeDirection[tail.direction].toLowerCase() }`); | |
else if (bodySegment.hasFood) | |
cellClasses.add('food-eaten'); | |
} | |
} | |
const poop = poops.find((p) => equals(p, pos)); | |
if (poop) { | |
cellClasses.add('poop'); | |
if (poop.timeToVanish < REDRAW_TIME * 6) { | |
cellClasses.add('vanishing'); | |
} | |
} | |
if (includes(obstacles, pos)) | |
cellClasses.add('obstacle'); | |
if (x === 0) | |
cellClasses.add('limit', 'top-limit'); | |
if (y === 0) | |
cellClasses.add('limit', 'left-limit'); | |
if (x === linesLimit.length - 1) | |
cellClasses.add('limit', 'bottom-limit'); | |
if (y === columnsLimit.length - 1) | |
cellClasses.add('limit', 'right-limit'); | |
line.appendChild(cell); | |
return line; | |
}, document.createElement('div')); | |
frag.appendChild(gridLines); | |
return frag; | |
}, document.createDocumentFragment()); | |
board.appendChild(grid); | |
} | |
function createFood() { | |
for (let i = 0; i < defaultFoodsToPlace; i++) { | |
const validPositons = positions | |
.reduce((acc, arr) => ([ | |
...acc, | |
...arr.filter((o) => !includes([nextHead, ...snake, ...foods, ...obstacles], o)) | |
]), []); | |
const food = validPositons[getRandomInt(0, validPositons.length-1)]; | |
food.value = defaultFoodValue; | |
removePoop(food); | |
foods.push(food); | |
} | |
} | |
function createNextHead() { | |
nextHead = { ...snake[0], hasFood: false }; | |
let nextDirection = movementBuffer.length ? movementBuffer.shift() : null; | |
if (nextDirection === oppositeDirection[direction]) | |
nextDirection = null; | |
if (nextDirection) | |
direction = nextDirection; | |
nextHead.direction = direction; | |
switch (direction) { | |
case 'N': nextHead.y--; break; | |
case 'S': nextHead.y++; break; | |
case 'E': nextHead.x++; break; | |
case 'W': nextHead.x--; break; | |
} | |
if (infinityGameMode) { | |
if (nextHead.x === -1) nextHead.x = BOARD_WIDTH-1; | |
if (nextHead.y === -1) nextHead.y = BOARD_HEIGHT-1; | |
if (nextHead.x === BOARD_WIDTH) nextHead.x = 0; | |
if (nextHead.y === BOARD_HEIGHT) nextHead.y = 0; | |
} | |
} | |
function moveSnake() { | |
removePoop(nextHead); | |
if (eatFood(nextHead)) | |
nextHead.hasFood = true; | |
else if (eatingCount) | |
eatingCount--; | |
else | |
createPoop(snake.pop()); | |
snake.unshift(nextHead); | |
} | |
function eatFood(pos) { | |
const idx = foods.findIndex((f) => equals(f, pos)); | |
if (idx === -1) return false; | |
const food = foods[idx]; | |
eatingCount += food.value; | |
foods.splice(idx, 1); | |
return true; | |
} | |
function createPoop(poop) { | |
if (snake.length > 1 && poop.hasFood) { | |
const { x, y } = poop; | |
poops.push({ x, y, timeToVanish: REDRAW_TIME * Math.min(BOARD_WIDTH, BOARD_HEIGHT) * 5 }); | |
} | |
} | |
function cleanPoop() { | |
if (poops.length > 10) | |
removePoop(poops[0]); | |
for (const poop of poops) { | |
poop.timeToVanish -= REDRAW_TIME; | |
if (!poop.timeToVanish) { | |
removePoop(poop); | |
obstacles.push(poop); | |
} | |
} | |
} | |
function removePoop(pos) { | |
const idx = poops.findIndex((c) => equals(c, pos)); | |
if (idx > -1) poops.splice(idx, 1); | |
} | |
function checkGameOver() { | |
const head = snake[0]; | |
return head.x === -1 || head.y === -1 || | |
head.x === BOARD_WIDTH || head.y === BOARD_HEIGHT || | |
includes(snake.slice(3), head) || | |
includes(obstacles, head); | |
} | |
function renderPoints() { | |
const points = snake.length - 1; | |
const record = infinityGameMode ? infinityRecord : walledRecord; | |
const hasOvercomeRecord = points > record; | |
for (const counter of pointsCounters) { | |
counter.innerText = points; | |
if (hasOvercomeRecord) counter.classList.add('has-overcome'); | |
} | |
} | |
function renderRecords() { | |
const record = infinityGameMode ? infinityRecord : walledRecord; | |
for (const counter of recordCounters) | |
counter.innerText = record; | |
for (const counter of recordCounters) | |
counter.classList.remove('has-overcome'); | |
for (const wrapper of recordWrappers) | |
wrapper.classList.toggle('active', record > 0); | |
for (const counter of infinityRecordCounters) | |
counter.innerText = infinityRecord; | |
for (const counter of walledRecordCounters) | |
counter.innerText = walledRecord; | |
} | |
function setRecord() { | |
const points = snake.length - 1; | |
const previousRecord = infinityGameMode ? infinityRecord : walledRecord; | |
if (points > previousRecord) { | |
if (infinityGameMode) { | |
infinityRecord = points; | |
localStorage.setItem('snake.infinityRecord', infinityRecord); | |
} else { | |
walledRecord = points; | |
localStorage.setItem('snake.walledRecord', walledRecord); | |
} | |
renderRecords(); | |
} | |
} | |
function togglePause() { | |
paused = !paused; | |
gameWrapper.setAttribute('data-gamestate', paused ? 'paused' : 'started'); | |
} | |
function handlePlayPause() { | |
if (hasGameOver) { | |
resetGame(); | |
return; | |
} | |
if (!initialized) { | |
initGame(); | |
return; | |
} | |
togglePause(); | |
} | |
function toggleGameMode() { | |
infinityGameMode = !infinityGameMode; | |
setGridGameMode(); | |
localStorage.setItem('snake.isInfinityGame', infinityGameMode ? 1 : 0); | |
renderRecords(); | |
} | |
function setGridGameMode() { | |
gameWrapper.setAttribute('data-gamemode', infinityGameMode ? 'infinity' : 'walled'); | |
} | |
function toggleControlMode() { | |
relativeControlsMode = !relativeControlsMode; | |
setControlMode(); | |
localStorage.setItem('snake.isRelativeControls', relativeControlsMode ? 1 : 0); | |
} | |
function setControlMode() { | |
gameWrapper.setAttribute('data-controlmode', relativeControlsMode ? 'relative' : 'absolute'); | |
} | |
function handleInput(e) { | |
switch (e.key.toLowerCase()) { | |
case 'enter': | |
case 'p': | |
handlePlayPause(); | |
break; | |
case 'escape': | |
willReset = true; | |
break; | |
case 'i': | |
if (!initialized) toggleGameMode(); | |
break; | |
case 'u': | |
if (!initialized) toggleControlMode(); | |
break; | |
case 'arrowup': | |
case 'w': | |
if (!relativeControlsMode) { | |
if (isPlaying()) movementBuffer.push('N'); | |
break; | |
} | |
case 'arrowdown': | |
case 's': | |
if (!relativeControlsMode) { | |
if (isPlaying()) movementBuffer.push('S'); | |
break; | |
} | |
case 'arrowright': | |
case 'd': | |
if (isPlaying()) { | |
if (relativeControlsMode) | |
movementBuffer.push(relativeDirections[direction]['arrowright']); | |
else | |
movementBuffer.push('E'); | |
} | |
break; | |
case 'arrowleft': | |
case 'a': | |
if (isPlaying()) { | |
if (relativeControlsMode) | |
movementBuffer.push(relativeDirections[direction]['arrowleft']); | |
else | |
movementBuffer.push('W'); | |
} | |
break; | |
default: | |
return; | |
} | |
e.preventDefault(); | |
} | |
function isPlaying() { | |
return initialized && !paused && !hasGameOver; | |
} | |
function gameOver() { | |
hasGameOver = true; | |
gameWrapper.setAttribute('data-gamestate', 'ended'); | |
drawBoard(); | |
setRecord(); | |
console.warn('GAME OVER'); | |
} | |
function equals(o1, o2) { | |
return o1.x === o2.x && o1.y === o2.y; | |
} | |
function includes(arr, obj) { | |
return arr.findIndex((c) => equals(obj, c)) !== -1; | |
} | |
function getRandomInt(min, max) { | |
return Math.floor(Math.random() * (max - min + 1) + min); | |
} | |
async function delay(ms) { | |
return new Promise(resolve => setTimeout(() => resolve(), ms)); | |
} | |
</script> | |
</html> |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment