技术解读 2天前 144 浏览次数 0 评论

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

CSDN
CSDN

发布了 182 文章

整理 | 苏宓

出品 | CSDN(ID:CSDNnews)

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

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

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  };  [removed] = 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) };  [removed] = function pauseGame {    gamePaused = true;    window.history.replaceState(null, null, location.hash + "[paused]");  };  [removed] = 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 行,但可玩性依然不错。

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

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

游戏原理

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

最后

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

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

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

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

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

CSDN

CSDN

182 文章 26442 浏览次数 0 粉丝

评论 (0)

睡觉动画