Repository: epidemian/snake Branch: master Commit: 319a58d45acc Files: 5 Total size: 19.6 KB Directory structure: gitextract_eh2l0vp_/ ├── README.md ├── UNLICENSE ├── index.html ├── snake.js └── style.css ================================================ FILE CONTENTS ================================================ ================================================ FILE: README.md ================================================ # URL Snake Play the classic snake game on a URL! This is how the game should look: ![Pro level gameplay](gameplay.gif) Note that the game might be unplayable on some browsers for different reasons, like the browser not showing the full URL, or not allowing it to change so frequently, or escaping the Braille characters used to display the game. Although this game is kind of a joke, bug reports, ideas and pull requests are always [welcome](https://github.com/epidemian/snake/issues)! ================================================ FILE: UNLICENSE ================================================ This is free and unencumbered software released into the public domain. Anyone is free to copy, modify, publish, use, compile, sell, or distribute this software, either in source code form or as a compiled binary, for any purpose, commercial or non-commercial, and by any means. In jurisdictions that recognize copyright laws, the author or authors of this software dedicate any and all copyright interest in the software to the public domain. We make this dedication for the benefit of the public at large and to the detriment of our heirs and successors. We intend this dedication to be an overt act of relinquishment in perpetuity of all present and future rights to this software under copyright law. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. For more information, please refer to ================================================ FILE: index.html ================================================ URL Snake!
================================================ FILE: snake.js ================================================ 'use strict'; var GRID_WIDTH = 40; var SNAKE_CELL = 1; var FOOD_CELL = 2; var UP = {x: 0, y: -1}; var DOWN = {x: 0, y: 1}; var LEFT = {x: -1, y: 0}; var RIGHT = {x: 1, y: 0}; var INITIAL_SNAKE_LENGTH = 4; var BRAILLE_SPACE = '\u2800'; var grid; var snake; var currentDirection; var moveQueue; var hasMoved; var gamePaused = false; var urlRevealed = false; var whitespaceReplacementChar; function main() { detectBrowserUrlWhitespaceEscaping(); cleanUrl(); setupEventHandlers(); drawMaxScore(); initUrlRevealed(); startGame(); var lastFrameTime = Date.now(); window.requestAnimationFrame(function frameHandler() { var now = Date.now(); if (!gamePaused && now - lastFrameTime >= tickTime()) { updateWorld(); drawWorld(); lastFrameTime = now; } window.requestAnimationFrame(frameHandler); }); } function detectBrowserUrlWhitespaceEscaping() { // Write two Braille whitespace characters to the hash because Firefox doesn't // escape single WS chars between words. history.replaceState(null, null, '#' + BRAILLE_SPACE + BRAILLE_SPACE) if (location.hash.indexOf(BRAILLE_SPACE) == -1) { console.warn('Browser is escaping whitespace characters on URL') var replacementData = pickWhitespaceReplacementChar(); whitespaceReplacementChar = replacementData[0]; $('#url-escaping-note').classList.remove('invisible'); $('#replacement-char-description').textContent = replacementData[1]; } } function cleanUrl() { // In order to have the most space for the game, shown on the URL hash, // remove all query string parameters and trailing / from the URL. history.replaceState(null, null, location.pathname.replace(/\b\/$/, '')); } function setupEventHandlers() { var directionsByKey = { // Arrows 37: LEFT, 38: UP, 39: RIGHT, 40: DOWN, // WASD 87: UP, 65: LEFT, 83: DOWN, 68: RIGHT, // hjkl 75: UP, 72: LEFT, 74: DOWN, 76: RIGHT }; document.onkeydown = function (event) { var key = event.keyCode; if (key in directionsByKey) { changeDirection(directionsByKey[key]); } }; // Use touchstart instead of mousedown because these arrows are only shown on // touch devices, and also because there is a delay between touchstart and // mousedown on those devices, and the game should respond ASAP. $('#up').ontouchstart = function () { changeDirection(UP) }; $('#down').ontouchstart = function () { changeDirection(DOWN) }; $('#left').ontouchstart = function () { changeDirection(LEFT) }; $('#right').ontouchstart = function () { changeDirection(RIGHT) }; window.onblur = function pauseGame() { gamePaused = true; window.history.replaceState(null, null, location.hash + '[paused]'); }; window.onfocus = function unpauseGame() { gamePaused = false; drawWorld(); }; $('#reveal-url').onclick = function (e) { e.preventDefault(); setUrlRevealed(!urlRevealed); }; document.querySelectorAll('.expandable').forEach(function (expandable) { var expand = expandable.querySelector('.expand-btn'); var collapse = expandable.querySelector('.collapse-btn'); var content = expandable.querySelector('.expandable-content'); expand.onclick = collapse.onclick = function () { expand.classList.remove('hidden'); content.classList.remove('hidden'); expandable.classList.toggle('expanded'); }; // Hide the expand button or the content when the animation ends so those // elements are not interactive anymore. // Surely there's a way to do this with CSS animations more directly. expandable.ontransitionend = function () { var expanded = expandable.classList.contains('expanded'); expand.classList.toggle('hidden', expanded); content.classList.toggle('hidden', !expanded); }; }); } function initUrlRevealed() { setUrlRevealed(Boolean(localStorage.urlRevealed)); } // Some browsers don't display the page URL, either partially (e.g. Safari) or // entirely (e.g. mobile in-app web-views). To make the game playable in such // cases, the player can choose to "reveal" the URL within the page body. function setUrlRevealed(value) { urlRevealed = value; $('#url-container').classList.toggle('invisible', !urlRevealed); if (urlRevealed) { localStorage.urlRevealed = 'y'; } else { delete localStorage.urlRevealed; } } function startGame() { grid = new Array(GRID_WIDTH * 4); snake = []; for (var x = 0; x < INITIAL_SNAKE_LENGTH; x++) { var y = 2; snake.unshift({x: x, y: y}); setCellAt(x, y, SNAKE_CELL); } currentDirection = RIGHT; moveQueue = []; hasMoved = false; dropFood(); } function updateWorld() { if (moveQueue.length) { currentDirection = moveQueue.pop(); } var head = snake[0]; var tail = snake[snake.length - 1]; var newX = head.x + currentDirection.x; var newY = head.y + currentDirection.y; var outOfBounds = newX < 0 || newX >= GRID_WIDTH || newY < 0 || newY >= 4; var collidesWithSelf = cellAt(newX, newY) === SNAKE_CELL && !(newX === tail.x && newY === tail.y); if (outOfBounds || collidesWithSelf) { endGame(); startGame(); return; } var eatsFood = cellAt(newX, newY) === FOOD_CELL; if (!eatsFood) { snake.pop(); setCellAt(tail.x, tail.y, null); } // Advance head after tail so it can occupy the same cell on next tick. setCellAt(newX, newY, SNAKE_CELL); snake.unshift({x: newX, y: newY}); if (eatsFood) { dropFood(); } } function endGame() { var score = currentScore(); var maxScore = parseInt(localStorage.maxScore || 0); if (score > 0 && score > maxScore && hasMoved) { localStorage.maxScore = score; localStorage.maxScoreGrid = gridString(); drawMaxScore(); showMaxScore(); } } function drawWorld() { var hash = '#|' + gridString() + '|[score:' + currentScore() + ']'; if (urlRevealed) { // Use the original game representation on the on-DOM view, as there are no // escaping issues there. $('#url').textContent = location.href.replace(/#.*$/, '') + hash; } // Modern browsers escape whitespace characters on the address bar URL for // security reasons. In case this browser does that, replace the empty Braille // character with a non-whitespace (and hopefully non-intrusive) symbol. if (whitespaceReplacementChar) { hash = hash.replace(/\u2800/g, whitespaceReplacementChar); } history.replaceState(null, null, hash); // Some browsers have a rate limit on history.replaceState() calls, resulting // in the URL not updating at all for a couple of seconds. In those cases, // location.hash is updated directly, which is unfortunate, as it causes a new // navigation entry to be created each time, effectively hijacking the user's // back button. if (decodeURIComponent(location.hash) !== hash) { console.warn( 'history.replaceState() throttling detected. Using location.hash fallback' ); location.hash = hash; } } function gridString() { var str = ''; for (var x = 0; x < GRID_WIDTH; x += 2) { // Unicode Braille patterns are 256 code points going from 0x2800 to 0x28FF. // They follow a binary pattern where the bits are, from least significant // to most: ⠁⠂⠄⠈⠐⠠⡀⢀ // So, for example, 147 (10010011) corresponds to ⢓ var n = 0 | bitAt(x, 0) << 0 | bitAt(x, 1) << 1 | bitAt(x, 2) << 2 | bitAt(x + 1, 0) << 3 | bitAt(x + 1, 1) << 4 | bitAt(x + 1, 2) << 5 | bitAt(x, 3) << 6 | bitAt(x + 1, 3) << 7; str += String.fromCharCode(0x2800 + n); } return str; } function tickTime() { // Game speed increases as snake grows. var start = 125; var end = 75; return start + snake.length * (end - start) / grid.length; } function currentScore() { return snake.length - INITIAL_SNAKE_LENGTH; } function cellAt(x, y) { return grid[x % GRID_WIDTH + y * GRID_WIDTH]; } function bitAt(x, y) { return cellAt(x, y) ? 1 : 0; } function setCellAt(x, y, cellType) { grid[x % GRID_WIDTH + y * GRID_WIDTH] = cellType; } function dropFood() { var emptyCells = grid.length - snake.length; if (emptyCells === 0) { return; } var dropCounter = Math.floor(Math.random() * emptyCells); for (var i = 0; i < grid.length; i++) { if (grid[i] === SNAKE_CELL) { continue; } if (dropCounter === 0) { grid[i] = FOOD_CELL; break; } dropCounter--; } } function changeDirection(newDir) { var lastDir = moveQueue[0] || currentDirection; var opposite = newDir.x + lastDir.x === 0 && newDir.y + lastDir.y === 0; if (!opposite) { // Process moves in a queue to prevent multiple direction changes per tick. moveQueue.unshift(newDir); } hasMoved = true; } function drawMaxScore() { var maxScore = localStorage.maxScore; if (maxScore == null) { return; } var maxScorePoints = maxScore == 1 ? '1 point' : maxScore + ' points' var maxScoreGrid = localStorage.maxScoreGrid; $('#max-score-points').textContent = maxScorePoints; $('#max-score-grid').textContent = maxScoreGrid; $('#max-score-container').classList.remove('hidden'); $('#share').onclick = function (e) { e.preventDefault(); shareScore(maxScorePoints, maxScoreGrid); }; } // Expands the high score details if collapsed. Only done when beating the // highest score, to grab the player's attention. function showMaxScore() { if ($('#max-score-container.expanded')) return $('#max-score-container .expand-btn').click(); } function shareScore(scorePoints, grid) { var message = '|' + grid + '| Got ' + scorePoints + ' playing this stupid snake game on the browser URL!'; var url = $('link[rel=canonical]').href; if (navigator.share) { navigator.share({text: message, url: url}); } else { navigator.clipboard.writeText(message + '\n' + url) .then(function () { showShareNote('copied to clipboard') }) .catch(function () { showShareNote('clipboard write failed') }) } } function showShareNote(message) { var note = $("#share-note"); note.textContent = message; note.classList.remove("invisible"); setTimeout(function () { note.classList.add("invisible") }, 1000); } // Super hacky function to pick a suitable character to replace the empty // Braille character (u+2800) when the browser escapes whitespace on the URL. // We want to pick a character that's close in width to the empty Braille symbol // —so the game doesn't stutter horizontally—, and also pick something that's // not too visually noisy. So we actually measure how wide and how "dark" some // candidate characters are when rendered by the browser (using a canvas) and // pick the first that passes both criteria. function pickWhitespaceReplacementChar() { var candidates = [ // U+0ADF is part of the Gujarati Unicode blocks, but it doesn't have an // associated glyph. For some reason, Chrome renders is as totally blank and // almost the same size as the Braille empty character, but it doesn't // escape it on the address bar URL, so this is the perfect replacement // character. This behavior of Chrome is probably a bug, and might be // changed at any time, and in other browsers like Firefox this character is // rendered with an ugly "undefined" glyph, so it'll get filtered out by the // width or the "blankness" check in either of those cases. ['૟', 'strange symbols'], // U+27CB Mathematical Rising Diagonal, not a great replacement for // whitespace, but is close to the correct size and blank enough. ['⟋', 'some weird slashes'] ]; var N = 5; var canvas = document.createElement('canvas'); var ctx = canvas.getContext('2d'); ctx.font = '30px system-ui'; var targetWidth = ctx.measureText(BRAILLE_SPACE.repeat(N)).width; for (var i = 0; i < candidates.length; i++) { var char = candidates[i][0]; var str = char.repeat(N); var width = ctx.measureText(str).width; var similarWidth = Math.abs(targetWidth - width) / targetWidth <= 0.1; ctx.clearRect(0, 0, canvas.width, canvas.height); ctx.fillText(str, 0, 30); var pixelData = ctx.getImageData(0, 0, width, 30).data; var totalPixels = pixelData.length / 4; var coloredPixels = 0; for (var j = 0; j < totalPixels; j++) { var alpha = pixelData[j * 4 + 3]; if (alpha != 0) { coloredPixels++; } } var notTooDark = coloredPixels / totalPixels < 0.15; if (similarWidth && notTooDark) { return candidates[i]; } } // Fallback to a safe U+2591 Light Shade. return ['░', 'some kind of "fog"']; } var $ = document.querySelector.bind(document); main(); ================================================ FILE: style.css ================================================ /* Reset */ html, body, h1, p { margin: 0; padding: 0; } * { box-sizing: border-box; } html { height: 100%; font-family: Arial, sans-serif; color: #222; line-height: 1.5; } body { min-height: 100%; display: flex; flex-direction: column; align-items: flex-start; gap: 4px; padding: 10px; } a { color: #3473ee; } .controls { display: grid; grid-template-areas: ". up ." "left down right"; /* Limit the size of the controls by the viewport size. */ width: 75vmin; height: 50vmin; margin: 10px auto; } #up { grid-area: up; } #down { grid-area: down; } #left { grid-area: left; } #right { grid-area: right; } .controls button { color: #888; font-size: 8vmin; background: none; border: 2px solid; margin: 2px; border-radius: 5vmin; } .controls button:focus { outline: none; } @media (min-width: 10cm) and (min-height: 10cm) { /* Avoid controls getting too big on larger touch devices. */ .controls { position: absolute; width: 7.5cm; height: 5cm; bottom: 1.5cm; right: 1.5cm; } .controls button { font-size: 0.8cm; border-radius: 0.5cm; } } #url, #max-score-grid { background: #8883; padding: 2px; border-radius: 3px; } #share { display: inline-block; } footer { margin-top: auto; font-size: 0.9rem; } .invisible { display: none !important; } .hidden { visibility: hidden; } :root.touch .no-touch-only, :root:not(.touch) .touch-only { display: none; } .expand-btn, .collapse-btn { background: none; border: none; padding: 0; font: inherit; font-weight: bold; cursor: pointer; width: 1rem; } .expand-btn, .expandable { transition: transform, opacity; transition-duration: .4s; } .expandable { display: inline-block; position: relative; height: 1.5rem; transform: translateX(-100%); /* Clear body padding so it doesn't show on the left of the expand-btn */ padding-right: 10px; } .expand-btn { position: absolute; right: 0; top: 0; transform: translateX(100%); opacity: 1; } .help-toggle { color: #0bc3ff;; } .high-score-toggle { color: #ff8c0b; } .collapse-btn { color: #aaa; } .expandable.expanded { height: auto; transform: none; } .expandable.expanded .expand-btn { opacity: 0; } #share-note { padding-left: 0.5rem; } #share-note.invisible { /* Only animate fading out. */ transition: opacity 0.4s ease-in-out, display 0s ease-out 0.4s; transition-behavior: allow-discrete; opacity: 0; /* Hide with display:none to avoid messing up the position of the collapse button. */ display: none; } @media (prefers-color-scheme: dark) { html { background: #222; color: #eee; } } /* Icon font styles copied from Fontello. */ @font-face { font-family: 'icons'; src: url('icons.woff2?40046441') format('woff2'); font-weight: normal; font-style: normal; } [class^="icon-"]:before, [class*=" icon-"]:before { font-family: "icons"; font-style: normal; font-weight: normal; speak: none; display: inline-block; text-decoration: inherit; width: 1em; margin-right: .2em; text-align: center; /* For safety - reset parent styles, that can break glyph codes*/ font-variant: normal; text-transform: none; /* Animation center compensation - margins should be symmetric */ /* remove if not needed */ margin-left: .2em; /* Font smoothing. That was taken from TWBS */ -webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: grayscale; } .icon-share:before { content: '\e811'; }