单文件HTML实现ASCII赛博朋克城市:字符渲染与程序化生成解析
这次我们来看一个非常有意思的项目整个赛博朋克城市被塞进了一个自包含的 HTML 文件里打开浏览器就能在字符组成的街区中自由行走。没有外部模型、没有 Node 依赖、没有引擎文件本身就是一个完整的可玩 demo。这类项目把“字符渲染”“程序化生成”“步行交互”和赛博朋克视觉压缩在一个文件里对前端开发者、像素游戏爱好者和喜欢复古计算机风格的人来说都是很好的拆解对象。这个项目最值得关注的点有三个。第一它用 ASCII 字符替代了传统的贴图和模型所有建筑、街道、灯光都由字符和颜色组合出来第二城市不是手工摆放的固化场景而是一套程序化生成规则在运行时构建出来的第三整个渲染循环、输入处理和动画更新都集中在单文件 JavaScript 里不需要任何构建工具双击打开就能跑。这篇文章会从渲染引擎、城市生成、步行交互、氛围动画和性能优化几个方向拆解并给出一套可以直接跑起来验证的核心原型。先说清楚一个前提本文给出的代码是概念级实现用来解释项目中最重要的技术点。如果你要复刻原项目的完整效果建议把原作者的 Demo 页面、README 和源码下载下来对照运行字符素材、配色方案和地图尺寸等细节以原仓库为准。1. 核心能力速览能力项说明项目类型单文件 HTML Canvas 演示 / 可交互原型渲染方式Canvas 2D 等宽字体字符画交互方式键盘控制角色移动视口跟随玩家滚动城市生成运行时程序化生成街道、建筑、霓虹灯、天气视觉风格赛博朋克配色深色底、霓虹紫、青蓝、粉红高光外部依赖无单个 .html 文件即可运行启动方式浏览器直接打开本地 HTML 文件是否支持 API不涉及这是纯前端演示项目是否支持批量任务不涉及但程序化生成函数可以批量构建不同地图适合读者前端开发者、游戏原型爱好者、ASCII 艺术爱好者从核心规格可以看出这个项目真正的门槛不在于硬件而在于理解“字符作为像素”的渲染思路。只要掌握了 Canvas 的 fillText 逐字符绘制和地图坐标系映射再复杂的城市效果都可以往上叠加。2. 这类项目解决什么问题适合谁用单个 HTML 文件构建一个小型可玩场景在很多场景下是有实际价值的。首先它很适合做技术演示把一段城市渲染逻辑放进一个文件里发给同事或者直接放到服务器上对方不需要安装任何依赖就能立刻运行这种交付方式在快速原型验证阶段很高效。其次它是游戏原型的低成本测试方式在投入正式美术资源之前先用字符级别的可视化验证游戏玩法例如碰撞检测是否合理、地图生成是否有趣、移动手感是否流畅这种验证成本比用完整引擎低很多。最后这种写法本身就是一种极简技术练习单文件约束会逼迫你精简代码结构把逻辑拆分、数据组织和渲染性能都想清楚。但也要说清楚它的边界。这类项目更适合作为技术演示或小体量互动作品不适合直接做成完整的商业游戏。字符渲染的画面表达能力有限复杂场景切换、UI 系统、音频管理如果都塞进单文件维护成本会快速上升。如果你需要一个完整的像素风游戏选择成熟的游戏引擎会是更稳妥的方向。涉及版权和安全边界时也要注意字符素材虽然是代码生成的但如果使用了特定字体文件例如厂商提供的 VGA 像素字体需要确认字体授权是否允许商用和修改。另外如果参考了别人项目中的配色、地图生成算法或角色设计发表时应该标注出处并遵守开源许可证。3. 渲染引擎用字符当像素这部分是整个项目的核心。所谓 ASCII 城市本质上是把传统游戏里“像素纹理”换成“字符 颜色”然后通过 Canvas 的 fillText 方法逐字符绘制到画布上。3.1 字符就是像素在传统网格渲染中一个地图格子可能是一张 16×16 的贴图。在这个项目里一个格子就是一个字符例如#表示墙面.表示地面O表示窗户*表示霓虹灯。字符的形状天然提供了更多的“纹理感”密集排列以后远处看起来就像一座城市。要构建这种渲染第一步是定义地图的二维数组。每个格子不一定只是一个字符还可以带着前景色和背景色。数据结构可以设计成下面这样const MAP_W 160; const MAP_H 100; const map []; function initMap() { for (let y 0; y MAP_H; y) { map[y] []; for (let x 0; x MAP_W; x) { map[y][x] { ch: ., color: #3a3f4a, bg: #0b0e14 }; } } }这里我用一个对象来描述每个格子ch是字符color是前景色bg是背景色。虽然对象比单纯的字符串数组占内存更多但对于一百乘一百的地图来说这个规模完全可控而且后续处理颜色会方便很多。3.2 单色与彩色渲染最普通的渲染方式是把所有字符用一种颜色绘制成文本行。这种方式性能很好因为可以把一整行字符拼接起来一次 fillText 画完。但赛博朋克城市如果没有颜色味道会差很多。为了得到霓虹灯的效果需要对不同物体使用不同颜色。最简单的实现是“按颜色分组渲染”先把屏幕上所有格子按照前景色和背景色分组每组拼接成一个文本块再统一绘制。这样可以避免每个字符单独调用一次 fillText 带来的性能开销。下面是一个按颜色分组渲染的示例逻辑function render() { ctx.fillStyle #0b0e14; ctx.fillRect(0, 0, canvas.width, canvas.height); const buckets {}; for (let dy 0; dy viewH; dy) { for (let dx 0; dx viewW; dx) { const tile getViewTile(dx, dy); const key tile.color | tile.bg; if (!buckets[key]) { buckets[key] []; } buckets[key].push({ x: dx * CELL_W, y: dy * CELL_H, ch: tile.ch }); } } for (const key in buckets) { const [color, bg] key.split(|); ctx.fillStyle bg; ctx.fillStyle color; ctx.font ${CELL_H}px monospace; ctx.textBaseline top; for (const item of buckets[key]) { ctx.fillText(item.ch, item.x, item.y); } } }这个实现里仍然是对每个字符调用 fillText。在 60×30 的视口下大约有 1800 个字符现代浏览器每帧处理这个数量是完全没问题的。如果你需要更大的视口例如 120×60可以做进一步优化把每个字符预先烘焙到离屏 Canvas 上再调用 drawImage 绘制整块字符图性能会明显更好。3.3 一个可运行的渲染循环渲染循环是游戏的心脏。常见的做法是用requestAnimationFrame驱动每帧执行时间更新和绘制let lastTime 0; function gameLoop(time) { const dt (time - lastTime) / 1000; lastTime time; update(dt); render(); window.requestAnimationFrame(gameLoop); } window.requestAnimationFrame(gameLoop);update负责更新玩家位置、天气粒子、霓虹灯闪动等逻辑render只负责把当前状态画到 Canvas 上。把逻辑更新和渲染分离是让后续扩展更清晰的关键。比如加入更多 NPC 时只需要在update里增加 NPC 的移动逻辑在render里绘制对应字符就行。4. 赛博朋克城市生成城市不是手工摆出来的而是通过程序生成。生成规则越简单代码越容易维护但越考验设计功力。这里我会拆解一个可行方案先画道路网格再在街区里生成建筑最后在建筑外墙生成窗户和霓虹灯。4.1 地图数据结构城市地图可以理解成一张二维网格。网格中的每一格都代表一个地块地块可能是道路、空地或者建筑。为了让城市看起来有规律道路通常按固定间距生成这样自然形成街区。function generateCity() { const ROAD_STEP 9; for (let y 0; y MAP_H; y) { for (let x 0; x MAP_W; x) { const isRoad x % ROAD_STEP 0 || y % ROAD_STEP 0; if (isRoad) { map[y][x] { ch: ., color: #2a2f3a, bg: #0b0e14 }; } else { map[y][x] { ch: , color: #444, bg: #0b0e14 }; } } } }这里的ROAD_STEP 9表示每隔 8 格出现一条道路。你可以调整这个参数让街区变得密集或者宽阔。4.2 在街区里生成建筑生成建筑的核心思路是在街区内部填充一个矩形区域边缘是墙内部是空间或窗户。为了让城市看起来参差不齐可以给每栋建筑随机留一些缺口避免所有楼房看起来完全一样。function buildBuilding(startX, startY, w, h) { for (let dy 0; dy h; dy) { for (let dx 0; dx w; dx) { const x startX dx; const y startY dy; const isEdge dx 0 || dy 0 || dx w - 1 || dy h - 1; if (isEdge) { map[y][x] { ch: #, color: #7a7f8a, bg: #151525 }; } else if (Math.random() 0.15) { map[y][x] { ch: O, color: #ffb3c1, bg: #2a0d1a }; } else { map[y][x] { ch: , color: #3a3f4a, bg: #0b0e14 }; } } } }建造位置可以按照街区的中心来算。比如街区在gx * ROAD_STEP 1到gx * ROAD_STEP ROAD_STEP - 1之间建筑就放在这个范围里并且每栋建筑在尺寸上做一点随机变化。为了让城市看起来更像赛博朋克还可以加入楼层高度的概念。例如建筑上部楼层显示窗户底部楼层显示门或者卷帘门这样可以形成垂直层次感。const layer y - startY; if (isEdge) { map[y][x] { ch: #, color: #6a6f7a, bg: #151525 }; } else if (layer 2) { map[y][x] { ch: G, color: #ffc800, bg: #1a1a0d }; } else if (Math.random() 0.2) { map[y][x] { ch: o, color: #ff2a6d, bg: #2a0d1a }; } else { map[y][x] { ch: , color: #3a3f4a, bg: #0b0e14 }; }在这个逻辑里底部的G可以是商店门面上层的o是点亮的小窗户边缘的#是建筑轮廓。配上粉色和暖黄色的光城市就活起来了。4.3 霓虹招牌与灯光赛博朋克城市最显眼的特征是霓虹灯。这里可以让一部分建筑地块直接变成广告牌广告牌上可以有固定字符组合例如HOTEL、GAME或者简单的高亮符号。为了让霓虹灯有闪烁感可以保存一份灯牌列表在 update 阶段根据时间改变颜色亮度。const billboards []; function addBillboard(x, y, text, phase) { billboards.push({ x, y, text, phase }); } function updateNeon(time) { for (const billboard of billboards) { const brightness (Math.sin(time * 0.003 billboard.phase) 1) / 2; for (let dx 0; dx billboard.text.length; dx) { const tx billboard.x dx; const color brightness 0.8 ? #ff2a6d : #80204d; map[billboard.y][tx] { ch: billboard.text[dx], color, bg: #0b0e14 }; } } }闪烁不必太频繁否则会干扰阅读。更稳妥的做法是让多数灯牌保持常亮少数招牌阶段式闪动整体画面就会有流动感。4.4 加入雨天和烟雾雨和烟雾对赛博朋克氛围的加成很直接。雨可以通过一组粒子实现粒子在屏幕坐标上不断下落并重新生成const raindrops []; for (let i 0; i 120; i) { raindrops.push({ x: Math.random() * W, y: Math.random() * H, speed: 8 Math.random() * 6 }); } function updateRain(dt) { for (const drop of raindrops) { drop.y drop.speed * dt; drop.x - drop.speed * 0.15 * dt; if (drop.y H) { drop.y 0; drop.x Math.random() * W; } } }下雨时可以把雨点的字符设置为;或,颜色设置为偏蓝的灰色这样能形成明显的长条雨线。烟雾则可以用半透明背景叠加的方式实现不过这需要更复杂的多层渲染在单文件 demo 里可以先不做。5. 实现可步行视角、碰撞与运动地图生成完成后下一步是让玩家能在城市里行走。这里涉及三个关键点玩家坐标、视口映射和碰撞检测。5.1 相机与视口玩家不需要一次性看到整张地图只需要看见以自己为中心的视口区域。假设视口大小是viewW × viewH个格子玩家坐标为(player.x, player.y)那么屏幕上(dx, dy)位置对应的地图坐标可以这样计算function getViewTile(dx, dy) { const mx player.x dx - Math.floor(viewW / 2); const my player.y dy - Math.floor(viewH / 2); if (mx 0 || my 0 || mx MAP_W || my MAP_H) { return { ch: , color: #000, bg: #0b0e14 }; } return map[my][mx]; }当地图边界没有定义时返回空格这样玩家走到地图边缘时不会出现空白报错。5.2 碰撞检测玩家移动前先检查目标格子是否可通行。道路.和空地空格是可通行的建筑外缘#和商店门G需要根据情况决定是否阻挡。最简单的方式是设定一个isBlocked判断函数function isBlocked(tile) { return tile.ch #; }如果你希望玩家能进入部分建筑可以专门设置入口字符例如表示可进入的门进入门后切换到建筑内部地图。5.3 键盘输入键盘输入使用keydown事件监听。方向键和 WASD 都能触发移动每次移动一格这符合网格类城市的基本交互。为了让移动更流畅可以在按住方向键时持续移动但需要加上移动间隔限制否则会在一次按键中连续移动好几格。window.addEventListener(keydown, (e) { const dirs { ArrowUp: [0, -1], ArrowDown: [0, 1], ArrowLeft: [-1, 0], ArrowRight: [1, 0], w: [0, -1], s: [0, 1], a: [-1, 0], d: [1, 0] }; const dir dirs[e.key]; if (dir) { e.preventDefault(); movePlayer(dir[0], dir[1]); } }); function movePlayer(dx, dy) { const nx player.x dx; const ny player.y dy; if (nx 0 || ny 0 || nx MAP_W || ny MAP_H) { return; } if (!isBlocked(map[ny][nx])) { player.x nx; player.y ny; } }这里的关键是isBlocked判断和地图边界判断一起做避免玩家移动时数组越界。5.4 移动动画与平滑如果每次移动都是直接跳一格画面会比较生硬。可以让玩家在当前格子和目标格子之间做短暂插值实现平滑滑动。不过这会增加状态管理的复杂度player对象还需要记录原位置、目标位置和移动进度。const player { x: 20, y: 20, fromX: 20, fromY: 20, toX: 20, toY: 20, progress: 1 }; function smoothMove() { if (player.progress 1) { return; } player.progress 0.15; if (player.progress 1) { player.x player.toX; player.y player.toY; player.fromX player.toX; player.fromY player.toY; } }然后渲染时根据fromX和toX的插值计算屏幕上的格子偏移。这样玩家走动时会有平滑过渡城市在滚动时也不会出现让人眼花缭乱的跳变。6. 雨、霓虹和故障赛博朋克氛围细节有了基础城市和玩家移动项目已经可以玩但还缺少氛围感。赛博朋克风格通常包含雨夜、霓虹灯、故障像素和信息流这些效果不需要复杂算法就能给画面带来明显的风格变化。6.1 霓虹灯持续闪烁灯牌闪烁已经在前面实现了。更好的做法是把闪烁系数应用到贴图颜色上让亮度和背景互相影响。可以在渲染霓虹灯字符时根据亮度值在明暗两套颜色之间插值而不是直接切换。6.2 故障效果故障效果是赛博朋克视觉的重要组成部分。实现思路是在渲染完成后选取屏幕上的若干行对它们进行水平偏移并改变一部分像素的颜色模仿老式显示器信号不稳定的感觉。function applyGlitch() { if (Math.random() 0.92) { const lineY Math.floor(Math.random() * canvas.height); const offset (Math.random() - 0.5) * 8; ctx.drawImage(canvas, offset, lineY, canvas.width, 2, 0, lineY, canvas.width, 2); } }这个技巧在 Canvas 上直接用drawImage复制自身部分区域成本很低但能获得明显的故障感。6.3 信息流与 HUD赛博朋克作品里经常有满屏的数据流和终端日志。你可以在场景上方叠加一行状态栏显示玩家坐标、时间、城市区域编号等让整体更接近游戏界面。HUD 渲染使用普通 DOM 元素或另一个小型 Canvas 都行放在主画布上方会简单一些。const hud document.getElementById(hud); hud.textContent POS: ${player.x}, ${player.y} | AREA: 07 | RAIN: ON;信息流类的字符可以从数组里随机选取显示在建筑墙面上形成一种“黑客系统正在运行”的感觉。7. 性能优化与资源占用这类项目虽然是单文件 HTML但并不能毫无节制地消耗 CPU。如果渲染逻辑写得很差哪怕只是一个 100×100 的地图也可能让风扇起飞。下面给出几个重点优化方向。7.1 减少 fillText 调用次数每个字符调用一次 fillText在 60×30 的视口下是 1800 次调用这个数量尚可接受但如果是 120×60 就会明显卡顿。优化方式是先做字符和颜色的分组再按组绘制。更进一步的优化是把字符网格整体渲染到离屏 Canvas再用 drawImage 绘制到主画布上这样可以大幅减少 Canvas API 调用次数。7.2 控制地图更新频率不是所有格子的内容每帧都需要重新生成。比如建筑结构生成之后就不需要重复计算只有雨点、霓虹闪烁和故障效果需要每帧更新。空间换时间的策略在这里很有效把静态层和动态层分开处理静态层只在城市生成时计算一次动态层每帧叠加。7.3 观察资源占用在浏览器开发者工具的 Performance 面板里可以录制一段移动过程查看脚本运行时长和帧率。重点观察两个地方script时间是否占比过高以及paint时间是否每次都很长。如果script时间高说明逻辑更新代码有重复计算如果paint时间长说明 Canvas 绘制调用过于频繁或尺寸过大。7.4 降低 Canvas 分辨率想要在小尺寸上获得更密的效果可以降低 Canvas 的物理分辨率再用 CSS 把它放大到合适的大小。例如 Canvas 内部分辨率设置为 640×400CSS 设置为 1280×800显示效果是像素风的绘制负担却降到了原来的四分之一。8. 完整原型代码骨架下面给出一个可以运行的最小骨架包含初始化、地图生成、主循环和基础渲染把前面几节的核心逻辑整合起来。这里只是一个起点字符细节和天气效果可以继续叠加。!DOCTYPE html html langzh-CN head meta charsetUTF-8 titleASCII Cyberpunk City Prototype/title style body { margin: 0; background: #000; overflow: hidden; } canvas { display: block; margin: 0 auto; background: #0b0e14; } #hud { position: fixed; left: 16px; top: 16px; color: #05d9e8; font-family: monospace; font-size: 14px; z-index: 10; } /style /head body div idhudLOADING.../div canvas idscene/canvas script const canvas document.getElementById(scene); const ctx canvas.getContext(2d); const CELL_W 10; const CELL_H 18; const VIEW_W 64; const VIEW_H 32; canvas.width VIEW_W * CELL_W; canvas.height VIEW_H * CELL_H; const MAP_W 160; const MAP_H 100; const map []; function initMap() { for (let y 0; y MAP_H; y) { map[y] []; for (let x 0; x MAP_W; x) { map[y][x] { ch: ., color: #3a3f4a, bg: #0b0e14 }; } } } function generateCity() { const ROAD_STEP 9; for (let y 0; y MAP_H; y) { for (let x 0; x MAP_W; x) { const isRoad x % ROAD_STEP 0 || y % ROAD_STEP 0; if (isRoad) { map[y][x] { ch: ., color: #2a2f3a, bg: #0b0e14 }; } else { map[y][x] { ch: , color: #3a3f4a, bg: #0b0e14 }; } } } for (let gx 1; gx 15; gx) { for (let gy 1; gy 10; gy) { buildBuilding(gx * ROAD_STEP 1, gy * ROAD_STEP 1, 7, 7); } } } function buildBuilding(startX, startY, w, h) { for (let dy 0; dy h; dy) { for (let dx 0; dx w; dx) { const x startX dx; const y startY dy; const isEdge dx 0 || dy 0 || dx w - 1 || dy h - 1; if (isEdge) { map[y][x] { ch: #, color: #6a6f7a, bg: #151525 }; } else if (Math.random() 0.15) { map[y][x] { ch: o, color: #ff2a6d, bg: #2a0d1a }; } else { map[y][x] { ch: , color: #3a3f4a, bg: #0b0e14 }; } } } } const player { x: 20, y: 20 }; window.addEventListener(keydown, (e) { const dirs { ArrowUp: [0, -1], ArrowDown: [0, 1], ArrowLeft: [-1, 0], ArrowRight: [1, 0], w: [0, -1], s: [0, 1], a: [-1, 0], d: [1, 0] }; const dir dirs[e.key]; if (dir) { e.preventDefault(); movePlayer(dir[0], dir[1]); } }); function movePlayer(dx, dy) { const nx player.x dx; const ny player.y dy; if (nx 0 || ny 0 || nx MAP_W || ny MAP_H) return; if (map[ny][nx].ch ! #) { player.x nx; player.y ny; } } function getViewTile(dx, dy) { const mx player.x dx - Math.floor(VIEW_W / 2); const my player.y dy - Math.floor(VIEW_H / 2); if (mx 0 || my 0 || mx MAP_W || my MAP_H) { return { ch: , color: #0b0e14, bg: #0b0e14 }; } return map[my][mx]; } function render() { ctx.fillStyle #0b0e14; ctx.fillRect(0, 0, canvas.width, canvas.height); ctx.font ${CELL_H}px monospace; ctx.textBaseline top; for (let dy 0; dy VIEW_H; dy) { for (let dx 0; dx VIEW_W; dx) { const tile getViewTile(dx, dy); ctx.fillStyle tile.color; ctx.fillText(tile.ch, dx * CELL_W, dy * CELL_H); } } const hud document.getElementById(hud); hud.textContent POS: ${player.x}, ${player.y}; } function loop() { render(); window.requestAnimationFrame(loop); } initMap(); generateCity(); window.requestAnimationFrame(loop); /script /body /html这个骨架在浏览器打开后可以用方向键或 WASD 在街道中移动。建筑墙上的#会阻挡角色地面.可以自由通行。想要看到更明显的霓虹闪烁可以在render中加入时间参数并根据Date.now()动态改变窗户字符的颜色。9. 常见问题与排查方法问题现象可能原因排查方式解决方案打开 HTML 后页面为空白Canvas 宽高计算有误或 JS 报错打开浏览器控制台查看报错检查 Canvas 宽高是否为正数检查数组下标是否越界键盘方向键无效事件监听未绑定或默认行为被拦截在监听函数里 console.log 测试确认 addEventListener 绑定在 window 上并调用 preventDefault字符显示为方块拼凑浏览器默认字体不是等宽字体检查 font 设置强制设置font-family: monospace或指定系统等宽字体画面闪烁严重每次渲染都重新构建全部地图数据在 render 函数里断点跳转把静态地图生成移到单独初始化函数渲染只读取数据NPC 和角色移动卡顿渲染循环中执行了繁琐的 split 或正则操作使用 Performance 面板分析耗时函数避免在每帧中创建大量对象使用离线 Canvas 缓存屏幕上地图边缘空白视口计算出地图边界外调用 getViewTile 时加边界判断地图外统一返回背景色格子这里有两点最值得注意第一Canvas 字体必须是等宽字体否则字符宽高不一致城市网格会歪掉第二键盘事件如果绑定在特定元素上焦点不在该元素时事件不会触发绑定到 window 是最稳妥的。10. 最佳实践与合规使用建议这类单文件项目在工程化上不需要复杂流程但如果你想在正式项目中使用有几个建议。第一第一次运行时保持小参数。视口可以先设置成 40×20地图 80×60确认逻辑没有 bug 后再逐步放大这样可以快速定位性能瓶颈。第二把地图生成、渲染、输入处理三个模块在文件内做好注释分隔。虽然不是模块化工程但清晰的注释能让你在后续改代码时节省大量时间。第三重新实现前先确认原项目许可证。如果你想基于原作者的代码做修改和发布需要查看仓库的 LICENSE 文件明确可以修改、商用还是仅供学习。第四如果要在互联网上部署需要注意素材合规。使用开源字体时确认字体的授权范围使用版权角色或标志时需要获得授权否则可能引发风险。第五这个项目本质上是代码生成的可视化内容但如果城市中包含了拟真的人物角色、公司名称、品牌元素应当避免影射真实公司或个人。使用虚构名称是更稳妥的选择。11. 总结与下一步这个单文件 HTML 的 ASCII 赛博朋克城市最值得尝试的地方在于它用极少的资源完成了看似复杂的城市体验。整条技术链路并不复杂字符网格渲染、程序化城市生成、键盘输入、视口滚动再加一点霓虹闪烁和雨滴效果就能构造出一个很有沉浸感的街区。如果你打算自己写一个类似项目最先应该验证的功能是“角色能否在道路上顺畅移动”这是所有后续效果的基础。最容易踩的坑是字体不一致导致网格错位以及渲染循环里频繁创建对象导致的卡顿。先把这两个问题解决再慢慢往里面加天气、故障效果和 NPC。后续可以扩展的方向很多加入昼夜循环改变全局亮度、为每个街区添加不同的建筑风格、把地图改成区块系统实现更大规模的动态加载、用 localStorage 保存玩家的位置状态甚至可以把地图导出为文本文件用来做关卡编辑。走到这一步整个项目已经从一个小 demo 变成了一个可扩展的多功能演示框架。建议把文稿里这段骨架代码保存下来跑通后再顺着自己的需求继续改。