/** # Snake * * Pure JavaScript implementation of the classic game Snake. * * @author Jonathan Bernard * @copyright 2014-2015 Jonathan Bernard */ (function() { var S = window.Snake = {}; // True constants, never change. var SPACE = 0; var WALL = 1; var SNAKE = 2; var FOOD = 3; // Fake constants (constant after board initialization). var ROWS = S.ROWS = 50; var COLS = S.COLS = 50; var tileWidth, tileHeight; // The current game board. var board; // The starting game board. var startBoard; var startGrowthFactor = 2; // When we eat food, how many turns do we grow? var startGFIncrease=0; // When we eat, how much should the GF increase? var body = []; // Queue of body indices. var headCur; // Index of the head's current position. var direction = 1; // Current movement direction. var foodEaten = false; // Did we eat food this turn? var growthFactor; // When we eat food, how many turns do we grow? var growthCounter; // How many remaining turns should we grow? var fps; // How fast should the game run (frames/sec)? var score = 0; // How many pieces of food have we eaten? // HTML Elements var canvas; var g; var editorControls; var editorDataTextarea; var cursorIdx; var cursorBlock; // Flags var editor = false; var dead = true; var pause = false; // Time-management var skipTicks; var nextGameTick; var cmdQueue = []; var handers = []; function coord2idx(row, col) { return (COLS * row) + col; } function idx2row(idx) { return Math.floor(idx/COLS); } function idx2col(idx) { return idx % COLS; } function initialize(options) { if (!options) options = {}; S.currentLevel = options; // Store our own copy of the canvas and get a 2D graphics context. if (options.canvas) canvas = options.canvas; else canvas = document.getElementsByTagName("canvas")[0]; g = S.graphicsContext = canvas.getContext("2d"); // Remove any existing handlers canvas.removeEventListener('keydown', handleGameKey); canvas.removeEventListener('keydown', handleEditorKey); canvas.removeEventListener('click', handleEditorClick); // Read in the board dimensions. if (options.rows) S.ROWS = ROWS = options.rows; if (options.cols) S.COLS = COLS = options.cols; if (options.board) startBoard = options.board; else { // Wipe the board and draw the walls. startBoard = new Array(ROWS * COLS); var i; for (i = 0; i < startBoard.length; i++ ) { if (Math.floor(i / COLS) === 0 || (i % COLS) === 0) { startBoard[i] = WALL; } else if (Math.floor(i / COLS) === (ROWS - 1) || (i % COLS) === (COLS - 1)) { startBoard[i] = WALL; } else startBoard[i] = SPACE; } } // Figure out how big each game tile is. tileWidth = Math.ceil(canvas.width / COLS); tileHeight = Math.ceil(canvas.height / ROWS); // Mark the player as dead. This is primarily for the case where a // player re-initializes the board during a game in progress. It // causes any scheduled logic to quit without affecting out game state. dead = true; // Read in the difficulty and difficulty-related data. if (!options.difficulty) options.difficulty = "easy"; var difVals = (options[options.difficulty] ? options[options.difficulty] : {}); startGrowthFactor = difVals.growthFactor ? difVals.growthFactor : 2; startGFIncrease = difVals.growthFactorIncrease ? difVals.growthFactorIncrease : 1; fps = difVals.fps ? difVals.fps : 3; // Check to see if they want to use the editor or the game. editor = Boolean(options.editor); // Clear the canvas. g.fillStyle = "white"; g.fillRect(0, 0, canvas.width, canvas.height); // They want the game if (!editor) { canvas.addEventListener('keydown', handleGameKey); g.font = "24px sans-serif"; g.fillStyle = "black"; g.fillText("Press Space to begin.", 50, Math.floor(canvas.height/2), canvas.width - 100); skipTicks = Math.ceil(1000 / fps); } // They want the editor else { canvas.addEventListener('keydown', handleEditorKey); canvas.addEventListener('click', handleEditorClick); editorControls = document.getElementById("editorControls"); editorDataTextarea = document.querySelector("#editorControls textarea"); startEditor(); } canvas.focus(); } function startGame() { while (body.length > 0) body.pop(); while (cmdQueue.length > 0) cmdQueue.pop(); direction = 1; dead = pause = false; score = growthCounter = 0; nextGameTick = (new Date()).getTime() + skipTicks; growthFactor = startGrowthFactor; growthFactorIncrease = startGFIncrease; // Copy over a fresh board. board = new Array(startBoard.length); for (var i = 0; i < board.length; i++) board[i] = startBoard[i]; // Create a new food item and add it to the board. board[randomEmptySpace()] = FOOD; // Find or write the initial body segment onto the board. for (headCur = -1, i = 0; i < board.length; i++) if (board[i] == SNAKE) headCur = i; if (headCur < 0) headCur = randomEmptySpace(); body.push(headCur); board[headCur] = SNAKE; // Draw the board paintBoard(); gameLoop(); } function startEditor() { board = new Array(ROWS * COLS); cursorBlock = SPACE; cursorIdx = headCur = coord2idx( Math.floor((ROWS - 1) / 2), Math.floor((COLS - 1) / 2)); // Wipe the board and draw the walls. var i; for (i = 0; i < board.length; i++ ) { if (Math.floor(i / COLS) === 0 || (i % COLS) === 0) { board[i] = WALL; } else if (Math.floor(i / COLS) == (ROWS - 1) || (i % COLS) == (COLS - 1)) { board[i] = WALL; } else board[i] = SPACE; } board[headCur] = SNAKE; document.getElementById("editorControls").style.display = "inline-block"; paintBoard(); } function gameLoop() { // Break the game loop if the player died or paused. if (dead || pause) return; if ((new Date()).getTime() > nextGameTick) { updateAndDraw(); nextGameTick += skipTicks; } requestAnimationFrame(gameLoop); } // For this game I want the game logic and the refresh rate to be tied // together. The snake moves one step per animation frame. We do limit the // animation frame to a given fps. This works because there are no other // elements being animated other than the snake and no partial animation // states. function updateAndDraw() { if (!g) return false; foodEaten = false; // 1. Read up to one command from the user. if (cmdQueue.length > 0) { var cmd = cmdQueue.shift(); if (cmd == 37) direction = -1; // Left else if (cmd == 38) direction = -COLS; // Up else if (cmd == 39) direction = 1; // Right else if (cmd == 40) direction = COLS; /* Down */ } // 2. Find the head's next spot. headCur = headCur + direction; // 3. First check to see if we've eaten food (since it will affect // collision detection for the body). This is also where growth // happens via not moving the tail. if (board[headCur] == FOOD) { foodEaten = true; score+=1; growthCounter += growthFactor; growthFactor += growthFactorIncrease; var scoreDetails = collectLevelData(); scoreDetails.score = score; scoreDetails.bodyLength = body.length; // Alert anyone listening to us. var scoreEvent = new CustomEvent('score', {detail: scoreDetails}); canvas.dispatchEvent(scoreEvent); } // 4. Remove the tail. if (growthCounter === 0) { var tailIdx = body.shift(); board[tailIdx] = SPACE; paintIdx(tailIdx); } else growthCounter -= 1; // 5. Detect wall and snake collisions if (board[headCur] == WALL || board[headCur] == SNAKE) { die(); return; } // 6. Move the head body.push(headCur); board[headCur] = SNAKE; paintIdx(headCur); // 7. Create more food if needed. if (foodEaten) { var foodLoc = randomEmptySpace(); board[foodLoc] = FOOD; paintIdx(foodLoc); } } // Find a new location for a piece of food. function randomEmptySpace() { // Find all the spaces on the board that are not occupied var emptySpaces = []; for (var i = 0; i < board.length; i++) if (board[i] == SPACE) emptySpaces.push(i); // Choose one of those spaces at random. return emptySpaces[Math.floor(Math.random() * emptySpaces.length)]; } function die() { paintIdxColor(headCur, "red"); dead = true; //g.fillStyle = "white"; //g.fillRect(0, 0, canvas.width, canvas.height); g.font = "24px sans-serif"; g.fillStyle = "red"; g.fillText("Press Space to retry.", 50, Math.floor(canvas.height/2), canvas.width - 100); } function handleGameKey(ke) { var key = ke.keyCode ? ke.keyCode : ke.which; // We will only intercept key events which we care about. This makes it // safe to attach the handler to the window or anywhere else without // worrying about the game becoming a black-hole for events. var dontCare = false; // Space, start a new game. if (key == 32) { dead = true; requestAnimationFrame(startGame); } // Pause, pause or unpause the game. else if (key == 19) { if (pause === true) { pause = false; nextGameTick = (new Date()).getTime(); gameLoop(); } else pause = true; } // Queue directions in the command queue else if ((key > 36) && (key < 41)) cmdQueue.push(key); // Anything else we don't care about. else dontCare = true; // If we *do* care about this key, consume it and prevent it from // bubbling up. if (!dontCare) ke.preventDefault(); return false; } // Handle input for the game editor. The editor is entirely input driven. // All of it's actions and logic are in response to user input. There is no // "game" loop and most of the editor's logic happens in this function. function handleEditorKey(ke) { var key = ke.keyCode ? ke.keyCode : ke.which; // Again, we will pass along input we are not interested in. var dontCare = false; switch (key) { // Space or Enter: place a tile. case 32: case 13: board[cursorIdx] = cursorBlock; break; // Direction keys: move the cursor. case 37: cursorIdx -= 1; break; // Left case 38: cursorIdx -= COLS; break; // Up case 39: cursorIdx += 1; break; // Right case 40: cursorIdx += COLS; break; // Down // Number keys: select a tile type. case 49: case 97: cursorBlock = SPACE; break; // 1 case 50: case 98: cursorBlock = WALL; break; // 2 case 51: case 99: cursorBlock = SNAKE; break; // 3 case 52: case 100: cursorBlock = FOOD; break; // 4 default: dontCare = true; } // Our board may have changed, let's spit out the data. emitEditorLevelData(); // Redraw the board. paintBoard(); // Draw the current cursor position. g.strokeStyle = "solid 4px gray"; g.strokeRect( (cursorIdx % COLS) * tileWidth, Math.floor(cursorIdx / COLS) * tileHeight, tileWidth, tileHeight); // Consume events we care about and prevent them from bubbling up. if (!dontCare) ke.preventDefault(); } function handleEditorClick(ke) { } // Write out the editor level data as a JSON string to the textarea. function emitEditorLevelData() { editorDataTextarea.value = JSON.stringify(collectLevelData()); } function collectLevelData() { return { board: board, rows: ROWS, cols: COLS, growthFactor: startGrowthFactor, growthFactorIncrease: startGFIncrease, targetScore: targetScore, fps: fps }; } // Load the editor data as a JSON string from the textarea. function loadEditorLevelData() { } // Draw the entire board. function paintBoard() { for (i = 0; i < board.length; i++) { paintIdx(i); } } function paintCoord(row, col) { var idx = coord2idx(row, col); if (board[idx] == WALL) g.fillStyle = "black"; else if (board[idx] == FOOD) g.fillStyle = "green"; else if (board[idx] == SNAKE) g.fillStyle = "blue"; else g.fillStyle = "white"; g.fillRect(col, row, tileWidth, tileHeight); } function paintIdxColor(idx, color) { g.fillStyle = color; g.fillRect( (idx % COLS) * tileWidth, Math.floor(idx / COLS) * tileHeight, tileWidth, tileHeight); } function paintIdx(idx) { if (board[idx] == WALL) g.fillStyle = "black"; else if (board[idx] == FOOD) g.fillStyle = "green"; else if (board[idx] == SNAKE) g.fillStyle = "blue"; else g.fillStyle = "white"; g.fillRect( (idx % COLS) * tileWidth, Math.floor(idx / COLS) * tileHeight, tileWidth, tileHeight); } S.initialize = initialize; S.pause = function() { pause = true; }; S.currentLevel = {}; })();