主题
从现象定位问题并做回归
建立静态契约、真实编辑器试玩、日志归因、截图和回归测试证据链。
什么时候查这篇
玩法或界面与预期不同,或准备交给其他作者安装时,使用本专题。按当前需求选择小节即可,不必按专题编号顺序读完。
- 开始前:能重现一个具体问题并找到当前试玩输出;不会 Lua 可先看现象排错页。
- 安装与示例范围:日志片段用于观察;诊断函数不改变分数。CLI 命令是可选工具,只在已绑定的专用教程工程运行,首次学习无需安装自动化平台。
- 本次要看到:写清一条复现操作、预期与实际结果;修复后重复同一操作并检查相邻功能。
如果还没有完成可运行的小游戏,先回到主线:10 课。语法卡住时查Lua 速查,运行结果不符时查按现象排错。
你会学到什么
- 如何用期望日志定位玩法链路。
- 如何按 editor-cli 跑测闭环验证修改。
- 如何用 play session 精确读取日志,并在结束后形成完整证据。
- 如何区分运行时错误、业务日志和 UI 截图问题。
- 如何用 EUI testspec + 截图做回归测试。
如何阅读本专题
本专题不是要求你一次搭完完整测试平台。初次排错先掌握“写下预期、重新试玩、复现、读本次日志、修复后重跑”;当项目开始接入 UI、存档、资产或平台服务时,再回来看 testspec、截图审查和可复现测试记录。
如果你正在排一个具体 bug,可以直接从“复杂链路分层定位”开始;如果你准备发布或交给别人评审,再补齐“跨模块失败复盘清单”和“可复现测试记录”。需要临时定位时先看诊断片段,需要重复验证时再读 CLI 与 testspec。
QA 的基本心智
写完代码不等于功能完成。你需要证明三件事:
| 证明目标 | 证据 |
|---|---|
| 脚本没有运行时错误 | editor-cli log trace --play-session ... 没有语义错误 |
| 玩法链路按预期发生 | 期望日志按顺序出现 |
| UI 真的显示正确 | testspec、断言日志、试玩中截图审查都通过 |
手工排错按“重新试玩 → 复现 → 读当前输出 → 修改 → 重跑”即可。下文的 ready、session 与 idle 检查适用于 CLI 自动化,不能省略,但不作为手工入门的前置。
日志文件的实际路径由 editor-cli status 的 log_file 返回,不要假定项目根目录一定有 log.txt。日志可以在试玩中按 session 查询;停止试玩的意义是关闭本轮时间窗口、释放运行态并确保下一轮从 idle 开始,而不是“只有停止后日志才可读”。
日志规范
好的日志不是越多越好,而是能证明关键节点发生了:
示例类别:解释片段;运行端:server。 只定义日志函数,由已有回合和计分回调传入真实值后调用。
lua
-- @runtime server
local function LogRoundStarted(gameEndTime)
if type(gameEndTime) ~= "number" then
print("[SE Lua Guide][WARN] 回合结束时间无效")
return
end
print("[SE Lua Guide][Round] started endTime=", gameEndTime)
end
local function LogScoreChanged(player, score)
if player == nil or not player:IsA("Player") or type(score) ~= "number" then
print("[SE Lua Guide][WARN] 计分日志参数无效")
return
end
print("[SE Lua Guide][Score] player=", player:GetName(), "score=", score)
end规则:
- 使用统一前缀,例如
[SE Lua Guide]。 - 把模块和动作写进固定标签,例如
[Round] started,方便精确检索;不要只靠自然语言句子。 - 成功路径和失败路径都要有日志。
- Heartbeat 中必须节流,不能每帧刷屏。
- 临时诊断日志解决问题后要删除,保留关键业务日志。
- 不要记录预留服务器访问码、订单凭据、完整存档或其他敏感数据。
期望日志驱动开发
写功能前先写“我期待看到什么日志”:
text
[SE Lua Guide] 回合开始
[SE Lua Guide] 碰撞检测触发
[SE Lua Guide] 玩家识别成功: <name>
[SE Lua Guide] 当前得分: 1
[SE Lua Guide] 回合结束运行后对比实际日志:
| 缺哪条日志 | 常见原因 |
|---|---|
| 回合开始 | 初始化没执行、main.lua 未挂载、require 路径错 |
| 碰撞检测触发 | 对象没找到、事件没绑定、碰撞/触发配置错误 |
| 玩家识别成功 | otherUnit 不是角色,需要检查碰撞对象 |
| 当前得分 | 服务端计分逻辑没走到,或被防抖/校验拦截 |
| UI 更新 | RemoteEvent 未定义一致,或 client 节点没找到 |
复杂链路分层定位
复杂功能出问题时,不要只问“是不是代码错了”。先把链路拆成可观察的层:
| 层 | 典型证据 | 常见问题 |
|---|---|---|
| 场景对象 | 启动日志打印对象名、GetFullPath()、Position | 命名不一致、对象未加载、父节点不对 |
| server 事件 | OnCollisionEnter / OnServerEvent 进入日志 | 碰撞配置错、客户端没发请求、事件名不一致 |
| server 判定 | 校验通过/拒绝日志、分数变化日志 | 防抖过严、玩家识别失败、权限校验漏了 |
| RemoteEvent 下发 | FireClient / FireAllClients 前后的日志 | 目标玩家错、单个 table payload schema 不一致 |
| client 接收 | OnClientEvent 进入日志 | client 脚本未加载、require 路径错 |
| UI 表现 | 节点存在日志、截图、testspec | 仍依赖旧节点映射、节点 Name 不稳定、布局遮挡 |
| 存储/排行 | pcall 成败、默认值、最终榜单 | Async 裸调用、GetAsync + SetAsync 覆盖、OrderedDataStore 值非整数 |
比如“技能命中后 UI 不更新”,应该先证明 server 已判定命中,再证明 server 已下发事件,再证明 client 收到事件,最后才看 UI 节点。这样能避免在 UI 里改半天,结果根因是服务端根本没有通过冷却校验。
失败记录模板
每次修复杂问题,建议留下一个短记录,方便回归:
md
### 失败记录:碰撞计分不增长
- 复现步骤:启动试玩,玩家推动「球体」撞「方块」。
- 期望结果:服务端打印“碰撞检测触发”,分数从 0 变 1,HUD 更新。
- 实际结果:只看到“碰撞检测触发”,没有“LastOwnerUID”。
- 分层定位:server 碰撞事件已进入;玩家识别层失败。
- 根因:球体碰到方块前没有触碰角色,`LastOwnerUID` 未写入。
- 修复:在球体触碰对象先确认 `otherUnit:IsA("EggyUnit")`,再用 `Players:GetPlayerFromCharacter(otherUnit)` 写属性。
- 回归:重跑同一流程,日志出现“加分后: 1”,截图中 HUD 显示 1。失败记录不是给别人看的仪式感,它能迫使你区分“已验证事实”和“猜测”。当同类问题第二次出现时,你也能直接复用分层定位路径。
跨模块失败复盘清单
正式公开前,建议每个 Part 至少补一条真实失败复盘。复盘不要求问题很大,但必须有复现步骤、日志或截图、定位过程和重跑结论。
| 范围 | 建议复盘问题 | 关联专题 | 证据落点 |
|---|---|---|---|
| Part 1 | 对象找不到、碰撞不触发、Timer 重复触发 | 02、05、06 | evidence/editor-runs/ |
| Part 2 | RemoteEvent 单载荷 schema 不一致、UI 节点 Name 不一致、DataStore 失败 fallback | 08、09、11 | evidence/editor-runs/ + evidence/screenshots/ |
| Part 3 | 动画播放了但技能没判定、相机 Raycast 方向写错 | 14、15 | evidence/editor-runs/ |
| Part 4 | custom:// 加载失败、preset 忘记 Parent | 17、18 | evidence/editor-runs/ |
| Part 5 | 平台 Async 裸调用、Capstone 中途加入 HUD 不更新 | 20、22 | evidence/editor-runs/ + evidence/screenshots/ |
这些复盘会让教程从“展示正确写法”升级为“教读者如何把错误修到正确”。如果只能先补一条,优先补完成小游戏后选择扩展挑战 Capstone 的中途加入或 UI 节点缺失,因为它同时覆盖 server、client、RemoteEvent、EUI 和 QA。
editor-cli 跑测闭环
editor-cli 用于选择编辑器实例、做只读预检、控制试玩、注入临时代码、读取 session 日志和截图。当前默认安装位置是用户目录下的 .eggitor/cli/editor-cli.exe。以下 PowerShell 先保存路径和目标实例;如果同时开了多个编辑器,优先用 PID 或 HTTP port,避免同名地图选错:
powershell
$editorCli = Join-Path $env:USERPROFILE ".eggitor\cli\editor-cli.exe"
& $editorCli editor-instances list --json
# 替换成上一步返回的准确 map_name、pid 或 http_port。
$editorTarget = "your-map-name-or-port"
& $editorCli --editor-instance $editorTarget status --json1. 先做只读预检
powershell
& $editorCli --editor-instance $editorTarget preflight `
--capabilities connection,map,play,screenshot `
--expect-state idle --json只有 JSON 中 ready 为 true 才进入下一步。preflight 不会启动/停止试玩、保存地图或同步代码。
如果本轮还要同步 Lua 工程,先额外检查 code-sync 并查看双向 diff;发现 only-local、only-map 或 differs 时先确认所有权,不得机械 push / pull 覆盖他人改动:
powershell
& $editorCli --editor-instance $editorTarget code diff `
--workspace "path-to-your-lua-workspace" --json2. 开始试玩
powershell
& $editorCli --editor-instance $editorTarget play start `
--wait-ready --wait-timeout 30 --json保存返回的 play_session。--wait-ready 会等待游戏运行时实际执行 Lua 探针;没有这个参数时,成功只代表启动请求已提交。不要用固定 sleep 猜测地图是否加载完成。
3. 复现问题
按本次测试目标操作:
| 测试目标 | 操作 |
|---|---|
| 碰撞计分 | 推球撞方块 |
| UI 更新 | 触发服务端分数变化 |
| 按钮请求 | 点击按钮 |
| 存档 | 完成一局后离开或触发保存 |
4. 试玩中按 session 查日志和截图
powershell
$playSession = "play-start-returned-session"
& $editorCli --editor-instance $editorTarget log grep "\[SE Lua Guide\]" `
--play-session $playSession --max-matches 200 --json
& $editorCli --editor-instance $editorTarget log trace `
--play-session $playSession --json
& $editorCli --editor-instance $editorTarget screenshot game `
--output "evidence\screenshots\qa-current.png" --no-clobber --json截图必须在游戏窗口仍处于试玩状态时采集。log grep 负责查期望 marker,log trace 负责提取语义错误;不要只搜一两个英文错误片段就断言“没有错误”。
5. 停止试玩并确认 idle
powershell
& $editorCli --editor-instance $editorTarget play stop --json
& $editorCli --editor-instance $editorTarget status --jsonplay stop 成功只确认停止请求。继续轮询 status,直到同时满足:
text
edit_mode = true
in_game_runtime = false
editor_state = idle6. 复核本轮完整日志
停止后继续用同一个 play_session 做最终复核,避免上一轮或编辑器启动日志污染结论:
powershell
& $editorCli --editor-instance $editorTarget log trace `
--play-session $playSession --json
& $editorCli --editor-instance $editorTarget log grep `
"\[SE Lua Guide\]\[(WARN|ERROR)\]" `
--play-session $playSession --max-matches 200 --json需要人工浏览时可再检索:
text
attempt to
stack traceback
not found
encoding is not valid
[SE Lua Guide][WARN]但发布结论应保存结构化命令结果、session ID、期望 marker 和截图,不要只写“看过日志没问题”。
7. 修复并重跑
每轮只改和诊断一致的最小范围。修复后重新执行完整闭环,不要只凭代码看起来合理就结束。
text
preflight ready → play start --wait-ready → 复现 → session 日志/截图 → play stop → idle → 最终复核 → 修复 → 重跑试玩中执行临时代码
试玩运行时可以通过 editor-cli 注入 Lua 查询状态:
powershell
& $editorCli --editor-instance $editorTarget exec `
"print('[SE Lua Guide][QA] probe=hello')" `
--runtime server --json
& $editorCli --editor-instance $editorTarget log grep `
"\[SE Lua Guide\]\[QA\] probe=hello" `
--play-session $playSession --platform server --json注意:
exec --runtime server/client是异步提交;命令成功不等于片段后来执行成功。- 用唯一 marker 写入日志,再按本轮
play_session回读;查到 BEGIN 而没有 END 也不能算通过。 exec能执行任意 Lua,属于变更操作。注入前先审查副作用,数据存储、交易、传送和分析上报只在隔离环境执行。- 复杂代码优先写入临时 UTF-8 文件,再使用
exec --file ...,避免多层引号转义错误;临时文件放项目tmp/。
pcall:包住可能失败的操作
网络、资源加载、数据存储、排行榜等都可能失败。用 pcall 捕获错误,避免脚本中断:
示例类别:沙盒接入片段;运行端:server。 只定义读取函数,使用本页隔离存储名并由调用方传入测试 key;没有专用存储环境时只阅读。
lua
-- @runtime server
local DataStoreService = game:GetService("DataStoreService")
local Task = game:GetService("Task")
if DataStoreService == nil or Task == nil then return end
local store = DataStoreService:GetDataStore("Tutorial_QA_Read_v1")
local function ReadForQa(key)
if type(key) ~= "string" or key == "" then return end
Task:Spawn(function()
local ok, result = pcall(function()
return store:GetAsync(key)
end)
if ok then
print("[SE Lua Guide][QA] datastore_read=success")
else
print("[SE Lua Guide][WARN] datastore_read=failed error=", result)
end
end)
end不要用 pcall 吞掉所有错误后什么都不打印。失败日志是 QA 的证据;同时不要把读取到的完整玩家数据打进日志。DataStore 具有外部副作用和真实数据风险,这段只在隔离测试存储中做行为验证,普通地图仅做契约校对与编译。
LogService
LogService 可用于测试代码监听运行日志:
示例类别:独立实验;运行端:client;文件:client/main.lua。
lua
-- @runtime client
local LogService = game:GetService("LogService")
if LogService == nil then return end
LogService.MessageOut:Once(function(message, messageType)
print("[SE Lua Guide][LOG]", messageType, message)
end)
LogService:Info("qa_probe", {
module = "tutorial",
})普通玩法代码不一定需要监听 LogService。自动化测试、错误收集、调试工具更常用它。这里必须用 Once:如果用永久 Connect,又在回调里继续 print,新日志可能再次触发同一个回调,形成递归日志风暴。长期监听器应避免在回调里再次产生日志,并在销毁时断开连接。
EUI testspec + 截图审查
UI 测试不能只看断言。断言全部 PASS,也可能出现文字被裁切、按钮偏位、颜色看不清等问题。
推荐顺序:
- 读取
test/testspec_{module}.md,确认节点、交互和设计意图。 - 启动试玩并注入测试。
- 按本轮
play_session轮询日志,直到出现[TEST:END]或错误。 - 试玩仍运行时截取 game 窗口;否则只能得到编辑态画面或截图失败。
- 先检查断言与
log trace,确认测试真正跑到 END 且没有语义错误。 - 停止试玩并确认 idle,再用同一 session 做最终日志复核。
- 查看刚才保存的截图,逐项检查视觉;日志 PASS 不能替代视觉验收。
截图审查至少检查:
| 检查项 | 判定标准 |
|---|---|
| 文字可读性 | 不被遮挡,对比度足够 |
| 布局合理性 | 居中/贴边/对齐符合 testspec |
| 无异常重叠 | 元素没有意外互相盖住 |
| 无裁切异常 | 文字和图片没有被截断 |
| 颜色与样式 | 状态色、透明度、禁用态正确 |
| 无渲染异常 | 没有黑块、白块、拉伸变形 |
如果截图异常,即使断言 PASS,也要修 UI 或 testspec。
保存可复现的测试记录
交给其他作者时,写下地图/工程版本、实际操作、预期结果、实际日志和仍未通过的情况。UI 附一张实际截图并说明要观察的节点。记录可以跟工程放在一起,不要求作者建立教程维护者的发布证据目录。
一个 testspec 至少要描述三类信息:
md
# testspec_hud.md
## 节点
| Name | 类型 | 用途 |
|---|---|---|
| label_time | EUITextLabel | 显示倒计时 |
| label_point | EUITextLabel | 显示当前分数 |
## 交互/数据
- 收到 `HudSnapshot { phase, endTime, serverTime, score }` 后,`label_time` 按 server 时间基准刷新。
- 同一份 `HudSnapshot` 中的 `score` 更新后,`label_point` 显示最新分数。
- 节点缺失时打印 WARN,不抛运行时错误。
## 截图验收
- 两个文本不重叠。
- 数字变化后仍完整显示。
- 低分辨率下不会被边缘裁切。最小排错示例:碰撞计分不增长
1. 写期望日志
text
[SE Lua Guide][Score] collision other= 球体
[SE Lua Guide][Score] last_owner= <uid>
[SE Lua Guide][Score] observed_score= <当前分数>2. 插入临时诊断日志
示例类别:解释片段;运行端:server。 这里只定义函数,由调用方传入本小节要求的对象后执行,不是完整入口。
lua
-- @runtime server
local function AttachScoreDiagnostics(cube, ball, scores)
if cube == nil or not cube:IsA("WorldUnit") then return nil end
if ball == nil or not ball:IsA("WorldUnit") then return nil end
if type(scores) ~= "table" then return nil end
return cube.OnCollisionEnter:Connect(function(otherUnit)
local otherName = otherUnit and otherUnit.Name or "<unknown>"
print("[SE Lua Guide][Score] collision other=", otherName)
if otherUnit ~= ball then
print("[SE Lua Guide][WARN] score_collision=ignored reason=not_ball")
return
end
local uid = ball:GetAttribute("LastOwnerUID")
print("[SE Lua Guide][Score] last_owner=", uid)
if type(uid) ~= "string" or uid == "" then
print("[SE Lua Guide][WARN] score_collision=ignored reason=missing_owner")
return
end
-- 诊断只观察,不修改权威分数,也不发送得分消息。
print("[SE Lua Guide][Score] observed_score=", scores[uid] or 0)
end)
end这个函数只负责临时诊断,不会增加分数。碰撞回调之间的先后顺序不能用来断言加分前后,因此这里只打印观察时的当前值;真实计分是否发生,继续看原有计分模块自己的日志。调用方必须保存返回的 Connection,排查结束或系统销毁时调用 Disconnect()。它假定 LastOwnerUID 已由另一条经过 EggyUnit 类型检查的角色触碰链路写入;诊断日志不能代替那条权威判定。
3. 运行闭环判断
| 现象 | 下一步 |
|---|---|
没有 collision other= | 检查 cube 是否找到、事件连接是否建立、物理与碰撞事件是否启用 |
打印 reason=not_ball | 当前碰到别的对象,检查目标物与事件绑定对象 |
打印 reason=missing_owner | 检查已有玩法的最后触碰者记录,不能由诊断代码补造归属 |
| 原计分模块确认新分数,UI 却不变 | 按服务端快照 → 客户端接收 → 文本赋值逐段检查 |
常见错误
错误:不带 session 读取一大段混合日志
试玩中可以安全读取日志;错误在于不限定 play_session,把上一轮残留错误或编辑器日志算进本轮。运行中用 session 查询 marker 与错误,停止后再用同一 session 做最终复核。
错误:只看最后一行报错
真正的错误源通常在 stack trace 中间。要看调用栈中第一个指向自己脚本的文件和行号。
错误:没有复现步骤
“有 bug”不是测试用例。至少写清:启动后做什么、期待什么、实际看到什么。
错误:UI 只看 PASS,不看截图
UI 的目标是玩家看得见、点得到、读得懂。截图审查是 UI QA 的主力证据。
错误:修复后不重跑
修复必须用同一复现步骤重跑确认。只改代码不重跑,不能证明问题已解决。
练习任务
- 给用事件和计时器组织动作碰撞示例写一份期望日志清单。
- 用 editor-cli 完整跑一轮:只读 preflight、
play start --wait-ready、复现、按 session 查日志/截图、停止并确认 idle、最终复核。 - 给让 EUI 显示状态并响应按钮 HUD 写
test/testspec_hud.md,并列出至少 3 个截图检查项。 - 找一个可能失败的 DataStore 或资产加载调用,用
pcall包裹并打印失败日志。 - 给完成小游戏后选择扩展挑战 Capstone 写一份失败记录,至少覆盖“中途加入玩家 HUD 不更新”或“UI 节点缺失”其中一个问题。
- 从“跨模块失败复盘清单”中任选一个问题,按模板写入
evidence/editor-runs/。
本专题验收标准
- [ ] 我能写出功能的期望日志。
- [ ] 我会用
play_session隔离本轮日志,并在停止后做最终复核。 - [ ] 我能按 editor-cli 跑测闭环复现和验证问题。
- [ ] 我能用
pcall包住可能失败的操作,并保留失败日志。 - [ ] 我知道 EUI 测试必须结合 testspec、日志和截图。
- [ ] 我能把复杂问题拆成场景对象、server 判定、RemoteEvent、client 接收、UI 表现和存储几层定位。
- [ ] 我能把一次真实失败整理成可回归的证据记录。
本专题产物
- 一份期望日志清单,用来驱动一次真实 playtest。
- 一份失败记录,包含复现步骤、实际日志、定位层级、修复动作和重跑结论。
- 一份截图或 testspec 审查记录,说明 UI 是否可读、无遮挡、节点命名稳定。
本专题 API 对照
把结果带回小游戏
先确认本页“本次要看到”的现象,再把选中的功能接到已有模块。保留原有入口和清理逻辑,只迁入需要的部分;不要把多个试验入口拼在一起。
