地址栏也能打游戏!程序员用不到400行代码,在浏览器地址栏复活《贪吃蛇》,网友惊叹:怎么想出这个点子的?
myzbx 2025-10-14 01:59 6 浏览
整理 | 苏宓
出品 | 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 不只是一个小游戏,更像是一场创意实验。它证明了即使在最“不适合”的环境里,也依然可以找到代码表达的可能。它没有酷炫的图形,也没有复杂的关卡,却让人看到了编程的另一种浪漫:在规则之外寻找惊喜。
相关推荐
- Xbox Series X具有比PS5更高的有效I/O吞吐量
-
来源:cnBeta在今年3月宣布XboxSeriesX时,微软就已经预告了全新的XboxVelocity架构,宣称可为次世代主机带来前所未有的功能体验。据悉,XboxVelocity体系结构有...
- 科个普:固态硬盘之友!DirectStorage显存直通车
-
谁能想到有一天,固态硬盘之友竟然是一个API——为了解决游戏Loading烦人的等待时间,微软利用NVMeSSD的超高读写速度特性,有针对性的开发了DirectStorageAPI,它可以让游戏直...
- 虚拟机备份应注意四大问题_虚拟机备份命令
-
2015-01-1405:48:00作者:赵为民虚拟化技术在近两年发展的非常快,很多企业都采用虚拟机技术来解决企业IT基础设施所面临的一些问题,如硬件过度浪费,扩展难等问题,但对于企业来说,保证企...
- PS4支持进入倒计时:2026年春季新发售的PS4游戏将停用部分功能
-
PlayStation似乎正在逐步开始淘汰对上世代主机PS4的支持。据InsiderGaming独家报道,PS4的一些传统服务将在2026年春季停止提供。InsiderGaming收到的文件显示...
- 2026年春季起索尼PS4平台新发行游戏将停用部分旧版PSN功能
-
IT之家10月2日消息,据游戏媒体InsiderGaming今天报道,部分文件显示,索尼互娱似乎已经准备开始逐步淘汰PS4游戏机。InsiderGaming收到的文件显示,索尼...
- 吞吐量18.09GB/s,硬盘启用DirectStorage 1.1的GPU解压功能实测
-
IT之家12月21日消息,AMD在今年5月初曾表示,即便用户装备了NVMe的存储设备,也可能无法满足SmartAccessStorage(该技术建立在微软DirectStora...
- 面试官:如何让localStorage支持过期时间设置?
-
聊到localStorage想必熟悉前端的朋友都不会陌生,我们可以使用它提供的getItem,setItem,removeItem,clear这几个API轻松的对存储在浏览器本地的...
- 2025年是时候对localstorage说再见了
-
localStorage隐藏风险在前端开发领域,localStorage自诞生之日起就一直是数据持久化的首选方案。凭借其看似简单的setItem/getItemAPI,它成为了存储用户偏好和应用状...
- 前端最能打的本地存储方案_前端数据存储
-
前言之前开发了一个离线存储的需求,需要在本地存储较大的数据量,并且还要考虑到多种场景下的存储方式兼容。产品的原话就是“要又大又全”。既然存储量大,也要覆盖全多种设备多种浏览器。方案选择既然要存储的数量...
- 抛弃 localStorage,这个存储方案更安全更高效
-
在前端开发的世界里,浏览器存储一直是我们处理客户端数据持久化的重要工具。多年来,localStorage凭借其简单易用的API和跨会话持久化能力,成为了许多开发者的默认选择。然而,随着Web...
- 软件性能测试中链接追踪工具Zipkin工具的使用
-
大家好,今天一起来学习一下在软件性能测试过程中如何使用Zipkin这个工具来追踪链接程序逻辑链路上的相关问题首先我们了解一下Zipkin是什么?Zipkin是Twitter的一个开源项目,基于G...
- Vue3管理系统实现动态路由和动态侧边菜单栏
-
在做Vue管理系统的时候,都会遇到的一个需求:每个用户的权限是不一样的,那么他可以访问的页面(路由),可以操作的菜单选项是不一样的,如果由后端控制,我们前端需要去实现动态路由,动态渲染侧边菜单栏。实现...
- JS删除上一条浏览器历史记录的方法(登录回退)
-
JS使用window.location.replace删除上一条浏览器历史记录的方法(登录回退)一、问题如果用户登录状态过期,或者没有登录,当用户登录之后回退上一个页面的时候,就会回退到登录页面,这样...
- LightRAG: 简单快速的检索增强生成工具
-
这里是Aideas,每日分享AI相关资讯。本文由AideasAgent整理并推荐。项目地址:/HKUDS/LightRAG,程序语言:Python,收藏:14,287,分支:1,996,...
- 实战指南:React 路由与Ant Design集成
-
路由管理:如何在React项目中集成react-router-dom使用前的准备:安装react-router-dom为了在React项目中使用路由功能,首先需要安装react-router-dom...
- 一周热门
- 最近发表
- 标签列表
-
- HTML 简介 (30)
- HTML 响应式设计 (31)
- HTML URL 编码 (32)
- HTML Web 服务器 (31)
- HTML 表单属性 (32)
- HTML 音频 (31)
- HTML5 支持 (33)
- HTML API (36)
- HTML 总结 (32)
- HTML 全局属性 (32)
- HTML 事件 (31)
- HTML 画布 (32)
- HTTP 方法 (30)
- 键盘快捷键 (30)
- CSS 语法 (35)
- CSS 轮廓宽度 (31)
- CSS 谷歌字体 (33)
- CSS 链接 (31)
- CSS 定位 (31)
- CSS 图片库 (32)
- CSS 图像精灵 (31)
- SVG 文本 (32)
- 时钟启动 (33)
- HTML 游戏 (34)
- JS Loop For (32)