<main id='app'></main>
@import url("https://fonts.googleapis.com/css2?family=Press+Start+2P&display=swap");
/* Global styles */
:root {
--green: #79be5b;
--black: #0000;
--grey: rgb(115, 115, 115);
--dark-green: #77b85c;
--light-black: #1e2c0f;
--light-grey: rgb(208, 208, 208);
}
@font-face {
font-family: "Press Start 2P";
src: url("@fonts/PressStart2P-Regular.woff2") format("woff2"),
url("@fonts/PressStart2P-Regular.woff") format("woff");
font-weight: 400;
font-style: normal;
font-display: swap;
}
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
html {
font-size: 62.5%;
font-family: "Press Start 2P", cursive;
}
body {
background: var(--green);
min-height: 100vh;
padding: 10rem;
}
main {
display: flex;
justify-content: center;
align-items: center;
min-height: inherit;
}
section {
width: 60vw;
height: 60vw;
min-width: 320px;
min-height: 320px;
max-width: 500px;
max-height: 500px;
background: var(--green);
}
button {
padding: 1rem;
background: transparent;
border: 4px double black;
border-radius: 5px;
font-family: "Press Start 2P", cursive;
font-size: 1.2rem;
text-transform: uppercase;
cursor: pointer;
}
button:hover {
transform: scale(1.05);
}
button:active {
transform: scale(1);
}
.hidden {
opacity: 0;
}
/* Menu section */
.menu-section {
display: flex;
flex-direction: column;
justify-content: space-evenly;
align-items: center;
padding: 2rem 0;
border: 5px double var(--light-black);
border-radius: 10px;
}
/* Menu header */
.menu-header {
width: 50%;
margin-bottom: 2rem;
text-align: center;
}
.menu-header--img {
width: 20%;
}
.menu-header h1 {
font-size: 2.5rem;
}
.menu-button--play {
width: 30%;
min-width: 85px;
}
/* Choose a level form */
.menu-levelsForm {
display: flex;
flex-direction: column;
justify-content: space-between;
align-items: center;
width: 60%;
min-height: 145px;
text-align: center;
}
.menu-levelsForm h2 {
font-size: 1.4rem;
line-height: 2.2rem;
}
.menu-levelsForm--options {
display: flex;
justify-content: space-between;
justify-content: center;
align-items: center;
flex-wrap: wrap;
gap: 5px;
width: 100%;
margin: 1.5rem 0;
}
.levelsForm-option--label {
padding: 1rem;
border-top: 1px solid var(--light-grey);
border-bottom: 2px solid var(--light-black);
border-left: 1px solid var(--light-grey);
border-right: 2px solid var(--light-black);
border-radius: 3%;
}
.levelsForm-option--input {
display: none;
}
.levelsForm-option--input:checked + .levelsForm-option--label {
background-color: var(--dark-green);
border-top: 1px solid var(--grey);
border-left: 1px solid var(--grey);
}
.levelsForm-option--title {
font-size: 1rem;
}
/* Game section */
.game-section {
display: flex;
flex-direction: column;
position: relative;
}
/* Score */
.game-score {
position: absolute;
top: -6rem;
left: 3rem;
font-size: 2rem;
}
.game-score::before {
content: url("https://img.icons8.com/ios-filled/50/minecraft-golden-apple.png");
position: relative;
top: 5px;
}
/* Game canvas */
.game-canvas {
width: 90%;
margin: 0 auto;
border: 5px double;
border-radius: 5px;
touch-action: none;
-ms-touch-action: none;
}
/* Game Over Message */
.game-msg {
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
position: absolute;
z-index: 10;
top: -10%;
bottom: 0;
left: 0;
right: 0;
width: 88%;
height: 88%;
min-height: 200px;
min-width: 200px;
margin: auto;
text-align: center;
backdrop-filter: blur(10px);
}
.game-msg.hidden {
z-index: -10;
}
.game-msg--over {
font-size: 2rem;
}
.game-msg--highScore {
font-size: 1.3rem;
margin: 2rem 0;
}
.game-msg-playBtn {
margin: 1rem 0;
}
.game-msg-backToMenu {
background: transparent;
border: none;
font-size: 0.8rem;
line-height: 1.6rem;
text-decoration: underline;
}
/* Game move buttons */
.game-btns {
display: grid;
grid-template-columns: repeat(3, 25%);
grid-template-rows: repeat(2, auto);
grid-gap: 0.5rem;
justify-content: center;
margin-top: 2rem;
}
.game-btns .moveUp {
grid-column: 2/3;
}
.game-btns .moveDown {
grid-column: 2/3;
grid-row: 2/3;
}
.game-btns .moveLeft {
grid-column: 1/2;
}
.game-btns .moveRight {
grid-column: 3/4;
}
class App {
constructor() {
this.container = document.querySelector("#app");
this.canvasElement = null;
this.gameCanvas = null;
this.scoreElement = null;
this.highScoreElement = null;
this.gameMsgElement = null;
}
playGame = (event) => {
event.preventDefault();
const form = document.querySelector(".menu-levelsForm");
const formData = new FormData(form);
const chosenLevel = formData.get("levelOption");
if (chosenLevel) {
const gamePage = new GameLayout();
this.container.replaceChildren(gamePage.render());
// Getting the canvas and other elements after rendering the game
this.canvasElement = document.querySelector(".game-canvas");
this.gameCanvas = this.canvasElement.getContext("2d");
this.scoreElement = document.querySelector(".game-score");
this.gameMsgElement = document.querySelector(".game-msg");
this.highScoreElement = document.querySelector(".highScore-span");
// Creating the snake and the apple in canvas
gamePage.start(chosenLevel);
}
};
fillGameCanvas = ({ color, x, y, width, height }) => {
this.gameCanvas.fillStyle = color;
this.gameCanvas.fillRect(x, y, width, height);
};
drawImgGameCanvas = ({ image, x, y, width, height }) => {
this.gameCanvas.drawImage(image, x, y, width, height);
};
clearGameCanvas = () => {
this.gameCanvas.clearRect(
0,
0,
this.canvasElement.width,
this.canvasElement.height
);
};
updateScoreElement = (val) => {
this.scoreElement.textContent = val;
};
updateHighScoreElement = (val) => {
this.highScoreElement.textContent = val;
};
toggleGameOverMsg = () => {
this.gameMsgElement.classList.toggle("hidden");
};
backToMenuPage = () => {
const menuPage = new Menu();
this.container.replaceChildren(menuPage.render());
};
}
class Cell {
constructor(defaultPosition = { x: 140, y: 140 }) {
this.position = defaultPosition;
this.defaultPosition = defaultPosition;
this.size = {
width: 20,
height: 20,
};
this.color = "#233311";
}
paint() {
const fill = { ...this.position, ...this.size, color: this.color };
app.fillGameCanvas(fill);
}
}
class Apple extends Cell {
constructor() {
super({
// Set default position
x: 140,
y: 40,
});
this.image = new Image();
this.image.src = "https://img.icons8.com/ios-filled/50/minecraft-golden-apple.png";
}
paint() {
const draw = { ...this.position, ...this.size, image: this.image };
app.drawImgGameCanvas(draw);
}
setNewPosition() {
this.position = {
x: this.getRandomPosition(),
y: this.getRandomPosition(),
};
}
getRandomPosition() {
const maxCellsOnCanvas = (app.canvasElement.width - this.size.width) / 10;
const minCellsOnCanvas = 0;
let randomPosition = Math.floor(
Math.random() *
(maxCellsOnCanvas - minCellsOnCanvas - 0 + minCellsOnCanvas) +
0
);
if (randomPosition % 2 !== 0) {
// If it's odd make it even
randomPosition -= 1;
}
return randomPosition * 10;
}
reset() {
this.position = {
x: this.defaultPosition.x,
y: this.defaultPosition.y,
};
}
}
class SnakeCell extends Cell {
constructor({ id, prevCell }) {
super({
x: prevCell?.oldPosition.x || 140,
y: prevCell?.oldPosition.y || 140,
});
this.id = id;
this.oldPosition = {
x: this?.position.x || null,
y: this?.position.y || null,
};
this.nextCell = null;
this.prevCell = prevCell;
}
updateCellPosition(velocity) {
if (this.id === 0) {
// Snake head
this.position.x += velocity.x;
this.position.y += velocity.y;
} else {
this.position.y = this.prevCell.oldPosition.y;
this.position.x = this.prevCell.oldPosition.x;
}
}
setOldPosition() {
this.oldPosition.y = this.position.y;
this.oldPosition.x = this.position.x;
}
}
class Snake {
constructor({ defaultSize }) {
this.head = null;
this.tail = null;
this.size = 0;
this.defaultSize = defaultSize;
this.velocity = { x: 0, y: 0 };
}
create() {
let initialSize = this.defaultSize;
while (initialSize) {
this.createCell();
initialSize--;
}
}
createCell() {
if (!this.head) {
this.head = new SnakeCell({ id: this.size, prevCell: null });
this.tail = this.head;
} else {
const newSnakeCell = new SnakeCell({
id: this.size,
prevCell: this.tail,
});
this.tail.nextCell = newSnakeCell;
this.tail = newSnakeCell;
}
this.tail.paint();
this.size++;
}
move({ event, moveTo }) {
const moveToValue = this.getMoveToValue({ event, moveTo });
if (
!this.velocity.y &&
(moveToValue === "moveUp" || moveToValue === "moveDown")
) {
const { height: val } = this.head.size;
const velocity = moveToValue === "moveDown" ? val : -val;
this.updateVelocity("y", velocity);
} else if (
!this.velocity.x &&
(moveToValue === "moveRight" || moveToValue === "moveLeft")
) {
const { width: val } = this.head.size;
const velocity = moveToValue === "moveRight" ? val : -val;
this.updateVelocity("x", velocity);
}
}
getMoveToValue({ event, moveTo }) {
const validKeys = {
ArrowUp: "moveUp",
ArrowDown: "moveDown",
ArrowRight: "moveRight",
ArrowLeft: "moveLeft",
};
if (!event) {
return moveTo;
} else if (event.type === "keydown" && validKeys[event.key]) {
return validKeys[event.key];
} else if (event.type === "click") {
return event.target.className;
}
}
updateVelocity(axis, val) {
const oppositeAxis = {
x: "y",
y: "x",
};
this.velocity[axis] += val;
this.velocity[oppositeAxis[axis]] = 0;
}
updatePosition() {
let snakeCell = this.head;
while (snakeCell) {
snakeCell.setOldPosition();
snakeCell.updateCellPosition(this.velocity);
snakeCell.paint();
snakeCell = snakeCell.nextCell;
}
}
hasCollidedWithItself() {
let snakeHeadPosition = this.head.position;
let snakeCell = this.head.nextCell;
while (snakeCell) {
if (
snakeHeadPosition.x === snakeCell.position.x &&
snakeHeadPosition.y === snakeCell.position.y
) {
return true;
}
snakeCell = snakeCell.nextCell;
}
return false;
}
reset() {
this.size = 0;
this.head = null;
this.tail = null;
this.velocity = {
x: 0,
y: 0,
};
this.create();
}
}
class Menu {
constructor() {
this.container = document.createElement("section");
this.container.setAttribute("class", "menu-section");
this.gameLevels = ["Easy", "Normal", "Hard"];
}
render() {
const menuHeader = this.createMenuHeader();
const levelsForm = this.createLevelsForm();
this.container.append(menuHeader, levelsForm);
return this.container;
}
createMenuHeader() {
const container = document.createElement("div");
container.setAttribute("class", "menu-header");
const headerImage = document.createElement("img");
headerImage.setAttribute("src", 'https://img.icons8.com/ios-filled/50/rattlesnake.png');
headerImage.setAttribute("alt", "snake");
headerImage.setAttribute("class", "menu-header--img");
const title = document.createElement("h1");
title.textContent = "Snake";
container.append(headerImage, title);
return container;
}
createLevelsForm() {
const form = document.createElement("form");
form.setAttribute("class", "menu-levelsForm");
const title = document.createElement("h2");
title.textContent = "Choose a level:";
const optionsContainer = document.createElement("div");
optionsContainer.setAttribute("class", "menu-levelsForm--options");
this.gameLevels.forEach((level) => {
const label = document.createElement("label");
label.setAttribute("class", "levelsForm-option--label");
label.setAttribute("for", level.toLowerCase());
const option = document.createElement("input");
option.setAttribute("class", "levelsForm-option--input");
option.setAttribute("id", level.toLowerCase());
option.setAttribute("name", "levelOption");
option.setAttribute("type", "radio");
option.setAttribute("value", level.toLowerCase());
option.setAttribute("autocomplete", "off");
const optionTitle = document.createElement("span");
optionTitle.setAttribute("class", "levelsForm-option--title");
optionTitle.textContent = level;
label.append(optionTitle);
optionsContainer.append(option, label);
});
const playBtn = document.createElement("button");
playBtn.setAttribute("class", "menu-button--play");
playBtn.setAttribute("type", "submit");
playBtn.textContent = "Play";
playBtn.addEventListener("click", app.playGame);
form.append(title, optionsContainer, playBtn);
return form;
}
}
class Game {
constructor() {
this.snake = null;
this.apple = new Apple();
this.speed = null;
this.isGameOver = false;
this.score = 0;
this.highScore = localStorage.getItem("highScore") || 0;
this.interval = null;
this.moveSnake = this.moveSnake.bind(this);
this.paintCanvas = this.paintCanvas.bind(this);
this.playAgain = this.playAgain.bind(this);
}
start(chosenLevel) {
switch (chosenLevel) {
case "easy":
this.speed = 200;
this.snake = new Snake({ defaultSize: 3 });
break;
case "normal":
this.speed = 150;
this.snake = new Snake({ defaultSize: 5 });
break;
case "hard":
this.speed = 100;
this.snake = new Snake({ defaultSize: 8 });
break;
}
this.snake.create();
this.apple.paint();
this.moveSnake({ moveTo: "moveUp" })();
}
moveSnake({ moveTo } = {}) {
let debounceTimer = null;
return (event) => {
if (debounceTimer) {
clearTimeout(debounceTimer);
}
debounceTimer = setTimeout(() => {
if (!this.isGameOver) {
this.snake.move({ event, moveTo });
if (!this.interval) {
this.paintCanvas();
this.interval = setInterval(this.paintCanvas, this.speed);
}
}
}, 100);
};
}
paintCanvas() {
app.clearGameCanvas();
this.snake.updatePosition();
this.apple.paint();
this.checkCollisionWithApple();
this.checkGameOver();
}
checkCollisionWithApple() {
if (
this.snake.head.position.x === this.apple.position.x &&
this.snake.head.position.y === this.apple.position.y
) {
this.updateScore();
this.snake.createCell(); // The snake grows
this.apple.setNewPosition();
}
}
checkGameOver() {
const snakeHead = this.snake.head;
const snakeHeadPosition = snakeHead.position;
const borderStart = 0;
const borderEnd = app.canvasElement.width - snakeHead.size.width;
// If snake head collides with a border or with itself
if (
snakeHeadPosition.x < borderStart ||
snakeHeadPosition.x > borderEnd ||
snakeHeadPosition.y < borderStart ||
snakeHeadPosition.y > borderEnd ||
this.snake.hasCollidedWithItself()
) {
this.isGameOver = true;
this.stopInterval();
this.getHighScore();
app.toggleGameOverMsg();
}
}
updateScore() {
this.score++;
app.updateScoreElement(this.score); // !!
}
stopInterval() {
clearInterval(this.interval);
this.interval = null;
}
getHighScore() {
if (this.score > this.highScore) {
this.highScore = this.score;
localStorage.setItem("highScore", this.highScore);
app.updateHighScoreElement(this.highScore);
}
}
playAgain() {
app.clearGameCanvas();
this.score = 0;
this.isGameOver = false;
this.snake.reset();
this.apple.reset();
app.updateScoreElement(this.score);
app.toggleGameOverMsg();
this.moveSnake({ moveTo: "moveUp" })();
}
}
class GameLayout extends Game {
constructor() {
super();
this.container = document.createElement("section");
this.container.setAttribute("class", "game-section");
/* Move on mobile */
this.touchStartX = 0;
this.touchStartY = 0;
this.touchEndX = 0;
this.touchEndY = 0;
this.moveOnMobileStart = this.moveOnMobileStart.bind(this);
this.moveOnMobileEnd = this.moveOnMobileEnd.bind(this);
}
render() {
const scoreElement = this.createScoreElement();
const canvas = this.createCanvas();
const gameOverMsgElement = this.createGameOverMsg();
const gameButtons = this.createGameButtons();
this.container.append(
scoreElement,
canvas,
gameOverMsgElement,
gameButtons
);
document.body.addEventListener("keydown", this.moveSnake());
return this.container;
}
createScoreElement() {
const scoreElement = document.createElement("span");
scoreElement.setAttribute("class", "game-score");
scoreElement.textContent = 0;
return scoreElement;
}
createCanvas() {
const canvas = document.createElement("canvas");
canvas.setAttribute("class", "game-canvas");
canvas.setAttribute("width", "300");
canvas.setAttribute("height", "300");
canvas.setAttribute("tabindex", "0");
canvas.addEventListener("touchstart", this.moveOnMobileStart);
canvas.addEventListener("touchend", this.moveOnMobileEnd);
return canvas;
}
createGameOverMsg() {
const gameOverMsgContainer = document.createElement("div");
gameOverMsgContainer.setAttribute("class", "game-msg hidden");
const gameOverP = document.createElement("p");
gameOverP.setAttribute("class", "game-msg--over");
gameOverP.textContent = "GAME OVER";
const highScoreP = document.createElement("p");
highScoreP.setAttribute("class", "game-msg--highScore");
highScoreP.textContent = "High Score: ";
const highScoreVal = document.createElement("span");
highScoreVal.setAttribute("class", "highScore-span");
highScoreVal.textContent = this.highScore;
const playAgainBtn = document.createElement("button");
playAgainBtn.setAttribute("class", "game-msg-playBtn");
playAgainBtn.textContent = "Play again";
const backToMenuBtn = document.createElement("button");
backToMenuBtn.setAttribute("class", "game-msg-backToMenu");
backToMenuBtn.textContent = "Choose another level";
playAgainBtn.addEventListener("click", this.playAgain);
backToMenuBtn.addEventListener("click", app.backToMenuPage);
highScoreP.append(highScoreVal);
gameOverMsgContainer.append(
gameOverP,
highScoreP,
playAgainBtn,
backToMenuBtn
);
return gameOverMsgContainer;
}
createGameButtons() {
const buttonsValue = {
moveUp: "↑",
moveDown: "↓",
moveLeft: "←",
moveRight: "→",
};
const container = document.createElement("div");
container.setAttribute("class", "game-btns");
for (let key in buttonsValue) {
const button = document.createElement("button");
button.setAttribute("class", key);
button.textContent = buttonsValue[key];
button.addEventListener("click", this.moveSnake());
container.append(button);
}
return container;
}
/* Move on mobile */
moveOnMobileStart(event) {
this.touchStartX = event.changedTouches[0].screenX;
this.touchStartY = event.changedTouches[0].screenY;
}
moveOnMobileEnd(event) {
this.touchEndX = event.changedTouches[0].screenX;
this.touchEndY = event.changedTouches[0].screenY;
const x = Math.abs(this.touchStartX - this.touchEndX);
const y = Math.abs(this.touchStartY - this.touchEndY);
if (x > y) {
if (this.touchStartX < this.touchEndX) {
this.moveSnake({ moveTo: "moveRight" })();
} else {
this.moveSnake({ moveTo: "moveLeft" })();
}
} else if (y > x) {
if (this.touchStartY < this.touchEndY) {
this.moveSnake({ moveTo: "moveDown" })();
} else {
this.moveSnake({ moveTo: "moveUp" })();
}
}
}
}
const app = new App();
const menuPage = new Menu();
app.container.append(menuPage.render());
This Pen doesn't use any external CSS resources.
This Pen doesn't use any external JavaScript resources.