
一个中午你打开了一个.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 langzh-CN head meta charsetUTF-8 titleASCII Cyberpunk City/title style /* CSS 样式 */ /style /head body !-- 页面结构 -- pre idscreen/pre script // JavaScript 逻辑 /script /body /html其中pre标签是关键它保留空格和换行非常适合显示等宽字符组成的画面。现代浏览器对pre的支持非常稳定这也是很多 ASCII 艺术网页选择它的原因。2.3 字符映射与城市符号要实现一座“赛博朋克城市”首先要建立一套字符映射规则。下面是常见的映射建议字符代表元素说明#建筑墙体高密度、厚重感窗户/入口细节层次*霓虹灯光赛博朋克氛围玩家/目标交互焦点空格空地/道路通行区域公路标线道路延伸~水/蒸汽动态元素管道/线缆设计时要考虑字符的“重量感”一个字符是否让人感觉明显、占空间、引人注意。在赛博朋克城市里#比.更重所以建筑用#远处的装饰可以用.。2.4 地图与视角ASCII 城市的渲染通常有三种视角俯视 2D 地图最简单地图本身就是一张字符矩阵。正等距视角模拟 2.5D上下左右按菱形排列。第一人称射线投射类似于早期《德军总部 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 langzh-CN head meta charsetUTF-8 meta nameviewport contentwidthdevice-width, initial-scale1.0 titleASCII 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 idgame !-- 核心渲染区域 -- pre idscreen/pre div idtipsWASD 移动 | R 重置位置/div /div script srccity.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: * }; // 颜色映射这里仅做简单示例只使用默认颜色 // 实际项目中可以根据 xy 坐标、建筑高度设置不同颜色 // 生成本教学版城市地图 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 启动。预期效果页面黑色背景中心显示由#、*、.、组成的字符城市。玩家角色在中央空地。按下W角色向上移动一格S向下A向左D向右。当角色碰到#建筑时移动被挡住画面保持不变。按下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频率或只在玩家移动时重绘而不需要每帧刷新文字太小看不清默认字体尺寸偏小调大#screen的font-size从 16px 开始试配合line-height调整键盘输入变成不相关字符输入法拦截按键使用event.code代替event.keyevent.code如KeyW不受输入法影响file://下字体效果不一致系统等宽字体差异用monospace作为最后兜底设置font-family: Courier New, monospace角色在边缘被卡住边界碰撞逻辑过严检查newX W是否越界边界同样充当墙体不允许越界刷新后进度丢失没有保存机制无单页项目可不实现持久化若要保存坐标可用localStorage8.1 关于event.key与event.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 个字符仍然可能造成卡顿。优化方向只在移动时渲染一次而不是定时循环渲染。离屏渲染到 Canvas再把结果一次写入pre。对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 上搜索同类项目时你会发现很多开发者选择用这种单文件方式展示创意原型因为它不需要任何依赖任何人都能立刻体验。这是一个适合“周末写一整天”的项目。从零开始维护一个自包含文件逐步添加功能最后你会得到一件有复古质感、又完全现代的交互作品。这大概就是前端最让人上瘾的地方一行字符一个世界。