游戏AI行为决策——HTN(分层任务网络)
游戏AI行为决策——HTN
前言
Hierarchical Task Network(分层任务网络),简称HTN,与行为树、GOAP一样,也是一种行为决策方法。在《地平线:零之曙光》、《变形金刚:塞伯坦的陨落》中都有用它来制作游戏敌人的AI (我一个都没玩过捏。比起其它行为决策方法,HTN有个十分鲜明的特点:推演。
HTN允许我们把要做的事以高度复杂的「复合任务」来表示,而不是单单一个行为。什么意思呢?无论是有限状态机状态的转换,还是行为树节点的切换,大多时候只是从一个执行动作变为执行另一个动作。而HTN的一次规划,可以一口气规划出包含好几个动作的「复合任务」,你看到它做出的新动作,也不过是之前就计划好的一部分。
这么看来,好像还有点预知未来的味道呢,说得越来越玄乎了,直接来看看它的运行逻辑吧!
PS:据后续反馈,分享一个有应用HTN的项目的代码,HTN使用的主要部分在该项目的Script/Characters/Enemy部分,有需要的可以参考看看这个HTN的实际使用。
运行逻辑
HTN的整体结构框架如下:
别怕,看着复杂而已,相信你能够理解的:
1. 任务
首先,和其它行为决策方法一样,角色内部有存储一系列要做的事。在有限状态机中是「状态」,行为树中是「动作节点」,而HTN中是 「任务(Task)」。但要注意,HTN的「任务」十分特殊,它不只是单一的动作,可能包含多个动作,总的可以分为三种:「复合任务」、「方法」以及「原子任务」。
- 原子任务,是最简单的任务,只是单一的动作,像「奔跑」、「跳跃」等就算是原子任务。通常也不建议把一个原子任务设计得太复杂。
- 复合任务,是……“哦,我知道,一定是多个原子任务组合成的,对不对?”( ⓛ ω ⓛ *),很可惜,并不完全正确。复合任务是由多个「方法」组合而成的,而每次执行复合任务,只会选择组成它的众多「方法」之一来执行,就像行为树的选择节点一样。
- 方法,方法是HTN让角色行动丰富的关键,一个方法可以由多个「原子任务」或「复合任务」组合而成。在「方法」的帮助下,我们可以自然且清晰地构建丰富的行为。以「砍树」为例,可以构造成这个样子:
方法的执行,会逐一判断组成的「复合任务」和「原子任务」是否满足条件,只要有一个不满足,这个方法便会被放弃,它有点像行为树中的顺序节点。
这里要多说一嘴,「复合任务」和「方法」只会在HTN的规划阶段被执行。所谓「规划阶段」,就是根据「世界状态」来决定该做什么事,规划时会把要做的「复合任务」和「方法」统统分解成一个个「原子任务」。也就是说,最终角色实际执行的都是「原子任务」。
2. 世界状态
在游戏常用的决策行为算法中,只有GOAP和HTN有用到「世界状态」。其实这是更接近传统人工智能的设计方式(GOAP和HTN也确实是由传统人工智能转变来的),还是以「砍树」为例,想要让一个角色去砍树,他就得知道:哪里有树、哪里有电锯、电锯有多少油……这些 做事的前提 都可以归为「世界状态」的一员,反过来说,世界状态就是这类「前提条件」的集合,它们共同构成了HTN任务规划的基础。
在规划阶段,角色会复制一份「世界状态」的副本用于个人判断并选出可执行的任务,就好像是侦探拿着照片进行脑补推断一样。这个过程不会影响真正的「世界状态」。而在选出了可执行的任务后,就会将它分解成一系列「原子任务」挨个执行。有些(或者说大多数)「原子任务」执行完成后会对「世界状态」造成一定影响,比如开枪会减少弹药数,锯完树会减少树木数量等等。但要注意,这里的影响就不再是“脑补”的啦,而是真正改变「世界状态」的某些值。就像是部队制定完计划后,就开始正式行动了。
3. 总结
通过上述两大点,我想已经能大概弄清楚HTN的运行逻辑了吧(如果还是很懵,可以看看这个视频相关部分的介绍):根据世界状态来选择要执行的任务,再将选好的任务分解为一个个原子任务来执行,而原子任务执行完后又会影响世界状态。一旦分解出的原子任务都执行完了,又或者某个原子任务的执行条件突然不能满足了,就重新选择,重复这个步骤。这就是HTN大体的运行逻辑了。
代码实现
这次代码实现同样参考了Steve Rabin的《Game AI Pro》,相比之前我们实现的行为树,这次所要写的类不会太多(除去注释的话就更少了)。
1. 世界状态
世界状态实现的难点在于:
- 状态数据的类型是多种多样的,该用什么来统一保存?
- 状态数据会时时变化,如何保证存储的数据也会同步更新?
对于问题1,我们可以用 <string, object> 的字典来解决。毕竟C#中,object类是所有数据类型的老祖宗。那问题2呢,假设用这种字典存储了某个角色的血量,那这个角色就算血量变成0了,字典里存储的也只是刚存进去时的那个值而不是0。而且反过来,我们修改字典里的这个血量值,也不会影响实际角色的血量……除非,这些值能像属性一样……
这是可以做到的!但要用到两个字典,一个用来模仿属性的get,一个用来模仿属性的set。分别用值类型为System.Action< object > 和 System.Func< object >的字典就可以了。
到这里我得再说一下,如果对于上面这几段话中的一些名词你有些许疑惑的话,就该再学习一下C#啦( ̄、 ̄),否则你可能不能理解世界状态类的实现:
//世界状态只有一个即可,我们将其设为静态类
public static class HTNWorld
{
//读 世界状态的字典
private static readonly Dictionary<string, Func<object>> get_WorldState;
//写 世界状态的字典
private static readonly Dictionary<string, Action<object>> set_WorldState;
static HTNWorld()
{
get_WorldState = new Dictionary<string, Func<object>>();
set_WorldState = new Dictionary<string, Action<object>>();
}
//添加一个状态,需要传入状态名、读取函数和写入函数
public static void AddState(string key, Func<object> getter, Action<object> setter)
{
get_WorldState[key] = getter;
set_WorldState[key] = setter;
}
//根据状态名移除某个世界状态
public static void RemoveState(string key)
{
get_WorldState.Remove(key);
set_WorldState.Remove(key);
}
//修改某个状态的值
public static void UpdateState(string key, object value)
{
//就是通过写入字典修改的
set_WorldState[key].Invoke(value);
}
//读取某个状态的值,利用泛型,可以将获取的object转为指定的类型
public static T GetWorldState<T>(string key)
{
return (T)get_WorldState[key].Invoke();
}
//复制一份当前世界状态的值(这个主要是用在规划中)
public static Dictionary<string, object> CopyWorldState()
{
var copy = new Dictionary<string, object>();
foreach(var state in get_WorldState)
{
copy.Add(state.Key, state.Value.Invoke());
}
return copy;
}
}
2. 任务类接口
「复合任务」、「方法」和「原子任务」它们有共通之处,我们把这些共通之处以接口的形式提炼出来,可以简化我们在规划环节的代码逻辑。
//用于描述运行结果的枚举(如果有看上一篇行为树的话,也可以直接用行为树的EStatus)
public enum EStatus
{
Failure, Success, Running,
}
public interface IBaseTask
{
//判断是否满足条件
bool MetCondition(Dictionary<string, object> worldState);
//添加子任务
void AddNextTask(IBaseTask nextTask);
}
3. 原子任务
原子任务是一个抽象类,相当于行为树中的动作节点,用于开发者自定义的最小单元任务。一般就是像「开火」、「奔跑」之类的简单动作。值得注意的是,这里的条件判断和执行影响都要分两种情况,一种是规划时,一种是实际执行时,因为规划时我们使用的并不是真正的世界状态,而是一份模拟的世界状态副本。
public abstract class PrimitiveTask : IBaseTask
{
//原子任务不可以再分解为子任务,所以AddNextTask方法不必实现
void IBaseTask.AddNextTask(IBaseTask nextTask)
{
throw new System.NotImplementedException();
}
/// <summary>
/// 执行前判断条件是否满足,传入null时直接修改HTNWorld
/// </summary>
/// <param name="worldState">用于plan的世界状态副本</param>
public bool MetCondition(Dictionary<string, object> worldState = null)
{
if(worldState == null)//实际运行时
{
return MetCondition_OnRun();
}
else//模拟规划时,若能满足条件就直接进行Effect
{
if(MetCondition_OnPlan(worldState))
{
Effect_OnPlan(worldState);
return true;
}
return false;
}
}
protected virtual bool MetCondition_OnPlan(Dictionary<string, object> worldState)
{
return true;
}
protected virtual bool MetCondition_OnRun()
{
return true;
}
//任务的具体运行逻辑,交给具体类实现
public abstract EStatus Operator();
/// <summary>
/// 执行成功后的影响,传入null时直接修改HTNWorld
/// </summary>
/// <param name="worldState">用于plan的世界状态副本</param>
public void Effect(Dictionary<string, object> worldState = null)
{
Effect_OnRun();
}
protected virtual void Effect_OnPlan(Dictionary<string, object> worldState)
{
;
}
protected virtual void Effect_OnRun()
{
;
}
}
4. 方法
方法既可以添加「复合任务」又可以添加「原子任务」作组成的子任务,所以我们用IBaseTask列表来存储;而方法的满足与否,要看两个条件,具体看代码注释吧:
public class Method : IBaseTask
{
//子任务列表,可以是复合任务,也可以是原点任务
public List<IBaseTask> SubTask { get; private set; }
//方法的前提条件
private readonly Func<bool> condition;
public Method(Func<bool> condition)
{
SubTask = new List<IBaseTask>();
this.condition = condition;
}
//方法条件满足的判断=方法本身前提条件满足+所有子任务条件满足
public bool MetCondition(Dictionary<string, object> worldState = null)
{
/*
再复制一遍世界状态,用于追踪每个子任务的Effect。方法有多个子任务,
只要其中一个不满足条件,那整个方法不满足条件,之前子任务进行Effect也不算数
因此用tpWorld记录,待验证了方法满足条件后(所有子任务均满足条件),再复制回worldState
*/
var tpWorld = new Dictionary<string, object>(worldState);
if (condition())//方法自身的前提条件是否满足
{
for (int i = 0; i < SubTask.Count; ++i)
{
//一旦有一个子任务的条件不满足,这个方法就不满足了
if(!SubTask[i].MetCondition(tpWorld))
{
return false;
}
}
//最终满足条件后,再将各Effect导致的新世界状态(tpWorld)给worldState
worldState = tpWorld;
return true;//如果子任务全都满足了,那就成了!
}
return false;
}
//添加子任务
public void AddNextTask(IBaseTask nextTask)
{
SubTask.Add(nextTask);
}
}
5. 复合任务
复合任务和「方法」类似,只不过只能添加「方法」作为子任务。
public class CompoundTask : IBaseTask
{
//选中的方法
public Method ValidMethod { get; private set; }
//子任务(方法)列表
private readonly List<Method> methods;
public CompoundTask()
{
methods = new List<Method>();
}
public void AddNextTask(IBaseTask nextTask)
{
//要判断添加进来的是不是方法类,是的话才添加
if (nextTask is Method m)
{
methods.Add(m);
}
}
public bool MetCondition(Dictionary<string, object> worldState)
{
for (int i = 0; i < methods.Count; ++i)
{
//只要有一个方法满足前提条件就可以
if(methods[i].MetCondition(worldState))
{
//记录下这个满足的方法
ValidMethod = methods[i];
return true;
}
}
return false;
}
}
到这里,基本的组件类就全部完成了,对比行为树那章,代码量很少对吧?接下来就是有关构造的类了:
6. 规划器
规划器的要点在于对「复合任务」的分解,这里提一下,一个HTN会保证有一个复合任务做为根任务,就和行为树的根节点一样。分解也是由此开始:
public class HTNPlanner
{
//最终分解完成的所有原子任务存放的列表
public Stack<PrimitiveTask> FinalTasks { get; private set; }
//分解过程中,用来缓存被分解出的任务的栈,因为类型各异,故用IBaseTask类型
private readonly Stack<IBaseTask> taskOfProcess;
private readonly CompoundTask rootTask;//根任务
public HTNPlanner(CompoundTask rootTask)
{
this.rootTask = rootTask;
taskOfProcess = new Stack<IBaseTask>();
FinalTasks = new Stack<PrimitiveTask>();
}
//规划(核心)
public void Plan()
{
//先复制一份世界状态
var worldState = HTNWorld.CopyWorldState();
//将存储列表清空,避免上次计划结果的影响
FinalTasks.Clear();
//将根任务压进栈中,准备分解
taskOfProcess.Push(rootTask);
//只要栈还没空,就继续分解
while(taskOfProcess.Count > 0)
{
//拿出栈顶的元素
var task = taskOfProcess.Pop();
//如果这个元素是复合任务
if(task is CompoundTask cTask)
{
//判断是否可以执行
if(cTask.MetCondition(worldState))
{
/*如果可以执行,就肯定有可用的方法,
就将该方法的子任务都压入栈中,以便继续分解*/
var subTask = cTask.ValidMethod.SubTask;
foreach(var t in subTask)
{
taskOfProcess.Push(t);
}
/*通过上面的步骤我们知道,能被压进栈中的只有
复合任务和原子任务,方法本身并不会入栈*/
}
}
else //否则,这个元素就是原子任务
{
//将该元素转为原子任务,因为原本是IBaseTask类型
var pTask = task as PrimitiveTask;
//再将该原子任务加入存放分解完成的任务列表
FinalTasks.Push(pTask);
}
}
}
}
7. 执行器
执行器的关键在于如何确认一个原子任务是否执行完成,并且要在执行完成后产生影响并切换到下一个原子任务。
public class HTNPlanRunner
{
//当前运行状态
private EStatus curState;
//直接将规划器包含进来,方便重新规划
private readonly HTNPlanner planner;
//当前执行的原子任务
private PrimitiveTask curTask;
//标记「原子任务列表是否还有元素、能够继续」
private bool canContinue;
public HTNPlanRunner(HTNPlanner planner)
{
this.planner = planner;
curState = EStatus.Failure;
}
public void RunPlan()
{
//如果当前运行状态是失败(一开始默认失败)
if(curState == EStatus.Failure)
{
//就规划一次
planner.Plan();
}
//如果当前运行状态是成功,就表示当前任务完成了
if(curState == EStatus.Success)
{
//让当前原子任务造成影响
curTask.Effect();
}
/*如果当前状态不是「正在执行」,就取出新一个原子任务作为当前任务
无论失败还是成功,都要这么做。因为如果是失败,肯定在代码运行到这
之前,已经进行了一次规划,理应获取新规划出的任务来运行;如果是因
为成功,那也要取出新任务来运行*/
if(curState != EStatus.Running)
{
//用TryPop的返回结果判断规划器的FinalTasks是否为空
canContinue = planner.FinalTasks.TryPop(out curTask);
}
/*如果canContinue为false,那curTask会为null也视作失败(其实应该是「全部
完成」,但全部完成和失败是一样的,都要重新规划)。所以只有当canContinue && curTask.MetCondition()都满足时,才读取当前原子任务的运行状态,否则就失败。*/
curState = canContinue && curTask.MetCondition() ? curTask.Operator() : EStatus.Failure;
}
}
差不多所有东西都完成了,为了方便使用,我们和上篇写行为树时一样,也做一个构造器:
8. 构造器
构造器会自带规划器和执行器,并将任务的创建打包成函数。也和上篇行为树一样,用栈的方式描述构建过程,提供一定可视化。
public partial class HTNPlanBuilder
{
private HTNPlanner planner;
private HTNPlanRunner runner;
private readonly Stack<IBaseTask> taskStack;
public HTNPlanBuilder()
{
taskStack = new Stack<IBaseTask>();
}
private void AddTask(IBaseTask task)
{
if (planner != null)//当前计划器不为空
{
//将新任务作为构造栈顶元素的子任务
taskStack.Peek().AddNextTask(task);
}
else //如果计划器为空,意味着新任务是根任务,进行初始化
{
planner = new HTNPlanner(task as CompoundTask);
runner = new HTNPlanRunner(planner);
}
//如果新任务是原子任务,就不需要进栈了,因为原子任务不会有子任务
if (task is not PrimitiveTask)
{
taskStack.Push(task);
}
}
//剩下的代码都很简单,我相信能直接看得懂
public void RunPlan()
{
runner.RunPlan();
}
public HTNPlanBuilder Back()
{
taskStack.Pop();
return this;
}
public HTNPlanner End()
{
taskStack.Clear();
return planner;
}
public HTNPlanBuilder CompoundTask()
{
var task = new CompoundTask();
AddTask(task);
return this;
}
public HTNPlanBuilder Method(System.Func<bool> condition)
{
var task = new Method(condition);
AddTask(task);
return this;
}
}
我还是来简单画图,示意一下构建栈得运作过程吧:
- 加入一个复合节点0后:
- 往这个复0加一个方法作为一个子任务:
- 如果要向复0再加一个方法,就要调用Back函数,再添加:
总之,用Back调整栈顶的元素,我们可以自由地控制新任务作为谁的子任务。而且通过缩进可以较直观的看到HTN的整个结构,例如下面这样:
//节选自我某个小游戏里的一个小怪的行动
protected override void Start()
{
base.Start();
trigger = Para.HeathValue * 0.5f;
hTN.CompoundTask()
.Method(() => isHurt)
.Enemy_Hurt(this)
.Enemy_Die(this)
.Back()
.Method(() => curHp <= trigger)
.Enemy_Combo(this, 3)
.Enemy_Rest(this, "victory")
.Back()
.Method(() => HTNWorld.GetWorldState<float>("PlayerHp") > 0)
.Enemy_Check(this)
.Enemy_Track(this, PlayerTrans)
.Enemy_Atk(this)
.Back()
.Method(() => true)
.Enemy_Idle(this, 3f)
.End();
}
上述中的Enemy_Check、Enemy_Atk都是实际开发实现的具体原子行为。现在再来看,发现还是有问题的,HTN擅长规划,其实并不擅长时时决策,所以在实际开发时,建议与有限状态机结合。将受伤、死亡这类需要时时反馈的事交给状态机,HTN本身也可以放进一个状态,来进行复杂行为。而不是像我这样,将受伤、死亡也当成原子任务,因为这样做就要你为各个行为设计受伤中断,代码就会比较繁冗。
“状态机+其它”的复合决策模型并不罕见,GOAP也经常以这种形式出现。
最后分享一些设计原子任务的心得:
- 如果一个原子任务有一定的运行过程,可以用一个bool值在Operator函数内部判断是否完成了动作。
- 因为我们的世界状态是用字符串来读取的,如果我们想获取某个士兵的血量该怎么办?有很多士兵在,该如何区分?可以用Unity的GetInstanceID()获取唯一的ID+“血量”,组合成字符串来区分,其它类似情况同理。例如:
HTNWorld.AddState(GetInstanceID() + "currentHp", () => currentHp, (v) => currentHp = (float)v);
HTNWorld.AddState(GetInstanceID() + "IsHurt", () => isHurt, (v) => { isHurt = (bool)v; });
HTNWorld.AddState(GetInstanceID() + "IsDie", () => curHp <= 0, (v) => { });
能说的都说的差不多了,真正要了解HTN还是应当自己上手使用,鄙人也只是结合个人的学习和使用心得写出了这篇文章。有不足或不清楚的可以评论哈 (只是我不常看账号,可能不会回复
完毕!\ ( ̄︶ ̄*\ )
游戏AI行为决策——HTN(分层任务网络)的更多相关文章
- 游戏AI之决策结构—有限状态机/行为树(2)
目录 有限状态机 行为树 控制节点 条件节点 行为节点 装饰节点 总结 额外/细节/优化 游戏AI的决策部分是比较重要的部分,游戏程序的老前辈们留下了两种经过考验的用于AI决策的结构: 有限状态机 行 ...
- 王亮:游戏AI探索之旅——从alphago到moba游戏
欢迎大家前往腾讯云+社区,获取更多腾讯海量技术实践干货哦~ 本文由云加社区技术沙龙 发表于云+社区专栏 演讲嘉宾:王亮,腾讯AI高级研究员.2013年加入腾讯,从事大数据预测以及游戏AI研发工作.目前 ...
- 游戏AI之初步介绍(0)
目录 游戏AI是什么? 游戏AI和理论AI 智能的假象 (更新)游戏AI和机器学习 介绍一些游戏AI 4X游戏AI <求生之路>系列 角色扮演/沙盒游戏中的NPC 游戏AI 需要学些什么? ...
- 趣说游戏AI开发:对状态机的褒扬和批判
0x00 前言 因为临近年关工作繁忙,已经有一段时间没有更新博客了.到了元旦终于有时间来写点东西,既是积累也是分享.如题目所示,本文要来聊一聊在游戏开发中经常会涉及到的话题--游戏AI.设计游戏AI的 ...
- 使用行为树(Behavior Tree)实现游戏AI
——————————————————————— 谈到游戏AI,很明显智能体拥有的知识条目越多,便显得更智能,但维护庞大数量的知识条目是个噩梦:使用有限状态机(FSM),分层有限状态机(HFSM),决策 ...
- 如何建立一个完整的游戏AI
http://blog.friskit.me/2012/04/how-to-build-a-perfect-game-ai/ 人工智能(Artificial Intelligence)在游戏中使用已经 ...
- 对弈类游戏的人工智能(4)--游戏AI的落地
前言: 对弈类游戏的智能算法, 网上资料颇多, 大同小异. 然而书上得来终觉浅, 绝知此事要躬行. 结合了自己的工程实践, 简单汇总整理下. 一方面是对当年的经典<<PC游戏编程(人机博弈 ...
- 对弈类游戏的人工智能(5)--2048游戏AI的解读
前言: 闲得没事, 网上搜"游戏AI", 看到一篇<<2048游戏的最佳算法是?来看看AI版作者的回答>>的文章. 而这篇文章刚好和之前讲的对弈类游戏AI对 ...
- 游戏AI之路径规划(3)
目录 使用路径点(Way Point)作为节点 洪水填充算法创建路径点 使用导航网(Navigation Mesh)作为节点 区域分割 预计算 路径查询表 路径成本查询表 寻路的改进 平均帧运算 路径 ...
- 游戏AI之感知(1)
目录 感知 视觉感知 听力感知 其它感知 实现 感知 视觉感知 视觉感知是一种常见的感知. 在许多即时战略游戏或者类DOTA游戏里,一个单位的视觉感知往往是圆形范围的. 当然在其他大部分俯视角游戏里, ...
随机推荐
- MathType选项灰色无法点击或者word无法粘贴,治本解决方案
问题描述: mathtype安装过后,word中会出现mathtype的选项,但是这时mathtype中的选项是虚的,无法点击,而且此时word无法粘贴内容. 解决步骤: 1.打开word选项,点击加 ...
- 基于微信小程序+Springboot线上租房平台设计和实现【三端实现小程序+WEB响应式用户前端+后端管理】
感兴趣的可以先收藏起来,大家在毕设选题,项目以及论文编写等相关问题都可以给我加好友咨询 一. 前言介绍: 近年来,我国的社会经济迅猛发展,社会对于房屋租赁的需求也在不断增多.在房屋租赁企业中面对繁琐而 ...
- 魔百和s905l3a蓝牙系列 在armbian驱动并使用蓝牙!
文章已废弃,因为现在x大的dtb不需要驱动直接可以使用 之后我会重新写文章,感谢大家
- [oeasy]python0022_ python虚拟机_反编译_cpu架构_二进制字节码_汇编语言
程序本质 回忆上次内容 python3 的程序是一个 5.3M 的可执行文件 我们通过which命令找到这个python3.8的位置 将这个python3.8复制到我们的用户目录下 这个文件 ...
- ABC361-C题解
背景 昨天打比赛的时候查了中考分,心快停跳了. 题意 从 \(n\) 个数字中删除 \(k\) 个数字,问剩下的数字中极差的最小值. 分析 首先把这 \(n\) 个数字排序,然后问题就可以转化为求这 ...
- Vue export & export default & import 总结
Vue export & export default & import 总结 by:授客 QQ:1033553122 1. 实践环境 Vue 2.9.6 2. 简介 在ES6 ...
- 【JavaWeb】封装一个MVC框架
框架参考自: https://www.bilibili.com/video/BV1gV411r7ct 在老师的基础上添加了 1.POST参数处理 2.Tomcat8版本下中文乱码处理 3.可声明请求方 ...
- 【H5】08 图片
摘自: https://developer.mozilla.org/zh-CN/docs/Learn/HTML/Multimedia_and_embedding 在这份教程中,到目前为止我们已经看到了 ...
- 【Mybatis】Bonus02 补充
关于主键生成问题 Mybatis的主键生成是基于JDBC的使用主键[getGeneratedKeys()]方法 也就是说,必须要JDBC驱动的支持才行 @Test public void junitT ...
- 制作Linux系统的启动盘
我不是没有试过软碟通制作启动盘 在写入U盘的选项确认的时候,盘区不支持NTFS!? 另外什么大白菜和老毛桃这样的根本就不支持Linux镜像加载 还是百度的这个靠谱 https://jingyan.ba ...