
1. 一个新媒体学生怎么就做起公交游戏了先说说这个项目的来龙去脉。我本科读的是网络与新媒体课程表里排的是传播学概论、新闻采访、短视频剪辑、数据可视化这类内容跟游戏开发八竿子打不着。但大三下学期有一门叫“交互叙事设计”的选修课老师布置的期末作业是“用任意媒介讲一个跟城市有关的故事”形式不限。班里大部分同学选了H5、短视频或者公众号推文我脑子一热决定做一个公交模拟驾驶的小游戏。为什么选公交因为我对城市公共交通有种执念。每天通勤两小时坐在公交车上看窗外街景一帧一帧往后倒那种节奏感和沉浸感是地铁给不了的。而且公交驾驶本身有一套完整的操作逻辑——开关门、报站、变道、礼让行人、准点率考核——这些元素天然适合做成有规则、有反馈的交互系统。更关键的是公交题材在独立游戏圈里几乎没人碰大家要么做赛车要么做卡车模拟公交这个细分方向反而有辨识度。这个项目最终做出来是什么样简单说它是一个基于浏览器的2D俯视角公交驾驶模拟器玩家扮演一名公交司机在一条环形线路上完成起步、停靠、开关门、报站、应对随机事件等任务系统会根据准点率、平稳度、乘客满意度给出评分。整个项目从零开始前后花了大概六周时间代码量不算大但踩的坑一个不少。这篇文章就是把这六周里所有值得说的东西拆开揉碎讲一遍包括技术选型、核心机制设计、实操步骤、参数计算以及那些只有真正动手做过才会知道的坑。如果你也是新媒体或者非计算机专业出身想做一个能拿得出手的交互作品这篇内容应该能帮你省下不少试错时间。2. 整体设计思路与技术选型2.1 为什么不做3D而选2D俯视角一开始我也想过做3D毕竟公交模拟嘛第一人称驾驶视角多带感。但冷静下来算了一笔账3D模型需要建模、贴图、骨骼绑定、光照渲染光是找一辆可用的公交模型就得花掉大半时间更别说场景搭建和性能优化。对于一个人、六周、零游戏开发经验的情况来说3D就是给自己挖坑。2D俯视角的好处非常实际。第一美术资源需求低公交、站台、道路、行人全部可以用简单的几何图形加色块拼出来我甚至直接用Canvas的绘图API画连图片素材都省了。第二逻辑清晰俯视角下车辆的位置、朝向、碰撞检测都在一个平面上数学计算简单直观。第三性能开销小浏览器里跑起来毫无压力老师用办公电脑打开也能流畅运行。第四俯视角反而有一种复古街机感跟公交这个题材搭配起来有种奇妙的协调。提示如果你也是非科班出身第一个交互项目千万别碰3D。2D做完了你至少有一个完整可玩的东西3D做不完你手里只有一堆半成品模型和崩溃的心态。2.2 技术栈选择为什么是原生JavaScript加Canvas技术选型上我考虑过几个方案。Unity WebGL功能最强但学习曲线陡峭打包出来的文件体积也大网页加载慢。Phaser.js是个不错的2D游戏框架文档齐全但引入框架意味着我要先学框架的API时间成本不低。最后我选了最笨但也最可控的方案原生JavaScript加HTML5 Canvas。这个选择的核心逻辑是我的游戏逻辑并不复杂不需要物理引擎、不需要粒子系统、不需要场景管理用原生API完全能覆盖。Canvas的2D上下文提供了绘制矩形、圆形、文字、路径的能力公交、站台、道路这些元素用这些基础图形就能拼出来。游戏循环用requestAnimationFrame状态管理用一个简单的对象碰撞检测用矩形包围盒全部手写代码量可控调试也方便。具体来说整个项目的文件结构是这样的bus-game/ ├── index.html // 页面骨架和Canvas容器 ├── style.css // 页面样式和UI布局 ├── main.js // 游戏入口初始化与主循环 ├── game.js // 核心游戏状态与逻辑 ├── bus.js // 公交车辆类 ├── route.js // 线路与站点数据 ├── ui.js // 界面渲染与交互 └── assets/ // 音效与少量图标没有构建工具没有npm没有打包步骤双击index.html就能跑。对于新媒体学生来说这种“打开就能改改完刷新就能看”的开发体验非常重要它让你把精力集中在游戏逻辑本身而不是被工具链折腾。2.3 核心玩法循环的设计逻辑公交游戏的核心乐趣在哪里我反复问自己这个问题。开车本身并不刺激公交还有一堆限制——不能超速、必须停靠、要开关门。但正是这些限制构成了游戏的张力。我的设计思路是把“遵守规则”变成一种有反馈的挑战。具体来说玩家每一趟车会经历这样的循环从始发站出发加速到合理速度接近下一站时减速精准停靠在站台范围内开门等待乘客上下车关门继续行驶。每个环节都有评分停靠位置偏差超过50厘米扣分开关门时机不对扣分急刹车扣分超速扣分。一趟跑完系统给出总分和星级评价。这个循环之所以成立是因为它模拟了真实公交驾驶中的“压力源”——准点、平稳、安全。玩家不是在追求速度而是在追求精确和流畅。这种“慢游戏”的体验反而比飙车更让人上瘾因为它调动的是专注力和节奏感而不是肾上腺素。3. 核心机制拆解与关键参数计算3.1 公交运动模型加速度、减速度与速度上限公交车的运动不能像赛车那样随心所欲。真实公交的加速度大概在1.0到1.5米每二次方秒之间减速度在1.5到2.5米每二次方秒之间城市道路限速通常是40到60公里每小时。我把这些数据换算到游戏里做了简化处理。游戏中的速度单位用“像素每秒”需要先确定一个换算比例。我设定1米等于10像素那么40公里每小时约等于11.1米每秒也就是111像素每秒。这个速度在Canvas画布上看起来比较合适不会太快导致操作困难也不会太慢显得拖沓。加速度方面我设定正常加速为15像素每二次方秒正常减速为20像素每二次方秒紧急刹车为40像素每二次方秒。这些数值不是随便拍的而是反复试玩后调出来的。加速度太大起步太猛玩家来不及反应加速度太小起步肉玩起来着急。减速度也是同理正常减速要能让玩家在接近站台时有足够的缓冲距离紧急刹车则要能应对突然出现的行人。// bus.js 中的运动参数 const BUS_CONFIG { maxSpeed: 111, // 像素/秒对应40km/h accel: 15, // 正常加速度 decel: 20, // 正常减速度 brakeDecel: 40, // 紧急刹车减速度 stopThreshold: 2, // 速度低于此值视为停止 };注意这些参数一定要在游戏里反复试。我最初把最大速度设成200像素每秒结果玩家根本来不及在站台前减速挫败感极强。后来降到111体验立刻好了很多。参数调优没有捷径就是玩玩到你觉得“顺手”为止。3.2 站台停靠判定距离偏差与评分算法停靠是公交游戏最核心的交互点。玩家需要在站台范围内把车停稳停得越准得分越高。我的判定逻辑是这样的每个站台有一个中心点公交有一个车头位置两者之间的距离偏差决定了停靠精度。具体评分规则偏差范围评分反馈0-20像素完美绿色提示满分21-50像素良好黄色提示80%分数51-100像素及格橙色提示50%分数超过100像素失败红色提示需要倒车重停这个偏差范围是怎么定的20像素对应实际中的2米公交车身长度大概12米站台长度一般能容纳两辆车所以2米以内的偏差在视觉上几乎看不出来算完美。50像素对应5米车头可能超出了站台边缘但乘客还能上车算良好。100像素对应10米基本就是停错位置了必须重来。为了让玩家有调整的机会我允许车辆在停靠后挂倒挡微调。倒挡速度限制在30像素每秒这样玩家可以小幅度修正位置但不会因为倒车太快而再次错过。3.3 乘客系统上下车逻辑与满意度计算乘客是公交游戏的灵魂。没有乘客开车就只是开车。我的乘客系统做了简化但保留了核心逻辑。每个站台有一个候车乘客队列乘客数量随机生成范围在1到8人之间。乘客上车需要时间每人上车耗时0.5秒下车同样每人0.5秒。车门打开后先下后上这个顺序不能乱否则会触发“乘客碰撞”的惩罚。乘客满意度由三个因素决定等待时间、行驶平稳度、停靠精度。等待时间从玩家到达站台前开始计算如果玩家让乘客等太久满意度下降。行驶平稳度通过监测急刹车和急加速的频率来计算每次急刹车扣5点满意度。停靠精度直接映射到满意度完美停靠加10点失败停靠扣20点。满意度最终会影响结算时的总分和星级。五星需要满意度在90以上四星80以上三星70以上低于60就是不及格。这个设计让玩家不能只顾开车还要照顾乘客的感受增加了策略维度。3.4 随机事件系统让每一趟车都不一样为了增加重玩价值我加入了一个简单的随机事件系统。每次发车时系统会从事件池中抽取2到3个事件在行驶过程中随机触发。事件类型包括前方有行人横穿马路需要紧急刹车前方车辆突然变道需要避让站台有老人上车上下车时间加倍临时交通管制需要绕行车辆故障提示需要靠边停车检查这些事件的处理结果会计入评分。比如成功避让行人加5分撞到行人扣30分并直接结束本趟任务。事件系统的代码不复杂就是一个定时器加随机数但它让游戏从“背路线”变成了“应对变化”体验丰富了很多。4. 实操过程与核心环节实现4.1 从零搭建Canvas游戏循环游戏循环是整个项目的骨架。没有循环画面就是静止的。我用requestAnimationFrame实现了一个标准循环每一帧做三件事计算时间差、更新游戏状态、渲染画面。// main.js 游戏主循环 let lastTime 0; function gameLoop(timestamp) { const deltaTime (timestamp - lastTime) / 1000; // 转换为秒 lastTime timestamp; // 防止切换标签页后deltaTime过大导致跳帧 const safeDelta Math.min(deltaTime, 0.1); update(safeDelta); // 更新逻辑 render(); // 渲染画面 requestAnimationFrame(gameLoop); } requestAnimationFrame(gameLoop);这里有个关键细节deltaTime必须做上限保护。如果玩家切换到其他标签页再切回来timestamp的差值可能有好几秒如果不限制公交会瞬间移动一大段距离直接飞出地图。我设了0.1秒的上限超过就按0.1秒算这样即使掉帧也不会出现逻辑爆炸。update函数里做的事情包括读取键盘输入、更新公交速度和位置、检测碰撞、更新乘客状态、检查事件触发。render函数则负责清空画布、绘制道路、绘制站台、绘制公交、绘制UI。这两个函数分开写逻辑清晰调试也方便。4.2 公交绘制用基础图形拼出一辆车Canvas绘制公交不需要图片素材用矩形和圆形就能拼出一个像模像样的俯视角公交。我的绘制顺序是车身底色、车顶、车窗、车轮、车灯、路线牌。// bus.js 绘制方法 function drawBus(ctx, bus) { ctx.save(); ctx.translate(bus.x, bus.y); ctx.rotate(bus.angle); // 车身 ctx.fillStyle #2E86AB; ctx.fillRect(-bus.length/2, -bus.width/2, bus.length, bus.width); // 车顶 ctx.fillStyle #1B6B8A; ctx.fillRect(-bus.length/2 4, -bus.width/2 4, bus.length - 8, bus.width - 8); // 车窗 ctx.fillStyle #A9D6E5; ctx.fillRect(-bus.length/2 8, -bus.width/2 6, bus.length - 16, 6); // 车轮 ctx.fillStyle #333; ctx.beginPath(); ctx.arc(-bus.length/2 10, -bus.width/2, 4, 0, Math.PI * 2); ctx.arc(-bus.length/2 10, bus.width/2, 4, 0, Math.PI * 2); ctx.arc(bus.length/2 - 10, -bus.width/2, 4, 0, Math.PI * 2); ctx.arc(bus.length/2 - 10, bus.width/2, 4, 0, Math.PI * 2); ctx.fill(); ctx.restore(); }这段代码的核心是translate和rotate的配合。先把坐标系原点移到公交中心再旋转坐标系这样绘制时只需要考虑公交自身的局部坐标不用管它在世界坐标里的位置和角度。这是2D游戏里最常用的绘制技巧一定要掌握。公交的尺寸我设定为长度60像素、宽度24像素对应实际中的6米长、2.4米宽比例大致合理。颜色选了蓝色系因为蓝色在道路背景上比较显眼而且给人一种公共服务的稳重感。4.3 道路与站台的生成用数据驱动地图地图不是画出来的而是用数据生成的。我定义了一条环形线路由一系列路径点组成每个路径点有坐标和方向。道路就是沿着这些路径点绘制的一条粗线站台则是特定路径点上的矩形区域。// route.js 线路数据 const ROUTE { waypoints: [ { x: 100, y: 100, angle: 0 }, { x: 700, y: 100, angle: 0 }, { x: 700, y: 500, angle: Math.PI/2 }, { x: 100, y: 500, angle: Math.PI }, { x: 100, y: 100, angle: -Math.PI/2 }, ], stops: [ { id: A, x: 400, y: 100, angle: 0, name: 市民广场 }, { id: B, x: 700, y: 300, angle: Math.PI/2, name: 图书馆 }, { id: C, x: 400, y: 500, angle: Math.PI, name: 体育中心 }, { id: D, x: 100, y: 300, angle: -Math.PI/2, name: 老城区 }, ], };这种数据驱动的设计有个巨大好处改线路只需要改数据不用动绘制代码。我后来想加一个站台只用了五分钟就搞定了因为只需要在stops数组里加一条记录绘制逻辑会自动处理。道路绘制用ctx.lineWidth设置线宽用ctx.strokeStyle设置颜色沿着waypoints连线即可。站台则是在指定位置绘制一个半透明的矩形加上站牌文字。为了让站台更显眼我加了一个脉冲动画用Math.sin(time)控制透明度变化玩家一眼就能看到下一个站台在哪里。4.4 输入控制键盘与触屏的双套方案输入控制我做了两套键盘和触屏。键盘用方向键或WASD控制加速、刹车、转向触屏则在屏幕上绘制虚拟按钮。为什么要做两套因为老师可能在电脑上玩同学可能在手机上玩兼容性很重要。键盘控制的逻辑很简单监听keydown和keyup事件维护一个按键状态对象在update里根据按键状态调整公交的加速度和转向角速度。// 键盘输入 const keys {}; document.addEventListener(keydown, (e) { keys[e.key.toLowerCase()] true; }); document.addEventListener(keyup, (e) { keys[e.key.toLowerCase()] false; }); // 在update中使用 if (keys[arrowup] || keys[w]) { bus.speed BUS_CONFIG.accel * deltaTime; } if (keys[arrowdown] || keys[s]) { bus.speed - BUS_CONFIG.decel * deltaTime; }触屏控制稍微麻烦一点需要在Canvas上绘制虚拟按钮然后监听touchstart和touchend事件判断触摸点是否落在按钮区域内。我用了最简单的方式在屏幕左下角画两个大按钮一个加速一个刹车右下角画两个转向按钮。按钮的点击区域比视觉区域大20%这样手指粗的玩家也能按准。提示触屏按钮一定要做“按下反馈”比如按下时改变按钮颜色或透明度。没有反馈的按钮会让玩家怀疑自己有没有按到体验很差。4.5 音效与报站用Web Audio API合成声音音效是提升沉浸感的关键但我没有现成的音频素材。怎么办用Web Audio API合成。报站声可以用语音合成API发动机声可以用振荡器模拟。// 发动机声音 const audioCtx new AudioContext(); const oscillator audioCtx.createOscillator(); const gainNode audioCtx.createGain(); oscillator.type sawtooth; oscillator.frequency.value 80; // 基础频率 gainNode.gain.value 0.05; // 音量要小不然很吵 oscillator.connect(gainNode); gainNode.connect(audioCtx.destination); oscillator.start(); // 根据速度调整频率 function updateEngineSound(speed) { const freq 80 (speed / BUS_CONFIG.maxSpeed) * 120; oscillator.frequency.setTargetAtTime(freq, audioCtx.currentTime, 0.1); }报站用speechSynthesis这是浏览器自带的语音合成接口不需要任何外部资源。function announceStop(stopName) { const utterance new SpeechSynthesisUtterance(下一站${stopName}); utterance.lang zh-CN; utterance.rate 0.9; speechSynthesis.speak(utterance); }实测下来语音合成的效果虽然不如真人录音但胜在零成本、零素材、动态生成。而且不同浏览器有不同的语音库有的听起来还挺自然。需要注意的是语音合成在某些浏览器里需要用户先交互一次才能触发所以我在游戏开始按钮的点击事件里初始化了音频上下文。5. 常见问题与排查技巧实录5.1 公交穿模与碰撞检测失效这是我最开始遇到的最头疼的问题。公交在转弯时车头和车尾会扫过道路边缘视觉上看起来像穿进了站台或者撞到了行人但碰撞检测却没有触发。原因是我最初用的是点碰撞检测只检测公交中心点忽略了车身是有长度的。解决方案是把公交的碰撞体从点扩展为矩形用分离轴定理做矩形与矩形的碰撞检测。分离轴定理听起来吓人但2D情况下实现起来并不复杂核心就是检查两个矩形在四个轴上的投影是否重叠。// 矩形碰撞检测简化版适用于轴对齐矩形 function rectCollide(a, b) { return a.x b.x b.w a.x a.w b.x a.y b.y b.h a.y a.h b.y; }对于旋转的公交严格来说需要用分离轴定理但我做了一个简化用公交的包围盒轴对齐矩形代替精确的旋转矩形。包围盒会比车身稍大但检测更简单而且对于这个游戏来说精度足够。实测下来玩家几乎感觉不到差异。5.2 帧率波动导致的操作不一致在不同性能的电脑上游戏的帧率可能从30帧到144帧不等。如果update逻辑直接依赖帧数而不是时间差就会出现“高帧率电脑上公交跑得飞快低帧率电脑上公交像蜗牛”的问题。这个问题的根源是如果每帧都执行bus.x speed那么每秒执行60次的电脑和每秒执行144次的电脑公交移动距离差了2.4倍。正确的做法是用deltaTime乘以速度让移动距离与时间挂钩而不是与帧数挂钩。// 错误做法 bus.x bus.speed; // 正确做法 bus.x bus.speed * deltaTime;这个坑我踩了两天才发现因为我自己电脑帧率高测试时一切正常直到同学用旧笔记本打开公交慢得像在爬。改成deltaTime后所有设备上的体验就一致了。5.3 乘客上下车逻辑的边界情况乘客系统看起来简单但边界情况特别多。我遇到过的典型问题包括问题现象原因解决方案乘客上车后不消失上车动画未完成就关门关门时强制完成所有上下车乘客数量变成负数下车人数超过车上人数下车前检查车上人数乘客卡在车门上下车同时进行严格先下后上用状态机控制站台乘客不刷新队列未重置每次到站重新生成队列最隐蔽的一个问题是如果玩家在开门状态下直接开走乘客会“挂在”车上跟着走。我加了一个检测如果车门未关且车速超过5像素每秒自动关门并扣分。这个逻辑在真实公交里也是成立的——没关门就起步司机要扣工资的。5.4 移动端适配的坑移动端适配比我想象的麻烦。首先是Canvas尺寸问题不同手机的屏幕比例不一样如果Canvas固定尺寸在某些手机上会出现黑边或者拉伸变形。我的解决方案是用CSS让Canvas宽度100%、高度100%然后在JavaScript里根据实际像素比设置Canvas的宽高属性。function resizeCanvas() { const dpr window.devicePixelRatio || 1; const rect canvas.getBoundingClientRect(); canvas.width rect.width * dpr; canvas.height rect.height * dpr; ctx.scale(dpr, dpr); } window.addEventListener(resize, resizeCanvas);devicePixelRatio是为了适配高清屏如果不处理Canvas上的图形在高清屏上会模糊。这个细节很多新手会忽略但处理起来就几行代码效果立竿见影。另一个坑是触摸事件和点击事件的冲突。在移动端touchstart和click可能同时触发导致按钮被按两次。我的做法是统一用touchstart并在事件处理里调用preventDefault阻止默认行为。5.5 性能优化从卡顿到流畅游戏做完后我在低端手机上测试发现帧率只有20多画面明显卡顿。排查后发现几个性能瓶颈第一每帧都在创建新的Canvas路径和渐变对象。解决方案是把不变的绘制结果缓存到离屏Canvas每帧直接drawImage。道路和站台是静态的完全可以预渲染。第二乘客的绘制用了大量save和restore。解决方案是减少状态切换把相同样式的乘客批量绘制。第三碰撞检测每帧遍历所有对象。解决方案是加入简单的空间分区只检测公交附近的物体。优化后低端手机上的帧率稳定在50以上体验流畅了很多。这里的心得是性能优化不一定要做得很复杂把最耗时的操作找出来用缓存或者减少调用次数的方式解决往往就能带来巨大提升。6. 从作业到作品的几个关键决策6.1 为什么坚持不用游戏引擎整个项目做完我最庆幸的决定就是没有用游戏引擎。Unity和Godot确实强大但对于一个六周的作业来说学习引擎本身就要花掉两三周。原生JavaScript虽然什么都要自己写但每一行代码我都理解出了问题我能定位想改什么立刻就能改。这种掌控感对于第一个项目来说非常重要。而且原生JavaScript的项目部署极其简单。整个文件夹丢到任何静态服务器上就能跑不需要打包不需要配置不需要考虑引擎版本兼容。老师打开链接就能玩同学扫码就能体验传播成本几乎为零。6.2 评分系统的平衡性调整评分系统最初的设计过于严苛导致玩家很难拿到三星以上。我找了十个同学试玩收集反馈后做了三轮调整。核心改动包括降低急刹车的扣分权重、增加完美停靠的奖励、允许一定次数的失误不扣分。平衡性调整没有数学公式就是不断试玩、收集数据、调整参数。我记录每次试玩的得分分布目标是让新手第一次玩能拿到两星玩三次能拿到三星玩五次能冲击四星。这个曲线让玩家有成就感也有继续挑战的动力。6.3 视觉风格的取舍美术不是我的强项所以我选择了极简几何风格。公交是矩形加圆形站台是半透明矩形道路是粗线条行人是一个圆加一条线。这种风格的好处是统一、干净、不暴露美术短板。而且几何风格在Canvas里绘制效率极高不需要加载任何图片资源。颜色搭配上我用了蓝色为主色调辅以灰色和白色整体偏冷。站台用橙色高亮形成对比。UI文字用白色加半透明黑色背景保证在任何场景下都能看清。6.4 从作业到可展示作品的包装作业交完之后我把项目整理成了一个可展示的作品。具体做了几件事录制了一段两分钟的游戏演示视频写了一份项目说明文档把代码开源到了代码托管平台并在个人作品集里加了一个页面。这些包装工作花的时间不多但让项目从“课程作业”变成了“可以拿出去说的东西”。面试的时候面试官对这个小游戏很感兴趣问了很多实现细节这比干巴巴地说“我学过JavaScript”有说服力得多。7. 一些只有动手做过才知道的经验做这个公交游戏的过程中我积累了一些没法从教程里学到的经验这里分享几条。第一条先做能跑的东西再做好看的东西。我最初花了两天时间设计UI配色和字体结果核心的驾驶逻辑还没写。后来我强迫自己先把公交能开起来、能停靠、能开关门做出来哪怕画面丑得像草图。功能跑通之后再美化就很快了。这个顺序不能反反了就容易陷入“美化半成品”的陷阱。第二条参数一定要在真实设备上调。我在电脑上把公交加速度调得很舒服结果到手机上发现触屏操作的精度远不如键盘同样的参数在手机上根本停不准。后来我针对触屏单独做了一套参数加速度更柔和转向更灵敏体验才一致。第三条找人试玩比自己做一百次测试都有用。我自己测试时总是下意识地避开问题比如知道前面有站台就提前减速。但试玩的同学不知道路线经常冲过站台或者急刹车。他们的操作暴露了我完全没想到的问题比如站台提示不够明显、刹车反馈不够及时。第四条代码注释和文档要边写边做。我一开始觉得注释浪费时间后来改代码时发现自己都忘了某个函数是干什么的。从那以后我养成了习惯每写完一个模块就加注释每解决一个bug就记录原因。这些注释和记录后来直接变成了这篇文章的素材。第五条不要追求完美要追求完成。六周时间里我砍掉了好几个想做但没时间做的功能比如多线路选择、天气系统、昼夜变化。这些功能如果硬做很可能导致核心玩法都没打磨好。先把一个完整的、可玩的版本做出来比做一个半成品的“全能游戏”有价值得多。这个公交游戏最终在课程里拿了优秀作业但更重要的是它让我完整地经历了一个交互产品从想法到落地的全过程。技术上的收获是次要的真正宝贵的是那种“我居然真的做出来了”的信心。如果你也在犹豫要不要动手做一个自己的项目我的建议是别想太多先打开编辑器写第一行代码。