项目概况
大二小学期做的 2D 平台跳跃游戏,传送门机制,带剧情和 Boss 战。代码在 github.com/Ri-Nai/portal-2d。
- 技术栈:HTML5 Canvas 2D + 原生 ES6 Class,不用游戏引擎、不用框架、不用构建工具
- 规模:47 个 JS 文件约 4360 行,HTML 764 行,CSS 1185 行,Python 593 行
- 团队:2 名开发 + 1 名美术。我负责物理与碰撞基类、玩家手感、传送门、凝胶、关卡编译工具链
- 数据:关卡用 Excel 表格写,Python 工具链编译成 JSON;运行时只用
localStorage,没有网络请求 - 部署:GitHub Pages
架构
主循环在 Game.js,一帧分两阶段:先跑所有 computations,再跑所有 renderings。这两个数组就是系统的注册表,绘制顺序显式排定,按画家算法分层:
map → Boss → 凝胶机 → 箱子 → 凝胶层 → 事件 → 玩家 → 传送门 → 枪
其余目录:
Managers/ Map / Event / Dialog / Achievement / Sound / Texture / Input / Data
Entities/ Entity(物理基类)→ Player / Cube / Bullet / Gel / GLaDOS(Boss)
Events/ Event 基类 → Button / Door / Wire / Camera / DramaEvent / Achievement
Tiles/ Tile → Edge / Portal / GelDispenser / GelledEdgeList
utils/ Vector / Hitbox / Keyboard / Mouse / Save / Store / Draw / FrameRate
碰撞与物理
AABB + 轴分离
碰撞用轴对齐包围盒,x、y 分轴求解。返回值是压缩成 2 bit 的状态:
// &1 = 撞 x,&2 = 撞 y
调用方按键位判断要不要停速度、要不要放音效。这个 2 bit 约定后面凝胶也会复用(见凝胶一节)。
单位步长扫描移动
不用射线检测,直接按速度逐 1 个单位步进,每步试移动、撞了就回退并做速度修正:
// rigidMove:碰撞时 -v * 0.95,带衰减的反弹
好处是天然不会隧穿——速度上限 MaxSpeed = 6 / 帧,两轴加起来每帧最多步进 12 次,步数有界。代价是每帧的碰撞比较次数约为 12 × 候选面数。
浮点问题用取整规避,位移前先 round():
//round是因为,如果沾上浮点数判断,这辈子有了
摩擦分层
地面 / 空中、玩家 / 非玩家分别走不同分支:三个 decelerate 系数 0.1 / 0.3 / 0.01(其中一处还乘了 (1 + isPlayer)),另有一处按 Math.exp(-0.5 * deltaTime) 做指数衰减。分开写是为了让不同状态下的手感能单独调。
跳跃手感
手感参数集中在一个 Jumping 类里实例化:
new Jumping(baseJump = 4, maxJump = 9, gravity = 0.5,
coyoteTime = 8, jumpBufferTime = 15)
- 可变高度蓄力跳:按住跳跃键累积
chargeTime,初速在baseJump和maxJump之间插值,按得越久跳越高 - 土狼时间(8 帧):离开地面后仍有 8 帧可以起跳。落在速度凝胶上会重置并放大到
1.3×,允许1.5×跳高 - 跳跃缓冲(15 帧):落地前按下的跳跃被暂存,落地瞬间自动起跳
传送门
数据模型
每扇门有 4 个朝向 facing、宽度 portalWidth、半径 portalRadius。两扇门存在 view.portals 里,配对索引用 i ^ 1 取值。
统一的穿越判定
穿越逻辑写成 Entity 基类上的 checkPortal(delta),玩家、箱子、凝胶、子弹共用这一份实现。一个方法里做了五件事:
- 入口检测:
portals[i].isMoveIn(this.hitbox)。两扇门没放齐(type == -1)直接返回 false - 出口落点:
moveOutPortalPosition(portals[i ^ 1])算出口位置 - 防卡墙:出口落点撞到边就返回
null,此时直接break放弃这次传送,不把实体塞进墙里 - 速度旋转:
rotateVelocity(infacing, outFacing),角度是π/2 × ((in - out + 4) mod 4),模长不变、方向随门朝向旋转。另外如果入射速度在门法向上的分量太小,会被补到MaxSpeed × 1.2,否则玩家会被慢慢挤出门然后卡住 - 二次进入抑制:传送成功后置
this.inPortal = portalBuffer(3 帧)。updateXY里是if (!this.inPortal || ...) nextVelocityX = this.updateX(...),所以这几帧内水平输入不生效,不会刚被吐出来就又被同一扇门吸回去。例外是蓄力跳很深时(jumpVelocity <= -4 * maxJump)仍然让updateX生效——否则从门里被高速抛出来那一下会失去操作权
返回值是一个位标记 1 << (i ^ 1),和碰撞结果用同一套位运算风格。
调用点
checkPortal 在整个项目里只有两个调用位置:
rigidMove的逐单位步进里,每走 1 个单位之前试一次isOnGround()里,判断脚下有没有地面之前先往下试挪 1 个单位
也就是说站立不动时也会触发传送判定,不需要在移动逻辑里额外开分支。
唯一的变体:投射物
基类里有一处开关:
if (!this.isBullet)
this.velocity = Portal.unitDirection[infacing].scale(this.velocity.magnitude());
Bullet 和 Gel 都会把这个开关置为 true,所以它实际是「投射物」的意思:穿越时保留自身弹道方向,不把速度归一到门的法向。玩家和箱子才需要归一。Boss 的弹幕穿门后能变成玩家的子弹,靠的就是这个分支。
这个命名比语义窄,如果重做会改成类似 keepsTrajectory 的名字。
门的放置
射线是逐像素步进的,target = SPEED × frameRatio,SPEED = 25。每一步依次判断四件事,命中即结束:
- 画布边界
edges里type == 2的可开门面 → 定门- 白色转换凝胶集合(合成一条临时 Edge,并与相邻可开门面 merge)
super_edges作为纯阻挡
开火有 250ms 节流。判定通过后还要过两道检查:
- 合法性
Portal.valid:只能开在edge.type == 2上,edgeLength >= portalLength,且不能和另一扇门重叠(用另一扇门的 hitbox 判断新门两角是否落在里面) - 吸附
Portal.fixPosition:三分支把门夹在边的范围内——完全在边内就不动,只有右下角在内就贴边起点,否则贴边末端
这不是解析求交,是逐像素步进,代价是 O(射程) 步 × O(边数)。几何简单,够用。
凝胶
区间集合的并集与差集
三种凝胶(蓝 / 橙 / 白)各自维护一个分段列表 gelledEdges[0..2],每条段带朝向和长度。涂一条新凝胶时:
- 以门的尺寸贴片
clip到被击中的边,得到新段 - 遍历三种凝胶的既有段:同类型且重叠就 merge(并集);不同类型重叠就把新旧两段都切成最多两段、只留不相交的部分(差集),也就是新凝胶覆盖旧凝胶
- 重算每段的
length
本质是一个 1D 区间集合的 union / difference,用几何方式实现,没有上区间树。
和物理系统的耦合
isOnGround() 的返回值是一个压缩状态:&1 是实心地面,&2 和 &4 是两种速度凝胶,和碰撞结果的 2 bit 约定是同一套。updateX 对 &4 走加速 / 强减速分支。
白色转换凝胶在 PortalGun 里被当成可开门表面(合成临时 Edge 并与相邻可开门面 merge),也就是「把不可开门的表面涂成可开门」,对应 Portal 2 的转换凝胶玩法。
凝胶弹体本身复用 Entity 的物理,也走 checkPortal,所以凝胶可以穿过传送门,穿门后重新归一化方向;命中 super_edges 则涂抹并销毁。
关卡工具链
数据格式
每张地图是固定一屏的 18 × 32 = 576 格网格,40 像素一格,即 1280 × 720。
两层压缩
MapCreator.py 读 Excel 表(坐标索引),先补 NaN 为 0,然后做两层压缩:
第一层 fill_block():按行游程合并(RLE)。 连续相同的地块合成一条条带,正数进 blocks,负数进背景贴图层。
第二层 fill_edge():提取暴露面。 网格向外扩一圈边界后(B 是 20 × 34),沿 x 和 y 各两个方向共四趟扫描(make_edge_x(1)、make_edge_y(1)、make_edge_x(-1)、make_edge_y(-1)):
if B[i][j] <= 0 or B[i + diff][j] > 0:
# 非暴露面,结束当前段
else:
# 暴露面,按连续性和材质切段,写进 edges / super_edges
edges:按材质类型切段,用于渲染super_edges:不分材质,运行时碰撞用这个(Entity.checkHits(window.$game.map.superEdges))
渲染仍按单位块绘制,碰撞用合并后的边表,渲染保真和碰撞省算力分开。
实测数据
全量 22 张可解析地图:
| 量 | 平均 | 最大 |
|---|---|---|
| 实心格 | 231 | 379 |
blocks 条带(RLE 后) | 63 | 158 |
super_edges(碰撞候选集) | 25(中位 24) | 48 |
即每次碰撞查询的候选集从 231 个实心格降到 25 条边。
这里要说明一点:这不是复杂度层的优化。 段数取决于暴露面的连续性——大片连续地面会合并成 1 段,所以实测值才这么低;而棋盘格地形下每格四面都暴露、互不相连,段数最坏是 Θ(n · m),甚至因为四个方向各算一遍而比地块数更多。收益来自「大片实心 + 稀疏平台」的关卡几何,以及只存边界不存内部格子。
其它区域
同一份 Excel 里还有几个 sheet,用并查集做连通块、取包围盒:
EventArea→ 事件区,BFS 算上下游方向链(nxtdir/predir)DramaArea→ 剧情区,对应事件 idSignArea→ 上层贴图,不合并,逐格输出backgroundObjects→ 背景物件,同样用并查集合并成包围盒
产物结构
编译产物只有一份:assets/stages/maps/<name>.json。内容是:
layers(7 层)· blocks · edges · super_edges
· events(事件区)· drama_events(剧情区)
需要澄清的是 events/ 和 viewdatas/ 这两个目录不是产物,是手写输入:
events/<name>.json是对话文本。get_drama_events()会反过来json.load读它,把文本 update 进产物viewdatas/<name>.json是实体初始位置(玩家、箱子、凝胶机、门的初始状态)
RunAll.py 遍历 MapEditor/*.xlsx,逐个调 MapCreator.py 并通过 stdin 喂文件名。
加载方式
产物是 JSONP 而不是纯 JSON:
window.$game.dataManager.resolve({ ... })
DataManager.loadJSON 不用 fetch,而是 document.createElement('script') 把数据文件当脚本加载,Promise 的 resolve 被挂成全局回调。动机是 fetch 在 file:// 协议下会被 CORS 拦死,<script> 不受限制,这样双击 index.html 就能玩,不需要起本地服务器。
代价:数据不是严格 JSON(用严格解析器会失败,Test.json 就因为尾逗号挂过)、没法做流式加载和超时控制。现在会直接用 fetch + 本地 dev server。
事件与剧情
信号图
每个 GameEvent 有 id 和 affect: string[](下游事件 id 列表),按钮 → 电线 → 门 就是这样连起来的。
activate() 的实现是边沿触发:
activate() {
if (!this.activated) {
this.activated = true;
this.onActivate();
}
this.affect.forEach((id) => { ... event.activate(); });
}
先判 this.activated,只有 false → true 那一次跳变才调 onActivate(),然后才往下游传播。如果写成电平触发,玩家站在按钮上会每帧重复触发下游,剧情会被反复播放。
过场队列
EventManager 用一个手写链表(head / tail / next)当 FIFO 队列,每帧 handle() 处理一个事件。事件类型有 delay、dialog、fadeIn、fadeOut、turn、showImg、playBGM、deathSelect、gameEnd。
handle() 是 async,用 await 把打字机对话、淡入淡出、CG 展示串成顺序代码,主循环照旧由 requestAnimationFrame 驱动。过场期间 player.blockMove = true 锁输入。分支结局用 DeadScreen.show() 返回的 Promise 两个分支决定:重试就 restart(),否则写 Store.set("ending", "bad") 然后 gameEnd()。
这套写法的问题是全局单例 window.$game 到处都是,而且缺少统一的取消语义——中断过场靠 printing、blockMove 这类布尔标志,没有一个地方能统一取消。
帧与时间
两套帧率无关写法
项目里同时存在两种,语义不同:
- 物理:
deltaFrame = 60 × dt / 1000,把真实帧折算成「60fps 下的帧数」,速度量纲不变 - 传送门子弹:
target = SPEED × frameRatio,按真实时间给步数,掉帧时多走几步
暂停时间补偿
暂停时循环 await wait(100) 空转,恢复时把真实停顿的毫秒数加回时间戳:
loop(now + pauseTime)
这样 interval 不会因为暂停而暴涨,避免恢复瞬间物理炸开或穿墙。
已知问题
- 没有空间划分:边表和凝胶表都是线性遍历。单屏 25 条无所谓,多屏就该按区块分桶查局部
- 全局单例:
window.$game挂了一切,没有依赖注入也没有模块化。现在会拆 ES Module 加构建工具(当时是一堆<script>全局类) - 手感参数散落:摩擦 0.5 / 0.3 / 0.1、衰减系数、coyote 8 帧、buffer 15 帧散在各个文件里,没有集中配置
- 重复定义:
Entity.js里moveOutPortalPosition定义了两遍,内容相同 - 命名比语义窄:
isBullet实际表示投射物(子弹和凝胶) - 数据非严格 JSON:JSONP 的副作用,
Test.json的尾逗号会让严格解析失败 - 暂停实现粗糙:
while (isPaused) await wait(100)空转 - 没有测试:整个项目没有
package.json,没有任何断言。这是最该补的