feat(InBox): save zhihu article - Codex基于规则的Unity主线自动化测试实现
This commit is contained in:
470
InBox/zhihu_Codex基于规则的Unity主线自动化测试实现.md
Normal file
470
InBox/zhihu_Codex基于规则的Unity主线自动化测试实现.md
Normal file
@@ -0,0 +1,470 @@
|
||||
---
|
||||
source: 知乎专栏
|
||||
url: https://zhuanlan.zhihu.com/p/2047460516247888185?utm_psn=2051393077303103836
|
||||
created: 2026-06-20
|
||||
tags: [unity, 自动化测试, codex, 游戏开发]
|
||||
---
|
||||
|
||||
# Codex基于规则的 Unity 主线自动化测试实现
|
||||
|
||||
> 来源:[知乎专栏](https://zhuanlan.zhihu.com/p/2047460516247888185?utm_psn=2051393077303103836)
|
||||
|
||||
今天在水群中讨论到关于实现游戏自动化相关的内容是,我分析了《网易伏羲实验室-智能游戏测试.pdf》中的任务回归测试思路,然后也基于我们已经实现过的一套自动化思路,整理一套非 AI、规则驱动、可快速落地的 Unity 主线自动化测试实现方案。然后我用 codex 把自己的想法在基于上面的网易伏羲实验室-智能游戏测试的基础思路上,实现自己的构想。下面是 codex 给我的它认为可行的方案,基于此,我先分享出来,有没有时间在验证。
|
||||
1. 想法是否可行
|
||||
|
||||
可行。
|
||||
|
||||
PDF 中任务回归测试的核心思路是:
|
||||
|
||||
不一定从图像识别开始,可以直接从游戏引擎获取状态。
|
||||
将游戏状态抽象为任务 ID、任务步骤、角色状态、UI 状态、敌人状态、背包状态等。
|
||||
将游戏操作封装成动作,例如 RunToObj、ClickObj、UseItem、CastSkill、SubmitTask。
|
||||
根据当前状态提取合法动作,再执行动作。
|
||||
从游戏画面中提取当前可执行的游戏状态
|
||||
将画面中的各类游戏状态提取出来
|
||||
提取出来的各种游戏状态,扔到动作集合中
|
||||
对于某个动作,制定执行的规则,然后根据提取的数据,匹配动作是否满足执行条件
|
||||
|
||||
上面这几轮是《网易伏羲实验室-智能游戏测试》的核心思路。
|
||||
|
||||
基于此,我的想法是这个方案的简化版本:
|
||||
|
||||
判断当前界面 / 游戏状态属于哪类
|
||||
-> 主线、战斗、剧情、引导、Loading、死亡等
|
||||
-> 执行对应封装动作
|
||||
-> 用时间轮持续轮询
|
||||
大体的方案流程
|
||||
|
||||
这不需要强化学习,也不需要图像识别。只要项目代码能暴露当前状态和基础操作接口,就可以先跑通一条主线自动化流程。
|
||||
|
||||
|
||||
|
||||
|
||||
适合场景:
|
||||
|
||||
主线任务自动跑通。
|
||||
新手流程冒烟测试。
|
||||
每日构建基础回归。
|
||||
检查 NPC 不可达、任务卡住、对话断链、战斗无法结束等问题。
|
||||
|
||||
不适合场景:
|
||||
|
||||
无规则探索整个游戏。
|
||||
UI 遮挡、花屏、黑边等图像异常检测。
|
||||
复杂策略玩法验证。
|
||||
需要大量随机探索的开放世界测试。
|
||||
2. 最简架构
|
||||
|
||||
核心模块可以拆成四部分:
|
||||
|
||||
GameStateProvider:获取当前游戏状态
|
||||
ActionClassifier:判断当前应该执行哪类动作
|
||||
AutoAction:封装具体动作
|
||||
AutoRunner:时间轮轮询执行
|
||||
|
||||
整体流程:
|
||||
|
||||
每 0.5 秒轮询一次
|
||||
-> 读取当前 GameState
|
||||
-> 按优先级判断状态类型
|
||||
-> 执行对应动作
|
||||
-> 等待下一轮
|
||||
|
||||
|
||||
|
||||
|
||||
推荐状态优先级:
|
||||
|
||||
死亡 > Loading > 引导 > 剧情 / 对话 > 战斗 > 可提交任务 > 主线寻路 > 完成
|
||||
|
||||
原因是这些状态有明显的阻塞关系。比如角色死亡时,不应该继续寻路;Loading 时不应该点击 UI;剧情对话没点完时,也不能继续执行战斗或提交任务。
|
||||
|
||||
3. 状态抽象
|
||||
|
||||
最小状态可以这样设计:
|
||||
|
||||
public class GameState
|
||||
{
|
||||
public int questId;
|
||||
public int questStep;
|
||||
public bool isLoading;
|
||||
public bool isDead;
|
||||
public bool guideOpened;
|
||||
public bool dialogueOpened;
|
||||
public bool inBattle;
|
||||
public bool canSubmitQuest;
|
||||
public bool finished;
|
||||
public int enemyId;
|
||||
}
|
||||
|
||||
真实项目中可以继续扩展:
|
||||
|
||||
状态 示例
|
||||
任务状态 当前任务 ID、步骤 ID、目标 NPC、目标怪物
|
||||
角色状态 坐标、是否死亡、是否寻路中、是否战斗中
|
||||
UI 状态 是否打开引导、剧情、背包、奖励界面
|
||||
战斗状态 敌人 ID、距离、血量、技能 CD
|
||||
背包状态 是否有任务道具、是否需要使用道具
|
||||
场景状态 当前地图、是否 Loading、是否副本中
|
||||
4. 动作抽象
|
||||
|
||||
最小动作接口:
|
||||
|
||||
public interface IGameApi
|
||||
{
|
||||
GameState GetState();
|
||||
void ClickGuide();
|
||||
void ClickDialogueNext();
|
||||
void RunToQuestTarget();
|
||||
void CastSkill(int enemyId, int skillId);
|
||||
void SubmitQuest();
|
||||
void Revive();
|
||||
}
|
||||
|
||||
后续可以补充:
|
||||
|
||||
RunToNpc(npcId)
|
||||
RunToObj(objId)
|
||||
ClickObj(objId)
|
||||
UseItem(itemId)
|
||||
PickUpItem(itemId)
|
||||
EnterDungeon(dungeonId)
|
||||
ExitUI()
|
||||
ClaimReward()
|
||||
|
||||
动作封装的原则:
|
||||
|
||||
动作要尽量原子化。
|
||||
动作内部可以调用项目已有 C# 或 Lua 方法。
|
||||
动作执行后不要假设一定成功,下一轮仍然要重新读取状态。
|
||||
高风险动作,例如消耗货币、购买、强化,先不要纳入最小流程。
|
||||
5. Unity C# 最小可跑 Demo
|
||||
|
||||
新建脚本:
|
||||
|
||||
QuestAutoRunner.cs
|
||||
|
||||
挂到场景任意 GameObject 上即可运行。
|
||||
|
||||
下面代码使用 MockGameApi 模拟项目接口,可以直接在 Unity 中看到自动流程日志。
|
||||
|
||||
using System.Collections;
|
||||
using UnityEngine;
|
||||
|
||||
public class QuestAutoRunner : MonoBehaviour
|
||||
{
|
||||
private IGameApi game;
|
||||
private bool running;
|
||||
|
||||
private void Start()
|
||||
{
|
||||
game = new MockGameApi();
|
||||
running = true;
|
||||
StartCoroutine(TimeWheel());
|
||||
}
|
||||
|
||||
private IEnumerator TimeWheel()
|
||||
{
|
||||
while (running)
|
||||
{
|
||||
GameState state = game.GetState();
|
||||
ActionType type = Classify(state);
|
||||
|
||||
Debug.Log($"[AutoTest] state={type}, questStep={state.questStep}");
|
||||
|
||||
switch (type)
|
||||
{
|
||||
case ActionType.Guide:
|
||||
game.ClickGuide();
|
||||
break;
|
||||
|
||||
case ActionType.Dialogue:
|
||||
game.ClickDialogueNext();
|
||||
break;
|
||||
|
||||
case ActionType.Battle:
|
||||
game.CastSkill(state.enemyId, 1001);
|
||||
break;
|
||||
|
||||
case ActionType.MainQuest:
|
||||
game.RunToQuestTarget();
|
||||
break;
|
||||
|
||||
case ActionType.SubmitQuest:
|
||||
game.SubmitQuest();
|
||||
break;
|
||||
|
||||
case ActionType.Finished:
|
||||
Debug.Log("[AutoTest] 主线流程已跑完");
|
||||
running = false;
|
||||
break;
|
||||
|
||||
case ActionType.Loading:
|
||||
Debug.Log("[AutoTest] Loading 中,等待");
|
||||
break;
|
||||
|
||||
case ActionType.Dead:
|
||||
game.Revive();
|
||||
break;
|
||||
}
|
||||
|
||||
yield return new WaitForSeconds(0.5f);
|
||||
}
|
||||
}
|
||||
|
||||
private ActionType Classify(GameState s)
|
||||
{
|
||||
if (s.finished) return ActionType.Finished;
|
||||
if (s.isDead) return ActionType.Dead;
|
||||
if (s.isLoading) return ActionType.Loading;
|
||||
if (s.guideOpened) return ActionType.Guide;
|
||||
if (s.dialogueOpened) return ActionType.Dialogue;
|
||||
if (s.inBattle && s.enemyId > 0) return ActionType.Battle;
|
||||
if (s.canSubmitQuest) return ActionType.SubmitQuest;
|
||||
return ActionType.MainQuest;
|
||||
}
|
||||
}
|
||||
|
||||
public enum ActionType
|
||||
{
|
||||
Loading,
|
||||
Dead,
|
||||
Guide,
|
||||
Dialogue,
|
||||
Battle,
|
||||
MainQuest,
|
||||
SubmitQuest,
|
||||
Finished
|
||||
}
|
||||
|
||||
public class GameState
|
||||
{
|
||||
public int questId;
|
||||
public int questStep;
|
||||
public bool isLoading;
|
||||
public bool isDead;
|
||||
public bool guideOpened;
|
||||
public bool dialogueOpened;
|
||||
public bool inBattle;
|
||||
public bool canSubmitQuest;
|
||||
public bool finished;
|
||||
public int enemyId;
|
||||
}
|
||||
|
||||
public interface IGameApi
|
||||
{
|
||||
GameState GetState();
|
||||
void ClickGuide();
|
||||
void ClickDialogueNext();
|
||||
void RunToQuestTarget();
|
||||
void CastSkill(int enemyId, int skillId);
|
||||
void SubmitQuest();
|
||||
void Revive();
|
||||
}
|
||||
|
||||
public class MockGameApi : IGameApi
|
||||
{
|
||||
private GameState state = new GameState
|
||||
{
|
||||
questId = 10001,
|
||||
questStep = 0,
|
||||
guideOpened = true
|
||||
};
|
||||
|
||||
public GameState GetState()
|
||||
{
|
||||
return state;
|
||||
}
|
||||
|
||||
public void ClickGuide()
|
||||
{
|
||||
Debug.Log("[GameApi] 点击引导按钮");
|
||||
state.guideOpened = false;
|
||||
state.dialogueOpened = true;
|
||||
state.questStep = 1;
|
||||
}
|
||||
|
||||
public void ClickDialogueNext()
|
||||
{
|
||||
Debug.Log("[GameApi] 点击剧情对话下一步");
|
||||
state.dialogueOpened = false;
|
||||
state.inBattle = true;
|
||||
state.enemyId = 20001;
|
||||
state.questStep = 2;
|
||||
}
|
||||
|
||||
public void RunToQuestTarget()
|
||||
{
|
||||
Debug.Log("[GameApi] 自动寻路到主线目标");
|
||||
state.canSubmitQuest = true;
|
||||
state.questStep = 3;
|
||||
}
|
||||
|
||||
public void CastSkill(int enemyId, int skillId)
|
||||
{
|
||||
Debug.Log($"[GameApi] 对 enemy={enemyId} 释放 skill={skillId}");
|
||||
state.inBattle = false;
|
||||
state.enemyId = 0;
|
||||
state.canSubmitQuest = true;
|
||||
state.questStep = 3;
|
||||
}
|
||||
|
||||
public void SubmitQuest()
|
||||
{
|
||||
Debug.Log("[GameApi] 提交任务");
|
||||
state.canSubmitQuest = false;
|
||||
state.finished = true;
|
||||
state.questStep = 4;
|
||||
}
|
||||
|
||||
public void Revive()
|
||||
{
|
||||
Debug.Log("[GameApi] 复活角色");
|
||||
state.isDead = false;
|
||||
}
|
||||
}
|
||||
6. 运行效果
|
||||
|
||||
运行后 Console 会看到类似日志:
|
||||
|
||||
[AutoTest] state=Guide, questStep=0
|
||||
[GameApi] 点击引导按钮
|
||||
[AutoTest] state=Dialogue, questStep=1
|
||||
[GameApi] 点击剧情对话下一步
|
||||
[AutoTest] state=Battle, questStep=2
|
||||
[GameApi] 对 enemy=20001 释放 skill=1001
|
||||
[AutoTest] state=SubmitQuest, questStep=3
|
||||
[GameApi] 提交任务
|
||||
[AutoTest] state=Finished, questStep=4
|
||||
[AutoTest] 主线流程已跑完
|
||||
|
||||
这就完成了一个最小主线自动化闭环:
|
||||
|
||||
引导 -> 剧情 -> 战斗 -> 提交任务 -> 完成
|
||||
7. 接入真实项目
|
||||
|
||||
真实项目里,只需要把 MockGameApi 换成真实游戏接口。
|
||||
|
||||
示例:
|
||||
|
||||
public class RealGameApi : IGameApi
|
||||
{
|
||||
public GameState GetState()
|
||||
{
|
||||
return new GameState
|
||||
{
|
||||
questId = QuestManager.CurrentQuestId,
|
||||
questStep = QuestManager.CurrentStep,
|
||||
guideOpened = GuideManager.IsGuideShowing,
|
||||
dialogueOpened = DialogueManager.IsDialogueOpen,
|
||||
inBattle = BattleManager.InBattle,
|
||||
enemyId = BattleManager.GetNearestEnemyId(),
|
||||
canSubmitQuest = QuestManager.CanSubmit(),
|
||||
isLoading = SceneLoader.IsLoading,
|
||||
isDead = PlayerManager.IsDead
|
||||
};
|
||||
}
|
||||
|
||||
public void ClickGuide()
|
||||
{
|
||||
GuideManager.ClickCurrentGuide();
|
||||
}
|
||||
|
||||
public void ClickDialogueNext()
|
||||
{
|
||||
DialogueManager.ClickNext();
|
||||
}
|
||||
|
||||
public void RunToQuestTarget()
|
||||
{
|
||||
QuestManager.AutoRunToCurrentTarget();
|
||||
}
|
||||
|
||||
public void CastSkill(int enemyId, int skillId)
|
||||
{
|
||||
BattleManager.CastSkill(enemyId, skillId);
|
||||
}
|
||||
|
||||
public void SubmitQuest()
|
||||
{
|
||||
QuestManager.SubmitCurrentQuest();
|
||||
}
|
||||
|
||||
public void Revive()
|
||||
{
|
||||
PlayerManager.Revive();
|
||||
}
|
||||
}
|
||||
|
||||
然后在 Start 中替换:
|
||||
|
||||
game = new RealGameApi();
|
||||
8. 如果项目逻辑在 Lua
|
||||
|
||||
如果项目主要逻辑在 Lua,C# 可以只做时间轮和调度,动作内部调用 Lua。
|
||||
|
||||
示例:
|
||||
|
||||
public void RunToQuestTarget()
|
||||
{
|
||||
LuaManager.Call("AutoTest.RunToQuestTarget");
|
||||
}
|
||||
|
||||
public void ClickDialogueNext()
|
||||
{
|
||||
LuaManager.Call("AutoTest.ClickDialogueNext");
|
||||
}
|
||||
|
||||
public void SubmitQuest()
|
||||
{
|
||||
LuaManager.Call("AutoTest.SubmitQuest");
|
||||
}
|
||||
|
||||
Lua 侧再封装真实逻辑:
|
||||
|
||||
AutoTest = {}
|
||||
|
||||
function AutoTest.RunToQuestTarget()
|
||||
QuestManager:AutoRunToCurrentTarget()
|
||||
end
|
||||
|
||||
function AutoTest.ClickDialogueNext()
|
||||
DialogueManager:ClickNext()
|
||||
end
|
||||
|
||||
function AutoTest.SubmitQuest()
|
||||
QuestManager:SubmitCurrentQuest()
|
||||
end
|
||||
9. 最小版本先别做太复杂
|
||||
|
||||
第一版建议只支持这些动作:
|
||||
|
||||
动作 目的
|
||||
ClickGuide 跳过或点击引导
|
||||
ClickDialogueNext 推进剧情
|
||||
RunToQuestTarget 自动寻路到任务目标
|
||||
CastSkill 自动战斗
|
||||
SubmitQuest 提交任务
|
||||
Revive 死亡后复活
|
||||
|
||||
先跑通一条主线,再逐步补:
|
||||
|
||||
UseItem
|
||||
PickUpItem
|
||||
RunToNpc
|
||||
EnterDungeon
|
||||
ExitUI
|
||||
ClaimReward
|
||||
RunToTeleport
|
||||
|
||||
不要一开始就做复杂决策,否则会很快变成另一个难维护的自动化系统。
|
||||
|
||||
10. 总结
|
||||
|
||||
这套方案可以理解为“规则版智能任务测试”:
|
||||
|
||||
状态抽象 + 动作封装 + 优先级判断 + 时间轮轮询
|
||||
|
||||
它没有 AI,也不做复杂搜索,但胜在简单、稳定、容易接入项目代码。对于主线任务、新手流程和每日构建冒烟测试来说,已经足够有价值。
|
||||
|
||||
后续如果需要提升覆盖率,再考虑引入更复杂的动作选择、任务文本解析、路径搜索或 AI 策略。
|
||||
Reference in New Issue
Block a user