整理 | 苏宓
出品 | 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
};
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 行,但可玩性依然不错。
为了感兴趣的小伙伴能上手体验,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 不只是一个小游戏,更像是一场创意实验。它证明了即使在最“不适合”的环境里,也依然可以找到代码表达的可能。它没有酷炫的图形,也没有复杂的关卡,却让人看到了编程的另一种浪漫:在规则之外寻找惊喜。

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