地址栏也能打游戏!程序员用不到400行代码,在浏览器地址栏复活《贪吃蛇》,网友惊叹:怎么想出这个点子的?

整理 | 苏宓

出品 | CSDN(ID:CSDNnews)

打开浏览器的时候,你有没有想过,地址栏也能玩游戏?大多数人肯定没这么想过——毕竟它平时的功能也就那么简单:输入网址、回车、加载网页。但一些程序员总能做些让人意想不到的事。

最近,一位开发者就把经典的《贪吃蛇》搬进了地址栏里。没错,就是小时候大家都玩过的像素版贪吃蛇,现在竟然能在地址栏里动起来。

地址栏也能打游戏!程序员用不到400行代码,在浏览器地址栏复活《贪吃蛇》,网友惊叹:怎么想出这个点子的?地址栏也能打游戏!程序员用不到400行代码,在浏览器地址栏复活《贪吃蛇》,网友惊叹:怎么想出这个点子的?

400 行不到的 JavaScript 代码,把「贪吃蛇」塞到地址栏中

这个项目名叫 URL Snake,出自开发者 Demian Ferreiro 之手。

简单来看,他用了不到 400 行 JavaScript 代码,就在一个原本只能显示文字的地方“造出”了这款游戏。

话不多说,「Talk is Cheap,Show me the code」,完整代码如下:

'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     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 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     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;  $('-score-points').textContent = maxScorePoints;  $('-score-grid').textContent = maxScoreGrid;  $('-score-container').classList.remove('hidden');  $('').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     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       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;

听起来有点疯狂,但真的能玩,而且画面也不是乱闪的乱码。

在 Chrome 浏览器上打开,界面如下所示:你能清晰地看到一条由密密麻麻的盲文符号组成的“蛇”在地址栏里爬动,即「长的点」代表贪吃蛇,「单个点」是食物,吃掉小点点代表的食物,身体一点点变长。

整个画面虽然简陋,但加上浏览器实时更新 URL 的那种“闪动”,它像极了 DOS 年代的小游戏,简洁、直接、充满旧时代的技术感,也引发了一波回忆潮。

从操作上看,游戏支持「↑↓←→」方向键或 WASD 控制蛇移动。随着吃掉的“食物”增多,速度也会慢慢提升,难度上升。你需要反应足够快才能避免撞墙或自咬。虽然画面高度只有 4 行,但可玩性依然不错。

地址栏也能打游戏!程序员用不到400行代码,在浏览器地址栏复活《贪吃蛇》,网友惊叹:怎么想出这个点子的?

为了感兴趣的小伙伴能上手体验,Demian Ferreiro 将项目代码在 GitHub 上开源了:https://github.com/epidemian/snake

试玩地址:http://demian.ferrei.ro/snake

地址栏也能打游戏!程序员用不到400行代码,在浏览器地址栏复活《贪吃蛇》,网友惊叹:怎么想出这个点子的?

游戏原理

其实从技术上讲,要在浏览器那条显得有些狭窄的地址栏里面塞进一个小游戏,说简单不简单,说难也确实挺有门道。毕竟那地方既不能嵌入 Canvas 或 SVG,也没有图形 API 可以用,几乎不可能画出像样的画面。

好在 Ferreiro 向来不是一个墨守成规的极客,正如上图所示,他想出了一个让人意想不到的办法——用 Unicode 字符“画”出游戏画面。可以说,这波操作把“极简主义”玩到了极致。

至于为什么 Ferreiro 会想到这个离谱的项目,他自己也记不太清了。他在 Hacker News 上提到,灵感可能来源于 Unicode 的盲文字符(Braille)系统。他发现一个有趣的规律:

每个盲文字符都是 2×4 的点阵,每个点只有两种状态——亮或不亮。8 个点组合起来,正好对应一个字节,总共 256 种组合,而且 Unicode 把这些组合全都映射成编码点。

Ferreiro 兴奋地说:“这不就是展示字节级动画潜力的完美载体吗?”

地址栏也能打游戏!程序员用不到400行代码,在浏览器地址栏复活《贪吃蛇》,网友惊叹:怎么想出这个点子的?

于是,他把这个思路用在了 URL 栏里:用一串盲文字符拼出一块虚拟的“游戏屏幕”,每一帧都重新生成字符,更新蛇的形状和位置。

这个版本的《贪吃蛇》在一个 40×4 的“像素格”上运行,用了 requestAnimationFrame 来驱动动画,让一串串盲文字符在地址栏中滑动起来。虽然只有四行高,但蛇一旦上下移动,玩家就得迅速反应,否则分分钟撞墙。

玩这个游戏时,其实就是浏览器不断修改地址栏内容,用不同的 Unicode 符号“刷出”画面。它有点像早年程序员在命令行窗口里做 ASCII 动画,只不过这次空间更狭小,也更有创意——一条蛇,硬是在一行网址里“活”了过来。

地址栏也能打游戏!程序员用不到400行代码,在浏览器地址栏复活《贪吃蛇》,网友惊叹:怎么想出这个点子的?

副作用——打开浏览器的“历史记录”,网友:“天塌了”

玩着玩着,很多人会注意到一个奇怪的副作用:浏览器里的历史记录会被这个网址疯狂「刷屏」。

地址栏也能打游戏!程序员用不到400行代码,在浏览器地址栏复活《贪吃蛇》,网友惊叹:怎么想出这个点子的?

也不用太担心,正如上文所述,因为每一次蛇的移动都意味着地址栏内容发生了变化,浏览器就会记录一次新“访问”。短短一局游戏下来,你的历史记录可能已经塞满几百个“URL Snake”的痕迹。

Chrome 用户可以靠批量删除功能一次清掉,但如果你用的是其他浏览器,那就只能慢慢手动清理。

此外,游戏的画面空间非常有限。只有四行“像素”的高度,让上下移动变得特别危险。稍微操作迟一点就容易撞上自己。再加上地址栏本身不是为显示图形而生,盲文字符的显示效果也会受不同系统和字体影响,在某些浏览器里可能略显错位。换句话说,这并不是一款“完美”的游戏,而更像是一场炫技实验。

地址栏也能打游戏!程序员用不到400行代码,在浏览器地址栏复活《贪吃蛇》,网友惊叹:怎么想出这个点子的?

“这个项目本身带着点玩笑性质,但也不妨可以继续探索”

很多人好奇 Ferreiro 为什么要这么折腾?做一个普通网页游戏不是更简单吗?

其实,这种项目的意义不在“实用”,而在“创意”和“挑战”。对开发者来说,URL Snake 就像一场极限运动。它验证了一个问题——“我们能不能在完全不合适的环境里做出游戏?” 这种逆向思维带有一点黑客精神,也让人想起早期互联网的自由氛围:没人告诉你什么能做、什么不能做。

Ferreiro 在发布时也说过,这个项目本身带着点玩笑性质,但他觉得有趣的地方就在于:地址栏是网页中最被忽视的部分,它几乎没有被用作创意表达的空间。而他想让大家重新注意到这一点。

他也表示愿意继续改进,欢迎大家在 GitHub 上提交 bug、提意见、甚至直接拉个 PR 一起完善。

地址栏也能打游戏!程序员用不到400行代码,在浏览器地址栏复活《贪吃蛇》,网友惊叹:怎么想出这个点子的?

最后

看到这样一款游戏的诞生,HN 上网友也纷纷表达了自己的看法:

  • CobrastanJorji:太棒了。我喜欢人们用非常富有创意的方式让事物以奇怪的方式变得互动。百分百的黑客精神。干得好。

  • system2:对于普通人来说,这可能看起来没什么,但对我来说这太疯狂了。你们这些人到底是怎么想出这些点子的……

地址栏也能打游戏!程序员用不到400行代码,在浏览器地址栏复活《贪吃蛇》,网友惊叹:怎么想出这个点子的?

甚至有人期待,什么时候能在地址栏里面玩 DOOM 游戏?

地址栏也能打游戏!程序员用不到400行代码,在浏览器地址栏复活《贪吃蛇》,网友惊叹:怎么想出这个点子的?

其实说到底,URL Snake 不只是一个小游戏,更像是一场创意实验。它证明了即使在最“不适合”的环境里,也依然可以找到代码表达的可能。它没有酷炫的图形,也没有复杂的关卡,却让人看到了编程的另一种浪漫:在规则之外寻找惊喜。

地址栏也能打游戏!程序员用不到400行代码,在浏览器地址栏复活《贪吃蛇》,网友惊叹:怎么想出这个点子的?

友情提示

本站部分转载文章,皆来自互联网,仅供参考及分享,并不用于任何商业用途;版权归原作者所有,如涉及作品内容、版权和其他问题,请与本网联系,我们将在第一时间删除内容!

联系邮箱:1042463605@qq.com