用单文件HTML实现可步行的ASCII赛博朋克城市:从原理到代码实战
2026/9/3 1:33:03 网站建设 项目流程

一个中午,你打开了一个.html文件,本想着只是看一张静态图片,结果却走进了霓虹闪烁的 ASCII 像素街巷。按W向前走,按A左转,按D右转,城市在字符之间移动、变化,像一个可步行探索的复古赛博朋克游戏。这种感觉很奇妙——所有画面都由@#+*这些字符组成,却传递出足够的空间感和城市氛围。

这个页面只依赖一个自包含的 HTML 文件:没有外链框架,没有后端服务,没有 WebGL,双击即可运行。它的存在本身回答了一个问题:在 2025 年,我们还需要认真对待 ASCII 渲染和单文件网页吗?

这篇文章会从工程实现角度,拆解这样一个“ASCII 赛博朋克城市”的技术构成:如何用纯前端渲染字符城市,如何设计地图与移动逻辑,如何做碰撞检测,如何让字符产生“城市感”。同时会给出一个最小可运行的教学版示例,你可以直接复制、运行、修改,并在此基础上扩展为自己的作品。

如果你对创意编程、Web 游戏开发、极简前端艺术感兴趣,这篇文章值得读完。读完你不仅能理解它的原理,还能自己动手做出一版。

1. 这个项目真正解决的问题

很多人第一次看到 ASCII 赛博朋克城市,会把它归类为“漂亮的玩具”。但如果只停留在“好看”,就容易错过它作为技术样本的价值:

第一个价值,它展示了一个单页面应用如何做到极致的可移植性。

不需要构建工具,不需要 HTTP 服务器,一个.html文件下载到本地就能跑。这在开发环境复杂化的今天,像是反其道而行之,但恰恰是这种简单,让它在分享、演示、归档场景中有独特优势。你可以把它发送给朋友,可以在 U 盘里带走,可以在没有网络的环境里运行。

第二个价值,它是对“渲染方案”的一种逆思考。

主流网页游戏几乎离不开 Canvas、WebGL、CSS 动画,但字符渲染是一条不同的路线。它不追求像素级的画面还原,而是通过信息密度和抽象符号激发想象。#代表一栋楼,*代表灯光,空格代表道路。它让人重新思考:一个前端应用,最核心的到底是什么?

第三个价值,它涉及了游戏开发中的核心问题。

地图建模、相机视角、移动控制、碰撞检测、渲染循环,这些在任意一款 2D 或 3D 游戏中都会遇到的问题,在这个项目里被压缩到了极小的代码量中实现。对于想入门游戏开发的读者来说,它是一个很好的最小研究样本。

所以,这篇文章不是要劝所有人都去写 ASCII 城市,而是希望帮助你理解:一个自包含 HTML 文件,通过简单的字符与逻辑,可以做到远超预期的交互体验。

2. ASCII 渲染的核心概念

2.1 ASCII 艺术的前世

ASCII 艺术起源于计算机早期,那时显示器无法呈现图片,人们就用英文字母、数字和标点符号拼出图形。它有两个特点:

  • 字符本身就是画笔。
  • 同样的字符在不同场景下,可以被解读为不同物体。

例如#可以代表墙面、楼体、密集的建筑群;空格代表空白区域或道路;*代表灯光、星光;@代表人、目标点;+代表交叉口、窗户。

在现代 Web 技术中,我们不再需要把字符画保存为文本文件,而是可以用 JavaScript 动态生成字符,并控制它在页面上的位置、颜色和状态。这就是“动态 ASCII 渲染”:以字符为像素,每帧更新整个画面。

2.2 单文件自包含架构

所谓“自包含 HTML 文件”,是指 HTML、CSS、JavaScript 都写在一个.html文件内:

<!DOCTYPE html> <html lang="zh-CN"> <head> <meta charset="UTF-8"> <title>ASCII Cyberpunk City</title> <style> /* CSS 样式 */ </style> </head> <body> <!-- 页面结构 --> <pre id="screen"></pre> <script> // JavaScript 逻辑 </script> </body> </html>

其中<pre>标签是关键,它保留空格和换行,非常适合显示等宽字符组成的画面。现代浏览器对<pre>的支持非常稳定,这也是很多 ASCII 艺术网页选择它的原因。

2.3 字符映射与城市符号

要实现一座“赛博朋克城市”,首先要建立一套字符映射规则。下面是常见的映射建议:

字符代表元素说明
#建筑墙体高密度、厚重感
+窗户/入口细节层次
*霓虹灯光赛博朋克氛围
@玩家/目标交互焦点
空格空地/道路通行区域
=公路标线道路延伸
~水/蒸汽动态元素
``管道/线缆

设计时要考虑字符的“重量感”:一个字符是否让人感觉明显、占空间、引人注意。在赛博朋克城市里,#.更重,所以建筑用#,远处的装饰可以用.

2.4 地图与视角

ASCII 城市的渲染通常有三种视角:

  1. 俯视 2D 地图:最简单,地图本身就是一张字符矩阵。
  2. 正等距视角:模拟 2.5D,上下左右按菱形排列。
  3. 第一人称射线投射:类似于早期《德军总部 3D》,通过射线检测生成画面。

本文的“可步行城市”部分采用第一种,因为它最容易理解、适合教学,也最能体现“步行探索”的体验。当你按方向键时,玩家的坐标发生变化,画面根据新坐标重新生成城市景观。如果采用射线投射,就需要计算玩家视线与墙体相交的距离,代码量会明显增加。

我的建议是:先从 2D 地图做起,跑通移动和碰撞,再升级到伪 3D 渲染。这和很多游戏引擎的迭代路线一致。

3. 最小可玩版本的设计思路

在动手写代码前,先设计一个最小可玩版本(Minimum Viable Version)。它的目标不是立刻做出一座宏伟城市,而是以最快速度跑通“渲染 -> 输入 -> 更新 -> 重绘”的循环。

3.1 功能范围

  • 一张 50x30 的地图,包含道路、建筑、中心广场和装饰物。
  • 玩家通过WASD控制角色移动。
  • 地图四周有边界墙,玩家不能走出界。
  • 每个建筑内部不可通行,角色会被挡住。
  • 画面使用<pre>渲染,每帧刷新一次。

3.2 数据结构

地图数据用二维数组存储。每个元素的含义:

  • 0表示空地,可通行。
  • 1表示墙体/建筑,不可通行。
  • 2表示道路/广场,可通行。
  • 3表示霓虹装饰,不可通行但视觉上有细节。

3.3 渲染流程

render()函数将二维数组转换为字符串,然后赋值给<pre>textContent。这一步必须使用textContent而不是innerHTML,避免浏览器把字符内容当 HTML 解析,也避免潜在性能问题。

输入事件通过keydown监听,更新玩家坐标后调用render()

因此,整个程序的核心循环其实是一个非常简单的有限状态机:

初始化地图 -> 渲染初始画面 ↓ 监听键盘 -> 更新玩家坐标 -> 重新渲染

4. 环境准备与开发工具

这个项目对开发环境的要求非常低,这也是它的一个特点。

  • 操作系统:Windows、macOS、Linux 均可。
  • 浏览器:Chrome、Edge、Firefox、Safari 均可。
  • 编辑器:VS Code、WebStorm、Sublime Text,甚至记事本都可以。
  • 构建工具:完全不需要。

如果希望方便调试,建议使用 VS Code,并安装 Live Server 插件。但即使不安装,直接双击 HTML 文件也能正常打开。

这里有一个知识点:直接双击打开.html文件时,浏览器地址栏以file://开头。由于本项目的所有资源都内联在同一个 HTML 中,不涉及跨域请求,所以file://协议下也能正常运行。这也是“单文件自包含”带来的好处之一。

5. 完整示例代码实现

下面给出一个最小但可运行的教学版 ASCII 城市。为了保持代码清晰,我把它拆分成两个文件说明,但你可以合并为一个 HTML 文件使用。

5.1 HTML 与 CSS 结构

<!DOCTYPE html> <html lang="zh-CN"> <head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> <title>ASCII Cyberpunk City - 教学版</title> <style> /* 实现字符画面 */ body { background: #0d0f1c; margin: 0; min-height: 100vh; display: flex; justify-content: center; align-items: center; font-family: 'Courier New', monospace; } #game { background: #111320; padding: 20px; border-radius: 8px; box-shadow: 0 0 30px rgba(0, 255, 255, 0.15); } #screen { white-space: pre; color: #0ff; font-size: 16px; line-height: 1.1; letter-spacing: 1px; } #tips { margin-top: 12px; color: #8899aa; font-size: 14px; font-family: 'Courier New', monospace; } </style> </head> <body> <div id="game"> <!-- 核心渲染区域 --> <pre id="screen"></pre> <div id="tips">WASD 移动 | R 重置位置</div> </div> <script src="city.js"></script> </body> </html>

关键点:#screen使用white-space: pre,确保多个空格和换行符被原样保留;使用等宽字体让字符排列对齐;letter-spacing可以调整字符间距,让画面更易读。

5.2 地图、渲染与移动逻辑

// city.js (function() { const screen = document.getElementById('screen'); // 地图数据:0 = 空地, 1 = 建筑, 2 = 道路, 3 = 霓虹装饰 const W = 42; const H = 28; // 地图字符映射 const TILE = { 0: ' ', 1: '#', 2: '.', 3: '*' }; // 颜色映射(这里仅做简单示例,只使用默认颜色) // 实际项目中可以根据 x+y 坐标、建筑高度设置不同颜色 // 生成本教学版城市地图 const map = []; for (let y = 0; y < H; y++) { const row = []; for (let x = 0; x < W; x++) { // 默认空地 row.push(0); } map.push(row); } // 四周加边界 for (let y = 0; y < H; y++) { map[y][0] = 1; map[y][W - 1] = 1; } for (let x = 0; x < W; x++) { map[0][x] = 1; map[H - 1][x] = 1; } // 添加建筑街区:使用矩形表示 function addBuilding(x, y, bw, bh) { for (let j = y; j < Math.min(y + bh, H - 1); j++) { for (let i = x; i < Math.min(x + bw, W - 1); i++) { map[j][i] = 1; } } } addBuilding(4, 4, 6, 5); addBuilding(12, 4, 5, 7); addBuilding(20, 5, 8, 6); addBuilding(30, 4, 5, 8); addBuilding(5, 14, 7, 6); addBuilding(14, 16, 5, 5); addBuilding(22, 15, 7, 5); addBuilding(32, 14, 4, 7); // 添加霓虹装饰 map[6][6] = 3; map[6][7] = 3; map[13][6] = 3; map[22][7] = 3; map[31][7] = 3; map[5][16] = 3; map[17][18] = 3; // 玩家初始位置 let playerX = Math.floor(W / 2); let playerY = Math.floor(H / 2); // 如果初始位置碰到建筑,尝试在附近找空地 while (map[playerY][playerX] === 1) { playerX++; } function render() { let result = ''; for (let y = 0; y < H; y++) { for (let x = 0; x < W; x++) { if (x === playerX && y === playerY) { result += '@'; } else { const tile = map[y][x]; result += TILE[tile]; } } result += '\n'; } screen.textContent = result; } function tryMove(dx, dy) { const newX = playerX + dx; const newY = playerY + dy; // 边界检查与碰撞检查 if (newX < 0 || newX >= W || newY < 0 || newY >= H) { return; } if (map[newY][newX] === 1) { return; // 建筑不可通行 } playerX = newX; playerY = newY; render(); } function resetPlayer() { playerX = Math.floor(W / 2); playerY = Math.floor(H / 2); while (map[playerY][playerX] === 1) { playerX++; if (playerX >= W) { playerY++; playerX = 1; } } render(); } document.addEventListener('keydown', function(e) { const key = e.key; const handled = true; switch (key.toLowerCase()) { case 'w': tryMove(0, -1); break; case 's': tryMove(0, 1); break; case 'a': tryMove(-1, 0); break; case 'd': tryMove(1, 0); break; case 'r': resetPlayer(); break; default: handled = false; } if (handled) { e.preventDefault(); } }); // 初始渲染 render(); })();

代码解析:

  • map[y][x]表示第y行第x列的格子,这是二维数组的标准顺序。
  • TILE对象建立从地图值到字符的映射。
  • render()遍历整个地图,遇到玩家坐标就画@,否则画对应字符,注意在每行末尾添加换行符。
  • tryMove(dx, dy)是碰撞检测的核心:如果目标是墙(1),移动被拒绝。
  • 键盘监听使用keydown,对WASD分别调用移动函数。

我在建筑中间留下了一个空地作为中心广场,玩家初始位置也在中心附近。建筑只是静态的矩形块,但在实际项目中可以通过增加高度、随机窗户、霓虹灯符号来丰富细节。

5.3 添加视角方向和画面配色(扩展)

如果只是“上下左右移动”,玩家感觉不到方向感。这里加一个简单的视角方向字段,让@根据玩家面向的方向显示不同符号:

// 在 city.js 中新增方向字段 let direction = 0; // 0=下, 1=左, 2=上, 3=右 const DIR_CHAR = ['v', '<', '^', '>']; // render 中替换 @ 的显示逻辑 if (x === playerX && y === playerY) { result += DIR_CHAR[direction]; }

tryMove中更新方向:

function tryMove(dx, dy) { // 更新朝向 if (dx === -1) { direction = 1; } else if (dx === 1) { direction = 3; } else if (dy === -1) { direction = 2; } else if (dy === 1) { direction = 0; } // ... 原碰撞检测逻辑 }

这样玩家移动时,@会变成^v<>,大大增强交互反馈。

6. 运行与效果验证

将两个文件放在同一目录下,双击打开index.html,或者用 VS Code 的 Live Server 启动。

预期效果:

  1. 页面黑色背景,中心显示由#*.@组成的字符城市。
  2. 玩家角色@在中央空地。
  3. 按下W,角色向上移动一格;S向下;A向左;D向右。
  4. 当角色碰到#建筑时,移动被挡住,画面保持不变。
  5. 按下R,角色重置到中心位置。

如果运行后没有出现字符画面,请按以下顺序排查:

问题现象可能原因排查方式解决方案
页面空白city.js加载失败打开浏览器开发者工具(F12),查看 Console 报错检查city.js是否与 HTML 同目录,确认文件名正确
字符没有换行CSS 中缺少white-space: pre检查#screen的样式添加上white-space: pre;
按下按键没有反应焦点不在页面上点击页面再按键盘确认页面获得焦点
方向键移动正常,WASD 无效大小写或输入法问题检查 keydown 监听代码使用key.toLowerCase()统一处理
画面显示错位字体不是等宽字体查看font-family设置使用'Courier New', monospace

我建议你先跑通这个最小版本,再做修改。修改时一次只改一个变量,观察画面变化,这样能更快建立对代码的理解。

7. 进阶:实现“墙体阴影”与动态霓虹灯

基础版本跑通后,城市看起来会比较“平”。两个简单但效果明显的增强是:墙体阴影和动态霓虹灯。

7.1 墙体阴影

在一张 2D 字符地图中,可以在建筑右侧或下方添加一层更暗的字符(例如:;),形成阴影效果。实现方式是:在渲染时检查当前位置左侧或上方是否是建筑,如果是,则把当前空地的显示字符替换为暗色装饰。

const TILE_SHADOW = ':'; function render() { for (let y = 0; y < H; y++) { for (let x = 0; x < W; x++) { if (x === playerX && y === playerY) { result += DIR_CHAR[direction]; } else { const tile = map[y][x]; if (tile === 0 && x > 0 && map[y][x - 1] === 1) { // 右侧靠近墙体的空格子,显示阴影 result += TILE_SHADOW; } else { result += TILE[tile]; } } } result += '\n'; } }

这种细节会让城市立刻有立体感。#的密度和:的分散形成对比,视觉上不再是单纯的光秃秃矩形。

7.2 动态霓虹灯

ASCII 城市之所以有“赛博朋克感”,动态灯光很关键。可以用setInterval周期性改变某些装饰字符的显示:

let neonPhase = 0; setInterval(function() { neonPhase = (neonPhase + 1) % 4; render(); }, 300); // 每 300ms 切换一次 function render() { // 在渲染循环中,如果格子是 3(霓虹装饰),根据 phase 显示不同字符 if (tile === 3) { if (neonPhase < 2) { result += '*'; } else { result += '+'; } } }

这种闪烁很微弱,但会给整座城市带来呼吸感。实际项目中还可以用 CSS 动画改变字符颜色,比如在霓虹字符上加速color的过渡,让不同区块交替显示青、洋红、黄色。

7.3 地图编辑工具

手动在二维数组里写地图很慢,也容易出错。很多 ASCII 城市项目会提供一个简单的“地图编辑器”:在浏览器中,使用鼠标点击空地放置建筑,右键删除。如果你希望地图更丰富,建议先画在纸上,再转成代码。这样可以避免边写边想导致的数据错误。

8. 常见问题与排查方法

这里把我在实现过程中遇到过的问题整理成表格,方便你对照排查。

问题现象可能原因排查方式解决方案
角色进入建筑内部碰撞检测没生效检查tryMove中是否正确判断map[newY][newX]确认不可通行值用1,并避开装饰物用3的通行逻辑
游戏运行卡顿渲染循环太频繁或地图过大使用浏览器性能面板查看降低setInterval频率;或只在玩家移动时重绘,而不需要每帧刷新
文字太小看不清默认字体尺寸偏小调大#screenfont-size从 16px 开始试,配合line-height调整
键盘输入变成不相关字符输入法拦截按键使用event.code代替event.keyevent.codeKeyW不受输入法影响
file://下字体效果不一致系统等宽字体差异monospace作为最后兜底设置font-family: 'Courier New', monospace
角色在边缘被卡住边界碰撞逻辑过严检查newX >= W是否越界边界同样充当墙体,不允许越界
刷新后进度丢失没有保存机制单页项目可不实现持久化,若要保存坐标可用localStorage

8.1 关于event.keyevent.code的细节

在中文输入法环境下,event.key可能返回'Process'或中文字符,导致 WASD 失灵。这个时候改用e.code更稳定:

document.addEventListener('keydown', function(e) { switch (e.code) { case 'KeyW': tryMove(0, -1); break; case 'KeyS': tryMove(0, 1); break; case 'KeyA': tryMove(-1, 0); break; case 'KeyD': tryMove(1, 0); break; case 'KeyR': resetPlayer(); break; } e.preventDefault(); });

e.code表示物理按键位置,KeyW是键盘上 W 键的位置,不管输入法怎么切换,它都是稳定的。这是实际开发中很容易踩坑的地方。

9. 工程化与性能优化建议

当代码量超过 300 行后,建议引入以下工程习惯。

9.1 将地图数据与逻辑分离

不要在逻辑代码里混入大量地图坐标。把地图生成单独抽成函数,或者使用简单的地图文本串:

const mapText = ` ###################### #..............#.....# #..##..##......#..*..# #..##..##......#.....# #..............#.....# #........##.....#....# #..*......##....#....# ###################### `; function parseMap(text) { const lines = text.trim().split('\n'); return lines.map(line => line.split('').map(ch => { if (ch === '#') return 1; if (ch === '*') return 3; return 0; })); }

使用文本画地图,直观、容易修改,也方便从版本管理工具中查看差异。

9.2 渲染性能

如果地图扩大到 200x100,每帧生成 20000 个字符仍然可能造成卡顿。优化方向:

  1. 只在移动时渲染一次,而不是定时循环渲染。
  2. 离屏渲染到 Canvas,再把结果一次写入<pre>
  3. screen.textContent赋值前,先判断字符串是否发生变化,避免无意义 DOM 更新。

9.3 模块化与面向对象

建议使用简单的Game类:

class CyberCity { constructor(map, width, height) { this.map = map; this.playerX = Math.floor(width / 2); this.playerY = Math.floor(height / 2); } render() { /* ... */ } tryMove(dx, dy) { /* ... */ } }

好处是逻辑清晰,后续可以加 NPC、目标点、对话、任务等。虽然这是一个单文件页面,但代码组织仍然可以遵循基本的工程规范。

9.4 安全边界

单文件页面不涉及网络请求,通常没有安全风险。但如果你把它发布到网上,并让用户输入或加载外部地图,就需要考虑:

  • 不要直接使用innerHTML插入玩家输入的字符,防止 XSS。
  • 地图数据如果从 URL 参数读取,要做长度和字符集校验。
  • 不使用任何外部 CDN 资源,保持单文件完整性,也是安全层面的优势。

10. 总结与后续学习方向

一个“A walkable ASCII cyberpunk city in one self-contained HTML file”看起来像是复古技术的浪漫幻想,但它实现的核心能力恰好覆盖了前端互动应用的基础链路:地图数据建模、输入响应、碰撞检测、渲染循环、视觉氛围设计。这个链路本身,是很多大型游戏引擎和复杂前端应用的缩影。

如果你真的想把它继续做下去,我建议从几个方向扩展:

10.1 拓展为伪 3D 射线投射

2D 地图虽然可以“步行探索”,但画面不够沉浸。下一步可以学习射线投射算法:从玩家位置出发,向屏幕每一列发射一条射线,计算射线撞到地图的最短距离,然后根据距离把墙面映射成不同高度的字符列。这样就用极少的代码实现了类似《Wolfenstein 3D》的体验。视角移动、墙壁高低感和距离感都会发生质变。

10.2 引入昼夜循环

赛博朋克城市的核心是“灯光氛围”。可以引入时间变量:白天时字符颜色偏亮,晚上偏暗,霓虹灯显示更明显。用 CSSfilter: brightness()配合字符颜色变化,就能实现昼夜过渡。

10.3 加入简单任务与叙事

城市不只是用来走的,还可以往里加入任务:找到某个建筑、收集若干霓虹灯、到达出口。这会驱动玩家更有目的地探索,也能让你练习状态机和管理复杂对象。

10.4 尝试其他字符渲染风格

ASCII 只是字符艺术的一种。你还可以尝试 Unicode 方块字符(如),它们能让画面更精细,灰度层次更丰富。也可以混合使用中文全角符号制造特殊视觉。避免只停留在“能用”,试着建立自己的字符调配方案。

当你写完了自己的版本,建议把 HTML 文件保留下来。它既是一段代码,也是一个作品。在 GitHub 上搜索同类项目时,你会发现很多开发者选择用这种单文件方式展示创意原型,因为它不需要任何依赖,任何人都能立刻体验。

这是一个适合“周末写一整天”的项目。从零开始,维护一个自包含文件,逐步添加功能,最后你会得到一件有复古质感、又完全现代的交互作品。这大概就是前端最让人上瘾的地方:一行字符,一个世界。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询