网站首页 > 精选文章 正文
整理 | 苏宓
出品 | 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 = {// Arrows37: LEFT, 38: UP, 39: RIGHT, 40: DOWN,// WASD87: UP, 65: LEFT, 83: DOWN, 68: RIGHT,// hjkl75: 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; xvar 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 tovar 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; iif (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; ivar 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; jvar 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 不只是一个小游戏,更像是一场创意实验。它证明了即使在最“不适合”的环境里,也依然可以找到代码表达的可能。它没有酷炫的图形,也没有复杂的关卡,却让人看到了编程的另一种浪漫:在规则之外寻找惊喜。
- 上一篇: 系统小技巧:桌面一键快速访问常用网站
 - 下一篇: 浏览器开不了网页?全套解决方案留在这儿
 
猜你喜欢
- 2025-10-19 用什么远程操作员工电脑上的文件?分享8款实用软件,值得收藏!
 - 2025-10-19 WIN10 WIN11启用IE浏览器,禁止IE浏览器跳转到edge
 - 2025-10-19 三招解决Windows 10浏览器无反应_win10浏览器没反应
 - 2025-10-19 巧妙设置让Edge浏览器更好用_巧妙设置让edge浏览器更好用
 - 2025-10-19 感受谷歌Edge浏览器的新功能_edge 谷歌
 - 2025-10-19 快捷指令怎么用?玩转iOS14快捷指令全攻略
 - 2025-10-19 python有两种方式让浏览器自动跑起来,快来试试吧!
 - 2025-10-19 初探微软Win11预览版任务栏测速功能:非原生,靠Bing网页实现
 - 2025-10-19 提升效率!掌握SecureCRT必备使用技巧,让网工运维事半功倍
 - 2025-10-19 微软Edge新增实用“网页捕获”功能
 
- 最近发表
 
- 标签列表
 - 
- 向日葵无法连接服务器 (32)
 - git.exe (33)
 - vscode更新 (34)
 - dev c (33)
 - git ignore命令 (32)
 - gitlab提交代码步骤 (37)
 - java update (36)
 - vue debug (34)
 - vue blur (32)
 - vscode导入vue项目 (33)
 - vue chart (32)
 - vue cms (32)
 - 大雅数据库 (34)
 - 技术迭代 (37)
 - 同一局域网 (33)
 - github拒绝连接 (33)
 - vscode php插件 (32)
 - vue注释快捷键 (32)
 - linux ssr (33)
 - 微端服务器 (35)
 - 导航猫 (32)
 - 获取当前时间年月日 (33)
 - stp软件 (33)
 - http下载文件 (33)
 - linux bt下载 (33)
 
 
