《Cocos 游戏开发入门》附录 D:常见错误与 FAQ

本附录按报错原文索引《Cocos 游戏开发入门》的高频问题,每条给「现象 / 原因 / 修法」:固定步长累积器丢步跳步、window 变量 undefined 因 ES module 被 file:// 拦、Playwright 截图全白、纹理跨域污染 canvas、DPR 坐标错位、tsc 报 cc 无导出成员、YAML 里 @ 开头列表项、Hugo REF_NOT_FOUND 与围栏未闭合。

附录 D:常见错误与 FAQ

读懂报错,是这本书想帮你建立的独立能力。游戏开发里绝大多数「玄学问题」——画面抖、位置偏、截图白、速度不对——追到根上都是一条具体的机制没对上:时间戳量错了、坐标系没换算、加载方式不对。这一页把全书最常撞上的问题按「现象 / 原因 / 修法」整理出来,每条都给最小复现与修法。

用法:先按报错原文在 D.1 的表里定位条目,再照「修法」改。下面所有报错文本都在本机实际触发过。

D.1 报错原文速查表

你看到的原文去看一句话病根
画面偶尔抖一下、逻辑步数对不上D.2对增量取整而非按绝对时间戳累积
Uncaught TypeError: window.mini2d is undefinedD.3ES module 被 file:// 拦,脚本没跑
截图全白、截到空白帧D.4waitUntil 用 load 与渲染赛跑
SecurityError: ... tainted canvasesD.5跨域图片污染了 canvas
点击位置和画面对不上、偏一截D.6没算 DPR 或画布缩放
Module '"cc"' has no exported member 'X'D.7cc 符号名写错,须查 cc.d.ts
SyntaxError ... enum is not supportedD.8用了不可擦除语法
YAML 解析失败、构建中断D.9@ 开头的列表项没加引号
构建报 REF_NOT_FOUNDD.10relref 目标写错或文章日期晚于构建
代码块渲染成普通段落、整页错乱D.11围栏缺语言或未闭合

D.2 固定步长累积器丢步或跳步

现象:角色移动速度时快时慢,或者在同一段输入下,两次运行走了不同的距离;帧率波动时尤其明显。

原因:固定步长循环写成了「对增量取整」。看下面这个错误写法:

// 错误:对 delta 取整,余数被丢掉,累积误差越滚越大
function update(deltaMs: number): void {
  const steps = Math.round(deltaMs / STEP_MS);
  for (let i = 0; i < steps; i += 1) {
    step(STEP_MS);        // 永远用固定值推进,丢了余数
  }
}

Math.round 把余数抹掉:deltaMs = 23ms、STEP_MS = 16.67ms 时,steps = 1,那多出来的 6.3ms 凭空消失。它不会累积到下一次,于是逻辑时间永远追不上真实时间——这就是「丢步」。反过来,若用 Math.ceil,就会多走一步,表现为「跳步」。

修法:累积真实增量,按绝对时间戳消费。余数留在累积器里,下一帧继续用:

let accumulator = 0;

function update(deltaMs: number): void {
  accumulator += deltaMs;               // 累积真实时间,不取整
  while (accumulator >= STEP_MS) {
    step(STEP_MS);                      // 每次消费一个完整步长
    accumulator -= STEP_MS;             // 余数保留
  }
  const alpha = accumulator / STEP_MS;  // 剩余比例,交给渲染插值
  render(alpha);
}

关键点是「累积」与「取余」两件事分开:while 消费完整步长,余下的 accumulator 留给下一帧,同时它还能算插值系数 alpha。这就是第 1.3 节与第 4.2 节反复强调的写法。若担心卡顿后一次追太多步(死亡螺旋),再加一个 maxStepsPerFrame 上限。

D.3 window.xxx is undefined:ES module 被 file:// 拦

现象:直接双击 index.html 打开,控制台报:

Uncaught TypeError: window.mini2d is undefined

但同一份代码用 npm run dev 打开却一切正常。

原因:ES module(<script type="module">)在 file:// 协议下会被浏览器的 CORS 策略拦下——模块脚本必须通过 HTTP 抓取,而 file:// 的 origin 是 null,于是脚本根本没执行,window.mini2d 自然不存在。报错信息具有误导性:它像是「变量没赋值」,其实是「脚本没加载」。

修法:走 HTTP,别用 file:// 打开。两种方式:

npm run dev          # 开发服务器,热更新
npm run build && npm run preview   # 预览打包产物

唯一的例外是 vite build 后的单文件产物:C.6 的配置把 JS 全部内联进 index.html,内联模块脚本不需要再发起 fetch,file:// 下可以执行。但只要你在 index.html 里用 <script type="module" src="/src/index.ts"> 引外部文件,就必须走 HTTP。拿不准时一律走 HTTP。

D.4 Playwright 截图全白

现象:截图脚本跑通、没有报错,但 shot.png 是一张纯白(或纯背景色)的图,游戏画面没出来。

原因:waitUntil 用了 load。load 只等页面资源加载完成,不等第一帧渲染——requestAnimationFrame 的首帧往往排在 load 之后,截图抢在渲染之前,截到的是空白 canvas。

修法:把 waitUntil 改成 networkidle,并留一点缓冲时间:

await page.goto('http://127.0.0.1:4173/', { waitUntil: 'networkidle' });
await page.waitForTimeout(300);      // 等首帧与资源解码
await page.screenshot({ path: 'shot.png' });

networkidle 等「网络静默」,此时加载已稳定;再叠加一个短 waitForTimeout 覆盖首帧。更稳的做法是让页面在渲染完成后主动设一个标志,用 page.waitForFunction(() => window.__rendered === true) 精确等待——第 16.2 节会给出这种「渲染就绪信号」的完整实现。

D.5 纹理跨域污染 canvas

现象:本地 file:// 或跨域图片能正常显示,但一调 ctx.getImageData(...) 或 canvas.toDataURL() 就抛:

SecurityError: Failed to execute 'getImageData' on 'CanvasRenderingContext2D':
The canvas has been tainted by cross-origin data.

原因:只要有一张跨域图片被画进 canvas,整个 canvas 就被标记为「已污染」(tainted)。被污染的 canvas 禁止读取像素,防止通过 canvas 窃取跨域数据。

修法:让图片「同源且声明 CORS」。三选一:

  • 把图片放在与页面同源的目录下,用相对路径加载;
  • 给图片加 crossOrigin 属性,且服务器返回 Access-Control-Allow-Origin;
  • 全程用本地 HTTP 服务(C.8 的做法),避免 file:// 与跨域混用。
const img = new Image();
img.crossOrigin = 'anonymous';   // 必须在设置 src 之前
img.src = 'https://cdn.example.com/tile.png';
img.onload = () => ctx.drawImage(img, 0, 0);

crossOrigin 一定要在 src 之前设置,否则不生效。第 10 章加载远程图集时会再遇到这个问题。

D.6 坐标换算错位:DPR 与画布缩放

现象:点击屏幕左上角,游戏里的方块却在别处响应;或者高分屏(Retina)上一切正常、普通屏上偏一截。

原因:CSS 尺寸、canvas 像素尺寸、DPR 三者的关系没理清。canvas 有两个尺寸:canvas.style.width(CSS 显示尺寸)与 canvas.width(真实像素缓冲区尺寸)。高分屏上二者不相等,比值就是 devicePixelRatio。若只设了一个,或坐标换算时漏乘/多乘 DPR,就会出现错位。

修法:显式维护这条换算链。设置尺寸:

const dpr = window.devicePixelRatio || 1;
const rect = canvas.getBoundingClientRect();   // CSS 像素尺寸
canvas.width = Math.round(rect.width * dpr);   // 像素缓冲区尺寸
canvas.height = Math.round(rect.height * dpr);
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);        // 之后按 CSS 像素作图

换算指针坐标(事件给的是 CSS 像素,相对视口):

canvas.addEventListener('pointerdown', (e) => {
  const rect = canvas.getBoundingClientRect();
  const x = (e.clientX - rect.left) / rect.width * WORLD_WIDTH;
  const y = (e.clientY - rect.top) / rect.height * WORLD_HEIGHT;
  // 注意:若第 1.2 节约定世界坐标 y 轴朝上,这里还要 y = WORLD_HEIGHT - y
});

记住两条:尺寸用 DPR 放大,坐标先减 rect.left/top 再按比例映射。第 5.1 节会把这条链完整推导一遍,包括 y 轴翻转。

D.7 tsc 报 Module ‘“cc”’ has no exported member

现象:类型检查报:

error TS2305: Module '"cc"' has no exported member 'Node2D'.

原因:cc 里的符号名写错了。这类错误几乎都来自凭记忆写 API 名——Cocos 各版本 API 变动大,旧教程里的名字未必存在于 3.8.8。Node2D、CCNode 都是 Cocos2d-x 时代的遗留名,3.8 里根本没有。

修法:落笔前查 cc.d.ts。本书每个 cc 符号都先核对过,工具就放在工作目录:

cd /tmp/cocosbook
./ccgrep.sh Node          # 回显 export class Node ...
./ccgrep.sh Sprite
./ccgrep.sh tween

或直接 grep:

grep -nE 'export (class|interface|function|const|type) 符号名\b' \
  node_modules/@cocos/creator-types/engine/cc.d.ts

另一种常见变体是 Module '"cc"' has no default export——因为有人写 import cc from 'cc' 再用 cc.Node。3.8 的 cc 没有默认导出,必须用具名导入:import { Node } from 'cc'。

D.8 Node 报 enum is not supported

现象:node file.ts 直接抛:

SyntaxError [ERR_UNSUPPORTED_TYPESCRIPT_SYNTAX]: TypeScript enum is not supported in strip-only mode

原因:Node 26 只做类型剥离,enum 会生成运行时代码,无法被擦除(C.7 详述)。namespace 与构造函数参数属性同理。

修法:enum 改用 const 对象 + as const + 联合类型;namespace 改用普通模块导出;参数属性改成声明字段 + 函数体内赋值。下面是不可用的写法:

// 不可用:enum 需要生成运行时代码
enum Direction { Left, Right }

替换成:

const Direction = { Left: 0, Right: 1 } as const;
type Direction = (typeof Direction)[keyof typeof Direction];

把 tsconfig.json 的 erasableSyntaxOnly 打开(C.4),tsc 会在检查期就拦住这类写法,不必等到 node 运行才报错。

D.9 YAML frontmatter 解析失败:@ 开头的列表项

现象:Hugo 构建中断,报 frontmatter 解析错误;错误行往往指向一个看起来毫无问题的列表项。

原因:@ 在 YAML 里是保留指示符。- @ccclass 这样的列表项会被解析器当成非法节点。装饰器名、@ 开头的标识符最容易踩。

修法:给这类列表项加引号:

tags:
  - Cocos
  - "@ccclass"

顺带提醒:tags 里不要出现 #——Hugo 的 urlize 会把 # 吃掉,#c 会退化成 c,标签页 URL 就指错了。本书所有 tags 都避开了 #。

D.10 Hugo 构建报 REF_NOT_FOUND

现象:构建日志里出现:

ERROR ... REF_NOT_FOUND: Ref "cocos-intro-03-1-scene-graph": page not found

原因:两种。其一,relref 短代码的目标 slug 写错了(凭印象写是最高频来源)。其二,目标文章的 date 晚于构建时刻,被 buildFuture = false 静默跳过,于是它的页面不存在,引用它的 relref 自然失败。

修法:先核对 slug——relref 目标只能从白名单复制,不能凭语义猜。再检查目标文章的日期是否已到。另外验证构建必须用非 quiet 模式:hugo --quiet 会把 REF_NOT_FOUND 一并吞掉,页面照样生成,问题要等到线上 404 才暴露。

D.11 代码围栏未闭合或缺语言

现象:构建后代码块渲染成了普通段落,或者从某个围栏开始整页排版错乱。

原因:围栏的开标记后面没写语言(ts / json / sh / text),或开合数量不匹配——多开了一个 ``` 而少闭一个。

修法:每个围栏都标语言,并确保成对闭合。这类问题肉眼可见,但在长文档里极易漏掉一个。写完一篇后,用编辑器折叠所有代码块扫一眼,或数一数围栏总数是否为偶数。

D.12 三条通用排查思路

现象先怀疑
逻辑跑起来不对(丢步、错位)时间或坐标的量纲——回 D.2 / D.6 核对
浏览器里不工作,但代码没错加载方式——是不是 file://(D.3)或截图时机(D.4)
类型检查报错符号名——先 ccgrep.sh 查 cc.d.ts(D.7)

遇到报错先别改逻辑代码:把报错原文与 D.1 的表对齐,绝大多数问题在时间、坐标、加载方式、符号名这四类里,改一两行就能解决。真正需要动脑的是引擎机制本身——帧循环怎么切、批处理怎么合、碰撞怎么响应——那些才是正文各章要解决的问题;环境与口径问题不该占用你的调试预算。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「cocos」更多文章

  1. 《Cocos 游戏联机实战》9.3 延迟补偿与体验权衡
  2. 《Cocos 游戏联机实战》9.2 网络状态 UI 与提示
  3. 《Cocos 游戏联机实战》9.1 同步表现与插值动画