Axıs
技能目录

引擎附录:Web + three.js r128 + headless Chrome

引擎附录:Web + three.js r128 + headless Chrome

具体技术栈的结论集。通用纪律见 verification-and-forensics.md,本篇只在「现象 → 根因 → 修法 → 判据」给出可落地形态。

来源标注:除最后一节「公开资料补充」外,全部为亲历项目结论。


A. 语言与引擎

A.1 暂时性死区(TDZ)

  • 现象:启动即黑屏,无异常抛出。
  • 根因:文件前半段的房间构建代码调用了后半段才声明的常量/函数表达式。node --check 查不出(语法合法)。
  • 修法:这些 helper 内部引用的一切改成函数声明(会提升);或材质就地构造,不要引用后面才声明的常量。
  • 判据:把「启动一次」做成提交门,当场跑一次主回归页。已崩多次,这条是唯一有效的网。

A.2 拾取射线不检查 visible(r128)

  • 现象:隐藏的演出道具挡掉它后面真正的交互物——玩家站位正确但点不到目标。
  • 根因:r128 的射线器不检查 visible。
  • 修法:纯装饰或平时隐藏的网格一律标记为不参与射线。
  • 判据:全图可达性探针全绿(每个交互物 8 方向 × 2 距离 × 9 级俯仰,逐点命中)。
  • 参考:Raycaster 文档对 intersectObject 行为的说明(见文末公开资料)。

A.3 隐形挡板必须 visible = false,不能只靠 opacity: 0

  • 现象:玩家看到「画外面一圈黑框」;掠射角越明显。
  • 根因:MeshBasicMaterial({transparent: true, opacity: 0, depthWrite: false}) 的挡板与它穿过的表面相交时,会在轮廓上糊出一圈暗边。
  • 修法:设成 visible = false——因为 A.2,射线照样点得中,两个目标不冲突。
  • 例外清单(这些必须保持 visible = true,不能一刀切):运行时驱动透明度的对象——门缝漏光、粉笔显影、镜中剪影。
  • 判据:可达性探针全绿 且 掠射角目检无黑框。第二条不可省,它是唯一能抓到这条的手段。

A.4 灯不要挂在会被 visible 切的组里

  • 现象:玩家进出一间房 → 肉眼可见的卡顿/冻住;历史上那次「卡死 + 恢复后全黑」的真凶就是它。
  • 根因:three.js 按「本帧灯数/类型」生成着色器程序,灯进出场景 = 全材质重编译。
  • 修法:灯与光束目标一律挂 scene 根,用 intensity = 0 表达「不在场」。
  • 判据:连续切换房间场景 20 次,帧耗时的最大值与最小值之比 < 2;且每根光束的方向有几何断言。

A.5 着色器字面量必须递增,mix() 参数顺序要对

  • 现象:屏幕正中一条竖线/硬跳变,换机器就复现。
  • 根因:smoothstep(0.65, 0.35, uv.x) 这种「倒着想当然」是未定义行为。SwiftShader 恰好按 (x - e0)/(e1 - e0) 实现所以看着对,换驱动就在 x = 0.3 与 0.65 处硬跳变。同一行叠加 mix() 参数反了(本项目三次栽在这里),暗角那行属同类问题一并改掉。
  • 修法:smoothstep(edge0, edge1, x) 中 edge0 < edge1;mix(a, b, t) 在 t = 1 时取 b。
  • 判据:这类写法加源码 lint(新代码不得引入边缘字面量倒置的插值调用)。一次目视通过不算通过——未定义行为的当前正确不是正确。

A.6 emissive 太亮会掉进后期有序抖动带

  • 现象:整块镜子变成规则青色圆点阵,一眼假(镜子应该「比墙暗一点点但能映出东西」)。
  • 根因:过亮的自发光量在后期量化/抖动阶段落进固定的抖动带。
  • 修法:压低 emissive 强度,让它低于抖动带下沿。
  • 判据:看截图。 这类问题和贴图通道上的非线性问题一样,靠推理看不出来。

A.7 程序化贴图必须「一类表面一张画布 + 按世界位置取偏移」

  • 现象:门洞两侧「割裂感」——玩家报「每个门口都跳一下」。
  • 根因:两个独立缺陷叠加。
    1. floorTex/wallTex 内含 Math.random 斑驳,调十次就是十张互不相干的画布。
    2. 每片地面都从 UV 0 开始铺,格线与底色在每一个门口跳一次。
  • 修法(两条缺一不可):① 一类表面只画一张画布;② 每片按世界位置取偏移/重复,让相邻两片共用一个晶格。flipV 必须按表面朝向推(±90° 旋转会让一个轴翻转)。
  • 判据:
    • 重复数写死常数,不要留成「每间房一格多大看运气」。亲历同图从 0.33 m/格 到 11 m/格,差 33 倍。
    • 豁免只有一种:一块与别处无公共边的地面可以单独一套(并在代码里注明它是豁免)。
    • 断言:相邻两片的公共边像素差 < 容差。

A.8 drawImage(canvas) 读回永远是黑的

  • 现象:程序化贴图生成后读回来全黑,于是「亮度断言」全部失败。
  • 根因:未开 preserveDrawingBuffer。
  • 修法:亮度/像素只能靠截图或 readPixels,不要用 drawImage 回读渲染结果。
  • 判据:验收页里不存在 drawImage 回读渲染画布的用法(把它做成一条 lint)。

A.9 改着色器前先证明它编译成功

  • 现象:GLSL 语法错让整个后期静默失效,画面停在未处理状态,没有任何报错。
  • 修法:改完后断言 renderer.info.programs.length 增长符合预期,且 getContext().getError() === 0。
  • 判据:后期链路的每一段都有独立断言,不能只断言最终画面「看起来对」。

B. 状态与生命周期

B.1 每帧状态必须初始化

  • 现象:一个键从开局到第一次死亡之前永远按不开;眼皮音效从上线以来一次都没响过;线索条不刷新。
  • 根因:给状态对象加了字段,忘了在字面量里给初值 → undefined <= 0 恒 false。三例同因。
  • 修法:新加的每帧判定字段一律写进状态字面量并给初值(数值 0 / 标志 false)。
  • 判据:断言机制真的触发(计数变化、字段回到初值),不是「没报错」。

B.2 同一状态的多个初始化点会各写各的

  • 现象:「默认闭眼 / 默认眯眼 / 默认睁」三套值长期并存,行为取决于从哪条路进来的。
  • 根因:开局 / 教程 / 重生 / 截图模式四处初始化各写一套值 → 语义漂移。
  • 修法:收口到单一出口。
  • 判据:断言「进入该状态的入口只有一个」;四个入口各跑一遍,状态字面量相等。

B.3 接管状态都要有持有预算,不能只在顺利路径释放

  • 现象:死亡画面期间键盘锁死。
  • 根因:onCaught() 只清了演出锁,却留着控制锁。
  • 修法:每个接管状态声明自己的超时预算,超时强制交还并记泄漏账。释放写在两条路径上:正常路径 + 异常/超时路径。
  • 判据:看门狗从不触发才是通过条件;触发即说明有 bug 被糊住,去追账。

B.4 「停摆看门狗全绿」不等于「没有停摆」

  • 现象:演出期间逻辑状态是暂停,而暂停面板本不该出现在画面上——界面盖住了演出。
  • 根因:端到端断言只看逻辑状态,不看界面层。
  • 修法:界面可见性状态本身要有断言(层是否处于显示态,如 classList.contains('show'))。
  • 判据:涉及演出/暂停/结局的断言必须成对出现——逻辑状态断言 + 界面可见性断言。

B.5 共享 DOM 的状态机必须逐字段重置

  • 现象:视觉上「两张结局卡叠着放」。
  • 根因:四张结局卡共用一组 DOM,只清了其中一块。
  • 修法:进卡时重置全部四层,不要按需清。
  • 判据:进每张卡后断言四层的显示态互斥(最多一层为 show)。

B.6 结局链的定时器必须带代币

  • 现象:回检查点后旧链把上一张结局卡的内容漏进新卡。
  • 根因:回调链从不作废。
  • 修法:链条带代 Token,resume / 重生 / 新结局全部掐断旧代。
  • 判据:断言「同一时刻只有一个代存活」;走一遍 resume → 结局的序列,断言内容不来自上一张卡。

C. 空间与朝向

C.1 记住 yaw 符号约定

forward = (-sin yaw, -cos yaw)
朝西 = π/2   朝东 = -π/2   朝南 = π   朝北 = 0

给探针摆姿势前先画方位图,别凭直觉写 π。判据:探针摆完朝向后打一条正前方射线,断言命中点在预期扇区内。

C.2 「她在看我吗 / 我在这东西面前吗」一律用点积

  • 现象:背对镜子时剪影才累计凝视;人眼盯着看时剪影一动不动。
  • 根因:凝视判据写成 dot(toMirror, -fwd),符号反了。
  • 修法:指向目标的向量是 (tx - x, tz - z) / d,与 forward 直接点积;> 0.55 即「正对着」。
  • 判据:构造四个朝向(正面/侧面 x2/背面),断言点积判据只在前者通过。

C.3 反射面里的东西必须放在反射面之前一侧

  • 现象:剪影逻辑全对,但画面上永远看不见。
  • 根因:剪影摆在镜面之后 2cm,被自己的镜子挡住。
  • 修法:按「镜头在哪一侧」算落点,不是按墙心算。
  • 判据:对每个反射面内的元素,断言其位置在反射面之前侧(带容差)。

C.4 「藏在柜子里」必须真的留出腔体

五块板围出前开口(背/左右/顶/底),门板关上时是唯一的遮挡;不得用「实心箱 + 正面一块黑面片」假装内部。射线只认最近命中,实心箱会让腔内物件在几何上不可达——既看不见也点不中,于是「开柜才可交互」这类门禁逻辑物理上不可能成立。

完整工艺与判据见 level-and-space.md 第 1 节第 2 条。这里只补一条引擎相关的:通用可达性探针拦不住这一类——它只测未被门禁的交互物,而被门禁住的恰好是它跳过的那批,所以要单独写一条断言。腔体不在,五件套就少一件:它不是注释,是几何。判据:射线从四个水平方向 + 俯仰各打一遍,站进腔体后仍能被命中。

C.5 把灯挂到场景根后,旧行语义全部翻转

所有还写着「本地偏移」的旧行都变成了世界坐标。修完把语义重新推一遍并补几何断言。亲历:灯挂回根后本地摆动被当成绝对坐标,光束一直瞄着地图西端,而她在东端巡逻。

C.6 搜索循环的哨兵初值必须以空值起步

  • 现象:十间房的墙贴图 45 个提交全是走廊灰,逻辑「看起来全对」。
  • 根因:let ch = 'v' + !ch 恒假短路 → 循环体从未执行。
  • 判据:只有枚举/像素断言能抓——对全部实例的像素做颜色聚类,断言簇数 ≥ 2。

C.7 加房间要改三处

  1. 字符网格地图;
  2. 地面字符集判定;
  3. 两处遍历用的字符集(房间包围盒 + 地板/天花板)。

漏一处就是「房间存在但没有地板 / 走不进去」。

  • 判据:地图连通性测试——从起点洪水填充,断言没有不可达地板格。
  • 这条测试抓到过致命 bug:竖廊被整面墙截断,宿舍区与北侧完全不通,读代码完全看不出来。

C.8 网格旋转 π 不换轴

  • 现象:五排货架碰撞全部转置——能从货架两头穿进去,货架之间的空地反而撞隐形墙。
  • 根因:用了布尔 ry ? 判断朝向;ry = π 是真值但旋转 π 不换轴。
  • 修法:用 |sin(ry)| > 0.5 判断轴向。
  • 判据:对每个转 π 的碰撞体,断言四个方向的通行性符合设计(长边通、短边挡)。

C.9 门的 alongX 语义与几何相反

  • 现象:门开进墙里。
  • 根因:if (!alongX) grp.rotation.y = Math.PI / 2 把门板放成与通道平行。
  • 修法:转置条件 + 交换两条碰撞体分支 + 新增 swing(门板扫进能站人的那一侧)。
  • 判据:9 扇门逐扇断言三条——扫掠侧可通过 / 关闭时玩家被挡 / 打开后可通过。

C.10 墙体合并必须在门洞格不砌墙的前提下做

  • 现象:所有房间被封死、游戏不可玩。
  • 修法:建墙时先判门洞格;重建建造函数后先全量 grep 旧变量名(残留 mesh: grp 直接启动失败)。
  • 判据:主回归页 + 可达性页同时跑(可达性页抓的就是这类)。

C.11 纸片要有独立命中体

  • 现象:7cm 的钥匙在 1.4m 外点不中。
  • 根因:命中角只有 ±0.6°。
  • 修法:交互语义是「这一块地方」,不是「这一个像素」——给薄物体一个可交互的体积。
  • 判据:全图交互物逐个绕 8 方向 × 2 距离 × 9 级俯仰试射线,点不中即 FAIL。

D. headless 验收的四条铁律

D.1 逐帧断言必须先打渲染帧回调 shim,并确认它真在出帧

  • 现象:任何每帧演化的量(后期 uniform、眼睑插值、淡入淡出)停在初始值,看起来像「机制失效」。
  • 根因:headless 虚拟时间下 requestAnimationFrame 几乎不出帧。
  • 修法:boot 完成后执行
w.requestAnimationFrame = cb => w.setTimeout(() => cb(w.performance.now()), 16);

注意:只做兜底 shim 常常表现为「间歇性运行」,需要强制换成确定性驱动(setTimeout 循环)才能拿到稳定推进。

  • 判据:比对游戏内时钟与墙钟,两者差值在容差内(这是唯一可靠判据,见 MDN 对 rAF 调度的说明)。

D.2 单帧计数与绘制计数分开测

frameN 递增但 drawN 冻结 = 每帧抛异常把渲染吃掉(画面停、心跳还在)。只看其中一个都会误诊。判据:两条断言并列输出。

D.3 改完代码要换缓存旁路参数

  • 现象:跑的是缓存里的旧 JS,改了等于没改。
  • 修法:URL 带 ?cb=<数字> 每次递增;改完不刷新是最常见的假象来源。
  • shot rig 的坑:读 location.hash 的页面必须写成 page.html?cb=X#mode,写成 page.html#mode?cb=X 会让模式字符串带上 query,没有分支匹配,一张 PNG 都不产出——看起来像软渲染挂死。iframe 的 URL 必须带 cache-buster。
  • 判据:脚本里的 cb 值与上次运行不同(写进结果 JSON);每个 iframe URL 都含 cb。
  • 窗口尺寸:--window-size 要与画布布局匹配,否则 HUD 重排。

D.4 headless Chrome 的路径与预算

坑 根因 修法 判据
整批无结果,像游戏崩了 Git Bash 的 $PWD 是 /d/Project/game,Chrome 拿它当 --user-data-dir 会静默启动失败 用 pwd -W 转 Windows 形路径 每页产出结果文件数 = 预期页数
命令静默什么都不产出(像「截图没生成」而不是报错) 路径塞进变量再 "$CH" 展开,/c/Program Files/... 在空格处被拆词 路径里有空格必须整体加引号 同上
结果 JSON 抖动 / 无结果 场景变重后虚拟钟在页面跑完前耗尽 --virtual-time-budget 逐页分配 + 真实超时随预算放大 每页都在预算内完成
音频 bug 全部隐身 无用户手势策略下音频层是空操作 --autoplay-policy=no-user-gesture-required 才能真正开音频跑穷举页 穷举页断言「至少响过一次」,判据不依赖被测代码
整批表现为「游戏坏了」 location.search 为空说明浏览器面板停在旧标签/空白页 先确认面板确实在跑目标页 断言 location.search 含预期旁路参数

提速手段:纯逻辑页可 t.Post.render = () => {} 停渲染,但要在页头写清本页不含依赖循环刷新的断言(见通用篇 §5.13)。


E. 打包与商店发行(HTML5)

E.1 zip 条目必须用正斜杠

Windows 自带压缩打的包在商店平台上路径 404。判据:打包后逐条目检查分隔符;解压到干净目录后按 HTTP 方式访问主文件成功。

E.2 改完代码必须重跑打包脚本

亲历:差点上传了修复前的包。把「打包产物与当前代码同源」做成一条流程步骤或断言(产物内嵌构建标识,与当前源码哈希比对)。

E.3 HTML5 iframe 里没有平台级的退出/重开

结局页的出口必须自己做,不能指望平台提供。

E.4 剧情与营销不许同屏

先把剧情演完,停 5–8 秒,再淡入宣传层;不同字号分层。判据:截图断言任一时刻只有一层可见。

E.5 视口 960×540 进验收页当硬约束

  • 现象:按桌面分辨率设计,结局页内容 691px 在 960×540 里首行在屏上方外、按钮在屏下方外。
  • 判据:按钮 rect 多次采样完全相同 + 底边在视口内 + 统计块不压住按钮。
  • 额外坑:入场动画的 translateY 会把「钉底」的按钮实测低 10px。「位置固定」的元素不得参与任何入场动画——把它写成一条 lint(固定定位元素不得出现在动画目标列表里)。

E.6 结局按钮三层出口

回主菜单 / 从最后检查点继续 / 重开这一夜。「刷新即可重来」会被玩家读成「游戏做完了但没做完」。

每张结局卡进卡即显示「达成结局 N / 总数」并持久化去重。判据:三出口各有一条按键断言;进度跨刷新留证。

E.7 合规文案下沉

主界面只放一行灰字免责声明;健康提示放设置页与商店页,不要占主界面两行。

E.8 清空类操作要列全字段清单

删存储 + 删内存态 + 删 HUD + 删重读列表,四样一起清。只删存储的话本局画面与门禁全都还「记得」,玩家判定「没真的清干净」。判据:四样各有一条按键断言。

E.9 跨局持久化按「永久资产」设计

玩家在意的进度要按永久资产设计,不是本局状态:开机把存档灌回运行时状态;读过的不必每夜重新收集;「本局新增」单独记。

  • 落盘时机必须跟随状态变更点(读到即落盘),不能跟随「结算页」。
  • 亲历:跟随结算页导致中途回主菜单把最后读的几份丢掉,三个界面的数字同时对不上。
  • 判据:读到一份 → 立刻回主菜单 → 重进,断言该份仍在。

公开资料补充

以下为公开文档,仅用于核对上文的 API 行为描述,不含项目结论: