书名:Lua游戏AI开发指南
ISBN:978-7-115-43947-5
本书由人民邮电出版社发行数字版。版权所有,侵权必究。
您购买的人民邮电出版社电子书仅供您个人使用,未经授权,不得以任何方式复制和传播本书内容。
我们愿意相信读者具有这样的良知和觉悟,与我们共同保护知识产权。
如果购买者有侵权行为,我们可能对该用户实施包括但不限于关闭该帐号等维权措施,并可能追究法律责任。
• 著 [美] David Young
译 王 磊
责任编辑 陈冀康
• 人民邮电出版社出版发行 北京市丰台区成寿寺路11号
邮编 100164 电子邮件 315@ptpress.com.cn
• 读者服务热线:(010)81055410
反盗版热线:(010)81055315
Copyright ©2014 Packt Publishing. First published in the English language under the title
Learning Game AI Programming with Lua
All rights reserved.
本书由英国Packt Publishing公司授权人民邮电出版社出版。未经出版者书面许可,对本书的任何部分不得以任何方式或任何手段复制和传播。
版权所有,侵权必究。
游戏人工智能(游戏AI)是游戏开发的一个重要方面,在很大程度上决定了游戏产品的逼真程度和对玩家的吸引力,由此也得到游戏开发工程师越来越多的重视。
本书通过把动画和运动系统直接集成到AI系统的方法,详细介绍了如何使用Lua语言及相关的技术和工具来编写和实现高质量的游戏AI。全书共9章,分别介绍了沙箱基础、创建和移动智能体、角色动画、意识体控制、导航、决策制定、知识表达、感知和策略等主题。
本书适合于游戏开发工程师,特别是想要详细了解游戏AI开发的程序员阅读参考。
David Young,是游戏行业中的专业软件工程师。他从美国航空航天局(NASA)的深空探索网络(Deep Space Network)开始职业生涯,之后加入NASA的喷气推进实验室参与“好奇号”任务。离开NASA之后,他在Riot Game为游戏“英雄联盟(Leagure of Legends)”开发研发平台。David正在攻读南加州大学的博士学位,专注于图形学中的实时毛发渲染和模拟的研究。
感谢我的妻子,没有她的支持我不可能完成本书。
Mitch Allen使用Corana SDK和Unity3D在iOS和Android平台上发布视频游戏。可以在http://mitchallen.com
找到这些应用。你也可以关注他的Twitter:@mitchallen
他曾经在Lotus、IBM、纽约时报、卡特彼勒、Dragon System、Dictaphone、Nuance Communication、雅虎、Intuit Inc和多家初创公司工作,从事网络和移动项目。
他的专长在软件开发、自动化测试、网站性能测试、SDK测试、API测试和技术写作。
他曾经是《Corona SDK Mobile Game Development Beginner’s Guide》一书的审阅者。
Predrag Končar是一位游戏开发者和多媒体研究员。他主要的兴趣领域在游戏以及技术和艺术的结合。他也对图像和音频处理以及交互设计感兴趣,在闲暇时还喜欢绘画。在过去的12年中,他在许多网络项目中从事技术和创意总监的工作,发布过超过40款网络游戏,参与过一些iOS应用的制作,也为Packt出版社做过《Corona SDK Mobile Game Development Beginner’s Guide》和《Corona SDK Application Design》的审阅工作。他对Unity、C#、ActionScript、Lua、MEL脚本、Maya和Python都有深入的理解。他还是MENSA和ACM SIGGRAPH的成员。
Bayard Randel是Canonical的一位软件工程师,目前从事Ubuntu Linux基础架构的工作。他居住在新西兰的Dunedin。他在技术方面的兴趣包括基于Lua的软件教育、网页开发、分布式系统和函数式编程。
Jayant C Varma是澳大利亚人,是一位作家、开发者、培训师和顾问,专注于移动开发和移动应用在商业中的使用。他是《Learn Lua for iOS Game》的作者,目前正为Packt出版社写作一本主题为Xcode 6的书籍,他同时还在为其他出版社出版写作另外一些书籍。他是OZ Apps公司的首席咨询师,也是该公司的创立者。这家公司专注于提供移动商业解决方案和开发相关的服务。
Jayant在IT行业工作多年,目睹了从8位计算机和磁带上的应用到当今的移动设备和应用商店的变迁。他总是会被新技术所吸引。他在其职业生涯中从事过许多领域的工作(也因此游历过多个国家),包括作为宝马销售公司的IT主管。他帮助他们在工厂中建立无线诊断系统,在服务台部署无触点的钥匙读取系统。他还实现了前台部门的自动化,包括在21世纪初部署的销售自动化、订单、预测和客户关系管理系统等。他还曾是James Cook大学的讲师、苹果大学联盟(Apple University Consortium,AUC)和澳大利亚计算机协会(Australian Computer Society,ACS)培训和研讨会的培训师之一。他曾为客户开发过移动和桌面应用,这些都可以在应用商店中获取。
他是Packt出版社的多本书籍的技术评阅者。可以通过他的个人主页http://www.oz-apps.com
与他联系。
游戏人工智能(Artificial Intelligence,AI)是决策制定和动画回放的结合体。经典AI或学术AI仅仅关注于找到正确的决策,游戏AI则负责在游戏运行期间做出大量的决策。将游戏AI与动画分开处理是常见的错误。本书通过把动画和运动系统直接集成到AI系统中来杜绝这一问题。决策制定和决策执行之间微妙的差异会改变AI程序员不得不关注的诸多方面。
游戏AI的另一个大问题是,不同类型的游戏对AI有不同的需求和实现策略。为了避免毫无重点的平铺直述,本书将只关注一种游戏类型,即第一和第三人称动作游戏。将AI限定在这个前提下,使得我们可以用一种深入的、基于教程的方法来创建一个完整的AI系统。
第1章 从构建沙箱开始 这一章会学习项目的组织方式,以及Lua脚本与C++代码的交互方法。在这里,你会发现AI沙箱是构建在一组开源技术之上的,我们首先会在这一框架中集成Lua、Ogre3D、OpenSteer和Bullet Physics。
第2章 创建并移动智能体 这一章我们先从最底层AI开始构建我们的示例程序,包括与世界的交互、原地转向以及移动。接下来,将使用OpenSteer库来实现智能体的搜索、躲避以及群体移动。
第3章 角色动画 这一章将继续构建AI沙箱,我们会介绍Ogre3D中的动画回放和Lua脚本的资源管理。我们会把用于控制动画片段、动画状态机和分层动画的底层结构整合到沙箱中。
第4章 意识体控制 这一章将整合智能体的动画处理和原地转向及移动。我们将实现两种处理意识和身体交互的方法。第一种方法集中于处理代理的决策和行动之间的延迟,而第二种方法则关注于代理行为的感知质量。
第5章 导航 这一章首先实现本地移动,然后研究长距离的移动和路径规划。我们将在AI沙箱中集成由Recast库提供的导航网格生成功能,以支持由Detour库提供的A*寻路算法。
第6章 决策制定 这一章在AI代理的决策制定中加入智能。本章将介绍使用Lua脚本来实现创建模块化和可重用的决策逻辑的各种数据结构和方法。我们将在沙箱中集成决策树、有限状态机以及行为树。
第7章 知识表达 这一章为单个智能体添加存储长期和短期信息的能力。我们给Lua暴露了一种集中式的存储和传播智能体关于世界信息的方法。
第8章 感知 这一章把查询世界信息的服务接口暴露给了智能体,还集成了一些可见信息和基于通信的信息的获取方式。
第9章 策略 这一章为沙箱暴露了关于环境的高层空间信息。本章通过一种基于格栅的世界表达方法,各种知识来源被合并到一起来提供关于环境的准确的策略视野,用于支持决策制定过程。
AI沙箱的解决方案和项目文件都是使用Premake实用程序自动生成的。每个示例应用都通过了Visual Studio 2008、2010、2012、2013的兼容性测试。由于沙箱代码的构建依赖大量第三方库,沙箱项目只能使用Visual Studio来构建。
沙箱Lua脚本的编写和调试使用一个开源的Lua集成开发环境——Decoda。虽然也可以使用其他的Lua脚本编辑器,但是沙箱应用原生暴露的信息使得我们可以在Decoda中无缝地调试Lua代码。
本书面向想学习创建游戏AI各方面知识的程序员。我们假定你了解C或者C++以及Lua程序语言,虽然阅读本书不一定需要有直接的C或者C++语言知识,但是想要扩展已有的功能则需要能够深入沙箱的调试。沙箱中混合使用了Lua的函数式编程和面向对象编程,这两种方式在书中都会有详细的说明。了解现代游戏引擎将有助于理解本书的内容,但并非必需。
本书使用了几种字体来区分不同的信息。下面会展示这些字体的示例并解释它们的含义。
文本中的代码、数据库表名称、文件夹名称、文件名、文件后缀、路径名、假的URL、用户输入和Twitter账户会显示成这样:“LuaScriptUtilities
头文件定义了向量类型的元表名称”。
代码块的格式如下:
local function CallCallbacks(callbacks, stateName, looping)
for index = 1, #callbacks do
callbacks[index].callback(
stateName, looping, callbacks[index].data);
end
end
新出现的概念和重要词汇会加粗显示。你在屏幕(例如菜单和对话框)上看到的单词会显示成这样:“打开Project菜单下的Settings菜单项”。
![]()
警告或者重要的注释在此显示。
![]()
提示和小技巧在此显示。
我们非常欢迎读者们的反馈。告诉我你对本书的想法,哪些是你喜欢的,哪些你不喜欢。读者反馈可以帮助我们改进我们的作品,进而更好地发挥它的效用。
如果你想给我们一些反馈,可以写信到feedback@packtpub.com
,请在邮件的正文中写明书籍的名称。
如果你对熟悉某项专题且有兴趣为之写一本书,或者为某本书贡献内容,请参考我们的作者指南页面:www.packtpub.com/authors
。
现在你已经拥有Packt图书了,我们还提供了许多资源让你能更好地利用你购买的图书。
对于所有从Packt出版社购买的书籍,你都可以使用你在www.packpub.com
网站的账号下载书籍的示例代码文件。如果你是从其他途径购买了本书,你可以访问http://www.packtpub.com/support
网站并注册你的信息,我们会将文件通过邮件发送给你。
我们还为你准备了一个包含本书用到的截图和图表的彩色图片的pdf文件。这些彩图可以帮助你更好地理解项目输出的变化。 这个文件可以在这里下载到:https://www.packtpub.com/sites/default/files/downloads/1336OS_ColoredImages.pdf
。
虽然我们尽力确保本书内容的正确性,但是错误仍然在所难免。如果你在书中发现任何错误,比如文本或代码的错误,我们将非常感激你能将它报告上来。这样不但可以让其他读者,也可以让我们提高后续版本的正确性。如果你发现任何错误,请访问http://www.packtpub.com/submit-errata
来报告给我们,在这个页面中选择书籍名称,点击勘误提交表格链接,然后输入错误的细节。一旦我们确认了你提交的错误,你的提交将被采用并上传到我们的网站中或者添加到该书的勘误表的已知错误列表中。
你还可以在https://www.packtpub.com/books/content/support
查看已经提交的勘误表,只需在搜索栏内输入本书名称进行搜索。相关信息会在勘误表一节中显示。
在互联网上以各种媒介盗版版权内容的问题仍然没有解决。在Packt,我们对版权和专利的保护非常重视。如果你发现我们的作品在互联网上以任意形式被盗版,请立即提供地址或网站名称以便我们寻求补救措施。
请通过邮件地址copyright@packtpub.com
将可疑盗版材料的网络链接发送给我们。
我们将非常感谢你能帮助保护我们的作者和我们提供有价值作品的能力。
如果你对本书有任何疑问,请通过邮箱questions@packtpub.com
与我们联系,我们会尽力为你解决。
本章主题:
AI沙箱是一个特别设计的软件框架,它摆脱了应用管理、资源处置、内存管理、Lua绑定这些无聊的工作,让你能够立即着手应用Lua进行AI编程。虽然这个沙箱承担了一个小型游戏引擎的工作,但是它的内部结构是完全开放的。本章会详尽描述和解析它的内部代码,以便你在必要时对其进行扩展来获得更多的功能。
我们在设计AI沙箱时使用了一组预先编译好的开放源代码库,用以支持Lua代码实现的AI的快速原型开发和调试。C++代码维护和管理AI数据,而Lua脚本则管理AI的决策逻辑。数据和逻辑的分离使得Lua逻辑可以进行快速迭代,而不用担心当前AI状态的崩溃或失效。
在开始构建AI之前,本章将介绍沙箱的内部结构和设置。由于所有的AI脚本都在Lua端,我们很有必要理解Lua如何与沙箱交互,以及与Lua相对应的C++代码的功能。
沙箱项目的文件组织在共享媒体资源的同时可以轻松支持每个独立的项目。一个叫demo_framework
的项目提供了本书用到的所有通用代码。各章C++代码的区别在于设置的待运行的Lua沙箱脚本不同。虽然从本书一开始,整个沙箱框架就是可用的,但在每章中都会继续添加一些新的功能。
bin
x32/debug
x32/release
x64/debug
x64/release
build (generated folders)
project
Learning Game AI Programming.sln
decoda
lib(generated folders)
x32/debug
x32/release
x64/debug
x64/release
media
animations
fonts
materials
models
packs
particle
programs
shaders
textures
premake
premake
SandboxDemos
src
chapter_1_movement (example)
include
script
src
ogre3d (example)
include
src
...
tools
decoda
premake
vs2008.bat
vs2010.bat
vs2012.bat
vs2013.bat
![]()
下载示例代码
对于所有从Packt出版社购买的书籍,你都可以使用你在
www.packpub.com
网站的账号下载书籍的示例代码文件。如果你是从其他途径购买了本书,可以访问http://www.packtpub.com/support
网站并注册你的信息,我们会将文件通过邮件发送给你。
下面我们来逐一检查每个文件夹的内容。
bin
文件夹包含所有的可执行程序。解决方案无需重新编译就可以同时生成沙箱的32位和64位版本。
![]()
虽然沙箱可以编译成32位和64位应用,但只有32位版本能够支持在Decoda中进行Lua脚本调试。
build
文件夹包含Visual Studio解决方案文件。build/projects
文件夹包含了每个Visual Studio项目和解决方案。可以随时删除build
文件夹,并使用vs2008.bat
, vs2010.bat
, vs2012.bat
或者vs2013.bat
批处理文件来重新生成项目及工程文件。应该避免直接修改Visual Studio项目文件,因为重新生成解决方案文件时会覆盖掉本地所做的修改。deocda
文件夹包含了对应各章节示例程序的Decoda IDE项目文件。这些项目文件不是从构建脚本文件生成的。lib
文件夹是静态库编译时的中间输出文件夹。删除这个文件夹是安全的,因为Visual Studio在下次构建沙箱时也会生成任何缺失的库文件。media
文件夹包含了各章示例程序所共享的所有资源。沙箱使用的资源同时以零散文件和压缩包的方式存在。premake
文件夹包含了用于生成沙箱解决方案和项目文件的构建脚本。对Visual Studio解决方案或者项目的刻意修改都应该放在Premake脚本中。
![]()
Premake脚本会检测项目文件夹结构中添加的任何C++和Lua文件。在添加了新的Lua脚本或者C++文件后,只需要重新运行构建脚本来更新Visual Studio解决方案。
src
文件夹包含了各个开源库的源代码和沙箱源代码。沙箱解决方案中的每个项目都有相应的src文件夹,其中的头文件和源代码文件是分开的。每个章节示例都有一个额外的script文件夹来存放各自的Lua脚本。
![]()
每个开源库都包含一个
VERSION.txt
文件和一个LICENSE.txt
文件。前者声明了该开源库的版本号,后者则声明了用户必须遵守的许可协议。
tools
文件夹包含Decoda IDE的安装文件以及用于创建VisualStudio解决方案的Premake工具程序。Premake是一个基于Lua的构建配置工具。AI沙箱使用Premake为不同的版本的Visual Studio和配置项生成解决方案,以同时支持多个版本的Visual Studio。
执行vs2008.bat
批处理文件就能在build
文件夹下生成一个Visual Studio 2008版本的解决方案。相应地,vs2010.bat
和vs2012.bat
批处理文件生成沙箱的Visual Studio 2010和2012版本的解决方案。
编译沙箱只依赖DirectX SDK。你需要确保系统上安装了DirectX SDK,如果没有的话,可以到微软网站上免费下载,网址是http://www.microsoft.com/en-us/download/details.aspx?id=6812
。
沙箱的解决方案文件是build/Leaning
Game
AI
Programming.sln
,它可以通过运行vs2008.bat
、 VS2010.bat
、 VS2012.bat
、 VS2013.bat
这几个批处理文件中的一个来生成。沙箱的首次构建需要编译使用的所有开源库,这将花费几分钟的时间,这之后的编译将会快很多。
沙箱使用的是Lua版本是5.1.5,而不是更新的5.2.x。因为最新版的Decoda IDE的调试器只能支持5.1.x版本的Lua。你可以将沙箱中的Lua替换为更新的版本,但这会使Lua调试器无法工作。
本书编写时Ogre3D图形库的最新稳定版是Ogre3D 1.9.0。沙箱只使用了Ogre3D库的最精简配置,所以它只需要依赖最少量的库来进行图像处理、字体处理、ZIP压缩和对DirectX图形的支持。
Ogre3D需要的依赖项有:
沙箱构建使用的是9.29.1962版本的DirectX SDK,但任何更新的DirectX SDK版本都可以使用。此外还有其他一些开源库用于图形的调试、输出处理、物理模拟、转向模拟和寻路算法等,列举如下。
Premake dev e7a41f90fb80是Premake的一个开发版本,基于Premake的开发分支。沙箱的Premake配置文件使用了一些只在开发分支才有的最新特性。
Decode 1.6 build 1034 通过检查沙箱的调试符号文件来提供Lua代码的无缝调试功能。
Decode是一个专业的Lua集成开发环境(IDE),它由Unknown Worlds Entertainment公司开源发布,这个公司也是Natural Selection 2的制造商。网址:http://unknownworlds.com/decoda/
。
Decode采用一种独特的方式来进行Lua脚本调试,这种与应用程序的集成方式比其他的Lua调试器要优秀很多。其他调试器普遍使用基于网络的方法,这就要求应用内部的Lua虚拟机必须配置为支持调试的。Decoda使用由Visual Studio生成的调试符号文件来支持Lua调试。这种方法的最大优势在于,不需要对应用程序进行任何修改就能支持Lua调试。Decoda的这一重要差异使得调试沙箱中的Lua虚拟机调试变得容易。
只需打开本章的Decoda项目(decoda/chapter\_1\_movement.deproj
)。每个沙箱Decoda项目都已经设置好以运行对应的沙箱可执行程序。同时按下Ctrl+F5键,或者点击Debug菜单下的“Start Without Debugging”选项就可以在Decoda中运行沙箱了。
创建一个新的Decoda项目,只需简单几步来为Decoda设置正确的可执行程序和调试符号。
以下几个步骤就可以配置一个新的Decoda项目。
1.打开Project菜单下的Settings菜单,如图1-1所示。
图1-1 访问Decoda项目设置
2.设置Command文本框来指向新的沙箱可执行程序。
3.设置Working Directory和Symbols Directory指向可执行程序所在目录。
![]()
当使用调试符号时,Decoda只能调试32位应用程序。AI沙箱为Release和Debug构建配置都生成调试符号。
项目设置界面如图1-2所示。
图1-2 设置Decode调试可执行程序和工作路径
在Decode中按下F5键,可以启动沙箱的Lua脚本调试。F5键将启动沙箱应用程序,并把Decoda附加到运行的进程上。从Debug菜单选择Break,或者在执行的脚本中设置一个断点,就可以暂停沙箱来进行调试,如图1-3所示。
图1-3 在Decoda中调试Lua脚本
如果熟悉Visual Studio的Watch窗口,你会发现Decoda的Watch窗口非常相似,如图1-4所示。调试时可以在Watch窗口中键入任意变量来监视该变量的值。Decoda也允许你在Watch窗口键入任意的Lua语句。这些Lua语句会在调试器的当前范围内执行。
图1-4 在DecodaWatch窗口中查看Lua局部变量
Call Stack窗口显示当前执行的Lua调用堆栈,如图1-5所示。在任意一行上双击可以跳转到调用处。Watch窗口将根据调用堆栈的当前范围自动刷新。
图1-5 在Decoda Call Stack窗口分析Lua调用堆栈
Virtual Machines窗口显示在沙箱中运行的每个Lua虚拟机,如图1-6所示。沙箱应用有一个单独的虚拟机,每个运行中的智能体也有一个单独的虚拟机。
图1-6 在Decoda中调试多个Lua虚拟机
有几种方法可以同时调试C++沙箱和运行中的Lua脚本。
如果沙箱是从Decoda启动的,可以通过Visual Studio的Debug菜单下的Attach To Process选项附加到运行中的进程,如图1-7所示。
图1-7 从Decoda启动时同时调试C++沙箱
Decode也可以通过Debug菜单附加到一个运行中进程。如果沙箱是通过Visual Studio运行的,你可以在任何时候把Decoda附加到它上面,方法与Visual Studio附加到沙箱上一致,如图1-8所示。
图1-8 从Visual Studio启动沙箱时同时调试Lua脚本
可以在Decoda启动沙箱时同时附加Decoda和Visual Studio,只需要在Debug菜单下选择Attach System Debugger。从Decoda运行应用程序时,Windows会提示你立即附加一个即时(Just-In-Time,JIT)调试器。
![]()
如果你安装的Visual Studio在可选项中没有显示即时调试器选项,可以从菜单Tools|Options|Debugging|Just- In-Time来启动原生应用的JIT调试。
图1-9显示了用来附加系统调试器的Debug选项。
图1-9 启动沙箱时自动附加Decoda调试器
为了让Decoda知道将哪个Lua文件联系到当前正在运行的Lua脚本上,需要使用Lua API函数luaL_loadbuffer
来加载这个Lua文件,文件名作为chunkName
参数传入。luaL_loadbuffer
函数是在lauxlib.h
文件中提供的一个Lua辅助函数。
lauxlib.h
int luaL_loadbuffer(
lua_State* luaVM, const char* buffer,
size_t bufferSize, const char* chunkName);
Lua虚拟机是由一个定义在lstate.h
头文件中的lua_State
结构来代表的。这个结构完全是自包含的,不使用任何全局数据,因此非常适合支持多线程应用程序。
lstate.h
struct lua_State;
沙箱同时运行多个Lua虚拟机。一个主虚拟机被分配给沙箱自己使用,而每个构造出来的智能体都会运行它自己的一个虚拟机。使用多个独立的虚拟机会消耗沙箱的性能和内存,但也使得实时遍历每个智能体的Lua脚本成为可能。
Lua是一种弱类型语言,它的函数能接收任意数量的参数,也能有任意个数的返回值,因此它和C++代码的交互就比较棘手。
为了和强类型的C++语言交互,Lua使用一个先进先出的堆栈来发送和接收Lua虚拟机中的数据。例如,当C++想调用一个Lua函数,则将Lua函数以及调用参数推入堆栈中,然后由虚拟机来执行这个函数。函数的任何返回值也会被推入堆栈中,交由调用的C++代码处理。
在Lua代码中调用C++代码的过程正好相反。首先,Lua会将C++函数推入栈中,接着推入发送给函数的参数。代码执行结束后,返回值会被推入栈中,以便Lua脚本处理。
Lua堆栈数据可以从下至上或从上至下进行访问。栈顶元素可以用索引值-1
来访问,栈底元素的索引值是1
,相应地其他元素的索引则是-2
、-3
、2
、3
等,如图1-10所示。
图1-10 Lua调用堆栈的索引结构
![]()
Lua和大多数编程语言的一个差别在于它是从1而不是0开始索引。
在Lua中有8种基础类型:nil(空)、Boolean(布尔)、number(数字)、string(字符串)、function(函数)、userdata(自定义类型)、thread(线程)和table(表)。
Lua中的元表是一个表类型,利用元表可以用自定义函数来覆盖已有的通用操作,例如加、减、赋值等待。沙箱中大量使用了元表来为由C++管理的自定义类型提供通用操作。
Lua中获取元表的函数是getmetatable
,函数参数就是要获取其元表的对象:
metatable getmetatable(object);
Lua中设置元表函数是setmetatable
,两个参数分别是要设置其元表的对象和新的元表:
nil setmetatable(object, metatable);
![]()
由于沙箱在自定义类型上大量使用了元表,你总可以使用
getmetatable
函数来获取自定义类型的元表,以查看该自定义类型支持的操作。
元方法是元表中的特殊表项,它会在Lua需要某个被覆盖的操作时被调用。通常,所有的Lua元方法函数名都以两个下划线作为开头。
在元表中添加元方法的方法是,把函数赋值给由方法名索引的元表表项。例如:
local metatable = getmetatable(table);
metatable.__add = function(left, right)
return left.value + right.value;
end
setmetatable(table, metatable);
自定义类型是一块任意的数据,它的生命周期是由Lua的垃圾收集器管理的。每当代码创建一个自定义类型对象并推入Lua时,lua_newuserdata
函数会请求一块由Lua管理的内存。
lua.h
void* lua_newuserdata(lua_State* luaVM, size_t userdataSize);
虽然沙箱大量使用了自定义类型,它使用的内存的构造和析构仍然是在沙箱内部处理的。这使得运行Lua脚本时不必担心Lua内部的内存管理。例如,当通过自定义类型将一个智能体暴露给Lua时,Lua管理的只是一个指向这个智能体的指针。Lua可以自由地对这个指针进行垃圾收集,但对智能体本身不会造成任何影响。
沙箱通过Sandbox_Initialize
、Sandbox_Cleanup
和Sandbox_Update
这3个预定义的全局Lua函数来连接到Lua脚本上。在将相应的Lua脚本首次附加到沙箱时会调用Sandbox_Initialize
函数。沙箱在每次更新循环时会调用Lua脚本中的Sandbox_Update
函数。当沙箱被销毁或者重新加载时, Sandbox_Cleanup
函数将被调用以执行任何脚本端的清理工作。
为了让C++调用Lua函数,该函数需要能在Lua中获取到并推入堆栈中。然后将函数参数推入栈顶,接着就可以调用lua_pcall
函数来执行Lua函数了。通过lua_pcall
函数可以指定Lua函数接收的参数个数、返回值个数和错误处理的方式。
lua.h
int lua_pcall(
lua_State* luaVM, int numberOfArguments,
int numberOfResults, int errorFunction);
例如,AgentUtiltities
类使用下面的方式来调用Agent_Initialize Lua
脚本函数:
Agent.lua
function Agent_Initialize(agent)
...
end
首先,这个Lua函数在Lua中通过名字获取并推入堆栈中。接下来,将智能体本身作为Agent_Initalize
函数的唯一参数推入堆栈。最后,调用lua_pcall
函数会执行这个脚本函数并检查它是否执行成功,如果未成功,则沙箱会生成一个断言。
AgentUtilities.cpp
void AgentUtilities::Initialize(Agent* const agent)
{
// Retrieves the lua virtual machine the agent script is
// running on. lua_State* luaVM = agent->GetLuaVM();
lua_getglobal(luaVM, "Agent_Initialize");
// Agent_Initialize accepts one parameter, an Agent.
AgentUtilities::PushAgent(luaVM, agent);
// Execute the Agent_Initialize function and check for
// success.
if (lua_pcall(luaVM, 1, 0, 0) != 0)
{
assert(false);
}
}
可以通过函数绑定过程将C++函数暴露给Lua。任何暴露给Lua的被绑定的函数可以作为一个全局函数来访问,或者通过一个包来访问。Lua中的包类似于C++中的名空间,它是使用Lua中的一个全局表来实现的。
任何暴露给Lua的函数都必须符合lua_Cfunction
声明。一个lua_CFunction
声明接受一个Lua虚拟机作为参数,并返回被推入Lua堆栈中的返回值的个数。
lua.h
typedef int (*lua_CFunction) (lua_State *L);
例如,沙箱中暴露的C++函数GetRadius
在LuaScriptBidings.h
文件中是这样声明的:
LuaScriptBindings.h
int Lua_Script_AgentGetRadius(lua_State* luaVM);
函数的实际实现定义在LuaScriptBindings.cpp
文件中,它包含了从堆栈中获取参数和将数据推入堆栈的代码。GetRadius
函数需要一个智能体指针作为第一个也是唯一一个参数,然后使用AgentUtilities
类中的一个辅助函数从堆栈中获取这个指针引用的自定义数据。由一个额外的辅助函数来实际计算智能体的半径然后把结果推入到堆栈中:
LuaScriptBindings.cpp
int Lua_Script_AgentGetRadius(lua_State* luaVM)
{
if (lua_gettop(luaVM) == 1)
{
Agent* const agent = AgentUtilities::GetAgent(
luaVM, 1);
return AgentUtilities::PushRadius(luaVM, agent);
}
return 0;
}
为了完成绑定,我们定义一个常数数组来把Lua中的函数映射到实际调用的C函数上。这个映射数组必须以一个空的lua_Reg
类型结构来结束。当处理函数映射时,Lua使用这个空的LuaL_Reg
类型结构作为终止符:
AgentUtilities.cpp
const luaL_Reg AgentFunctions[] =
{
{ "GetRadius", Lua_Script_AgentGetRadius },
{ NULL, NULL }
};
函数绑定到Lua虚拟机实际发生在luaL_register
辅助函数中。这个注册函数将表中的函数名称绑定到它们对应的C回调函数。同时还可以指定一个包名称并在映射时关联到每个函数上。
AgentUtilities.cpp
void AgentUtilities::BindVMFunctions(lua_State* const luaVM)
{
luaL_register(luaVM, "Agent", AgentFunctions);
}
![]()
如果传入
NULL
作为包名,Lua会查询位于Lua堆栈顶部的表。Lua会将C函数添加到这个堆栈顶部的表中。
沙箱使用自定义类型来传递智能体和沙箱本身,同时也用来添加一些基础类型。这些基础类型完全由Lua的垃圾收集器控制。
沙箱中的向量类型就是一个完全由Lua控制的自定义类型的例子。向量基本上只是包含3个数值的一个结构体,因此让Lua来管理它的创建和销毁是正确的选择。与Lua向量交互的C++代码不能够持有Lua返回的内存地址,而是拷贝数据并保存在本地。
把向量实现为Lua的一个基础类型意味着需要支持用户可能对向量进行的所有操作。向量应该支持加、减、乘、索引以及所有其他Lua支持的基础操作符。
为实现这些操作,向量数据类型使用元方法来支持基础的数学运算符,并用点操作符来支持“.x”、“.y”和“.z”这样的语法。
LuaScriptUtilities.cpp
const luaL_Reg LuaVector3Metatable[] =
{
{ "__add", Lua_Script_Vector3Add },
{ "__div", Lua_Script_Vector3Divide },
{ "__eq", Lua_Script_Vector3Equal },
{ "__index", Lua_Script_Vector3Index },
{ "__mul", Lua_Script_Vector3Multiply },
{ "__newindex", Lua_Script_Vector3NewIndex },
{ "__sub", Lua_Script_Vector3Subtract },
{ "__tostring", Lua_Script_Vector3ToString },
{ "__towatch", Lua_Script_Vector3ToWatch },
{ "__unm", Lua_Script_Vector3Negation },
{ NULL, NULL }
};
LuaScriptUtilities.h
#define LUA_VECTOR3_METATABLE "Vector3Type"
为了让代码支持这些功能,Lua需要在分配内存时知道它正在操作的自定义类型的具体类型。LuaScriptUtilities
头文件定义了向量类型的元表名:
LuaScriptUtilities.cpp
void LuaScriptUtilities::BindVMFunctions(lua_State* const luaVM)
{
...
luaL_newmetatable(luaVM, LUA_VECTOR3_METATABLE);
luaL_register(luaVM, NULL, LuaVector3Metatable);
...
}
当把C++函数绑定到Lua虚拟机时,需要一个额外的步骤来支持向量。LuaL_newmetatable
函数会创建一个新的元表,并把它关联到向量自定义类型上。在新建元表并推入Lua堆栈之后,调用luaL_register
函数来把列在luaVector3Metatable
中的元方法加入到元表中:
LuaScriptUtilities.cpp
int LuaScriptUtilities::PushVector3(
lua_State* const luaVM, const Ogre::Vector3& vector)
{
const size_t vectorSize = sizeof(Ogre::Vector3);
Ogre::Vector3* const scriptType =
static_cast<Ogre::Vector3*>(
lua_newuserdata(luaVM, vectorSize));
*scriptType = vector;
luaL_getmetatable(luaVM, LUA_VECTOR3_METATABLE);
lua_setmetatable(luaVM, -2);
return 1;
}
每当在Lua中创建一个向量时,lua_newuserdata
函数会分配所需内存,Lua会获取向量的元表并关联到这个自定义类型上。这使得Lua知道自定义类型的具体类型以及它支持的所有函数。
Demo框架的设计遵循了沙箱中许多其他类的设计,包含了简单的初始化、更新和清理的功能。
BaseApplication.h
头文件的类概览图如图1-11所示。
图1-11 BaseApplication抽象类概览图
BaseApplication
类的主要功能有配置应用程序窗口、处理输入命令以及和配置并处理Ogre3D。BaseApplication
类还包含Cleanup
、Draw
、Initialize
和Update
函数,但这些函数的实现都是空的。BaseApplication
类的继承类可以重载这些函数以插入自定义的逻辑。
Initialize
函数会在应用程序启动时在Ogre初始化之后调用一次。Cleanup
函数会在应用程序准备关闭时,在Ogre清理之前调用。Draw
函数会在图形处理单元(Graphics Processing Unit,GPU)渲染当前应用程序帧之前调用。Update
函数的调用紧跟在GPU将所有处理当前帧的渲染调用列队之后。这使得GPU可以和CPU开始准备下一个渲染帧时同步工作。Ogre3D处理沙箱的全局更新循环和窗口管理。BaseApplication
实现了Ogre:: FrameListener
接口以实现沙箱的Update和Draw调用。
OgreFrameListener.h
头文件的类概览图如图1-12所示。
图1-12 Ogre3D FrameListener接口函数
BaseApplication实现的另一个接口是Ogre::WindowEventListener
,它使沙箱能够接受特定的窗口事件,比如窗口移动、尺寸调整关闭前关闭后以及窗口焦点变化等。
OgreWindowEventListener.h
头文件中的类概览图如图1-13所示。
图1-13 Ogre3D WindowEventListener接口函数
![]()
这两个接口的函数都是在Ogre的主线程中调用的,因此在处理事件时不会存在竞争条件。
面向对象输入系统(Object-Oriented Input System,OIS)库负责处理沙箱中所有的键盘和鼠标事件。BaseApplication
类实现了OIS系统中的两个接口来接收来自按键点击、鼠标点击和鼠标移动的事件。BaseApplication
一旦接收到这些事件,就把它们依次转发到沙箱中。
OISKeyboard.h
头文件的类概览图如图1-14所示。
图1-14 Ogre3D KeyListener接口函数
OISMouse.h
头文件中的类概览图如图1-15所示。
图1-15 Ogre3D Mouse Listener接口函数
SandboxApplication
类是AI沙箱的主应用程序类,它继承自BaseApplication
基类,实现了基类中的Cleanup
、Draw
、Initialize
和Update
函数。CreateSandbox
函数创建一个沙箱实例,然后把它关联到一个由文件名参数指定的Lua脚本上。
SandboxApplication.h
头文件中的类概览图如图1-16所示。
图1-16 SandboxApplication继承了BaseApplication抽象类
沙箱类封装了沙箱数据并处理对Lua沙箱脚本的调用。构造沙箱对象需要一个SceneNode对象来定位它在游戏世界中的位置。沙箱的SceneNode实例也是其他所有用于渲染的几何体SceneNode的父节点,也包含沙箱中的AI智能体类。
Sandbox.h
头文件中的类概览图如图1-17所示。
图1-17 Sandbox类概览图
代理类封装了代理数据,还能执行通过LoadScript函数绑定的Lua脚本。构造agent实例时需要一个SceneNode对象,用来维持代理对象在游戏世界中的方向和位置。
Agent.h
头文件的类概览图如图1-18所示。
图1-18 Agent类概览图
AI沙箱使用了很多工具模式来分离逻辑和数据。沙箱和代理类各自保存它们自己相关的数据,和Lua虚拟机交互的数据的处理则由工具类来完成。
比如,AgentUtilities
类处理Lua AI代理执行的所有动作,而SandboxUtilities
类处理Lua沙箱执行的所有动作。
任何通用功能或与Lua虚拟机的其他各种交互都由LuaScriptUtilities
类来处理。
LuaScriptBindings.h
头文件描述了沙箱暴露给Lua虚拟机的所有C++函数。你可以把这个文件作为AI沙箱的应用程序接口(API)的参考文档。每个函数都有功能描述、函数参数、返回值和Lua代码调用示例。
至此我们已经查看了AI沙箱的基本结构以及代码和Lua虚拟机的交互方式。在我们开始构建自己的沙箱AI示例之前,理解诸如Lua脚本调试这类关键概念和理解项目的文件结构是非常重要的。
第2章将介绍如何在沙箱中构造一个AI对象并对它施加推力。一旦有了一个基本的可运行的AI对象,我们将在它的基础上添加团队移动、碰撞规避和路径跟随。
本章主题:
到目前为止,我们已经了解了AI沙箱的创建和一些底层的系统结构,现在我们将从头创建一个新的沙箱示例程序。从本章开始一直到本书结束,我们会逐渐地向这个示例中添加新的Lua沙箱API,包括更多的AI功能、动画、图形和游戏性。
由于沙箱程序处理了图形和物理方面的繁重工作,核心的AI逻辑将全部由Lua语言实现,但它仍依赖一些由C++管理的数据结构。
首先,为了创建一个新的沙箱可执行程序,我们需要在Premake构建脚本中声明一个新的Visual Studio示例项目。打开SandboxDemos.lua
脚本文件,在SandboxDemos
表中添加一个新项。你可以将项目命名为my_sandbox
或者任何你喜欢的名字。项目的名字就是构建出来的可执行程序的名字。
SandboxDemos.lua:
SandboxDemos = {
"chapter_1_introduction",
...
"my_sandbox"
};
![]()
沙箱示例程序的所有复杂配置都位于
premake.lua
文件的CreateDemoProject
函数中。Remake.lua
文件只是简单地遍历SandboxDemos
表的每一项以创建项目并设置它的源代码文件、项目依赖项、库的头文件等。
接下来,我们将为示例程序C++源文件、C++头文件以及Lua脚本文件创建实际的文件结构。根据你添加到SandboxDemos
表中的项目来创建相应的目录结构。Premake会自动地在下列文件夹及其子文件夹中搜索.h
、.cpp
和.lua
文件,并添加到生成的Visual Studio项目中:
src/my_sandbox/include
src/my_sandbox/src
src/my_sandbox/script
项目创建好之后,你需要为Premake创建3个空白文件。创建如下的源文件和头文件:
src/my_sandbox/include/MySandbox.h
src/my_sandbox/src/MySandbox.cpp
src/my_sandbox/src/main.cpp
现在就可以运行vs2008.bat
、vs2010.bat
、vs2012.bat
或者vs2013.bat
来重新生成Visual Studio解决方案了。然后,当打开这个解决方案时,你就能看到新的my_sandbox
项目了。
每个沙箱示例程序都需要扩展SandboxApplication
基础类来声明可执行的Lua脚本的位置。
遵照下面的模式来声明你的MySandbox
类:
MySandbox.h:
#include "demo_framework/include/SandboxApplication.h"
class MySandbox : public SandboxApplication {
public:
MySandbox(void);
virtual ~MySandbox(void);
virtual void Initialize();
};
继承SandboxApplication
类可以提供一些基础的功能。目前我们只需要重载Initialize
函数来添加Lua脚本资源的路径。
![]()
继承
SandboxApplication
还能重载Update
和Cleanup
等函数。任何其他的C++代码都可以通过这些函数注入到主应用程序中。当重载这些函数时,你总是应该调用
Sandbox Application
基类的原始实现,因为它们处理了沙箱的清理、初始化和更新等功能。
在沙箱的源文件中,只需要设置沙箱的Lua脚本资源的路径,并调用父类的Initialization函数。
MySandbox.cpp:
#include "my_sandbox/include/MySandbox.h"
MySandbox:: MySandbox ()
: SandboxApplication("My Sandbox") {}
MySandbox::~ MySandbox () {}
void MySandbox::Initialize()
SandboxApplication::Initialize();
//Relative location from the bin/x32/release/ or
//bin/x32/debug folders
AddResourceLocation("../../../src/my_sandbox/script");
}
最后,你可以在main.cpp
文件中添加一点启动代码来开始你的应用程序:
main.cpp:
#include "my_sandbox/include/MySandbox.h"
#include "ogre3d/include/OgreException.h"
#define WIN32_LEAN_AND_MEAN
#include "windows.h"
int main() {
MySandbox application;
try {
application.Run();
}
catch(Ogre::Exception& error) {
MessageBox(
NULL,
error.getFullDescription().c_str(),
"An exception has occurred!",
MB_OK | MB_ICONERROR | MB_TASKMODAL);
}
}
现在可以编译并运行沙箱了。你应该只能看到一个黑色屏幕,因为还没有在沙箱中添加网格、灯光等物件。虽然看起来东西不多,但是其实已经完成了不少的工作,你的SandboxApplication类已经设置完成,Lua脚本已经开始接管工作了。
Visual Studio项目完成之后,可以来创建一个Decoda项目了。打开Decoda,新建一个空项目。将项目保存到decoda
文件夹,这会创建.deproj
和.deuser
两个文件。每当需要创建一个新的Lua脚本文件时,我们都会在Decoda中创建这个文件并保存到src/my_sandbox/script
文件夹中。
decoda/my_sandbox.deproj
decoda/my_sandbox.deuser
为了在Decoda中运行沙箱,我们需要使用下面的设置来配置Decoda项目。从Project|Settings菜单打开项目的设置面板,如图2-1所示。通常,我们会让Decoda运行沙箱的Release版本以获得更好的效率。除非你需要同时调试C++沙箱代码和Lua脚本,否则建议你运行项目可执行程序的Release版本。
图2-1 新Decoda项目的调试设置
![]()
请注意Release版可执行程序的相对路径。Decoda运行沙箱的路径是基于
.deproj
文件路径的。要记住,在使用Decoda调试前需要先编译Visual Studio解决方案。
基本的沙箱程序就位之后,我们就可以新建沙箱的创建Lua脚本了。首先在脚本文件夹中新建一个Sandbox.lua
脚本。
像下面这样创建一个Lua文件:
src/my_sandbox/script/Sandbox.lua
沙箱Lua脚本必须实现4个全局函数供C++代码调用,它们分别是Sandbox_Cleanup
、 Sandbox_HandleEvent
、 Sandbox_Initialize
和Sandbox_Update:
Sandbox.lua:
function Sandbox_Cleanup(sandbox)
end
function Sandbox_HandleEvent(sandbox, event)
end
function Sandbox_Initialize(sandbox)
end
function Sandbox_Update(sandbox, deltaTimeInMillis)
end
有了这些基础的回调函数,就可以修改SandboxApplication
类的Lua代码来创建沙箱了。
MySandbox.cpp:
void MySandbox::Initialize() {
SandboxApplication::Initialize();
...
CreateSandbox("Sandbox.lua");
}
![]()
在修改任何C++文件之后,别忘了重新编译沙箱应用程序。
现在沙箱已经初始化好并且连接到了Lua脚本,可以开始添加一些基本的几何体和光照了。沙箱暴露给Lua的函数中有一个CreatePlane
函数,它可以根据指定的宽度和高度创建一个平面网格,还会相应地创建一个物理半空间并添加到物理模拟中。
![]()
一个物理半空间是一个在x和z方向上无限延伸的平面。
创建好平面对象后,我们可以使用Core.SetMaterial
函数来为它设置一个Ogre材质。这个材质在media/materials/base.material
中定义。
![]()
沙箱已经可以处理标准的漫发射、高光和法线贴图材质类型。还可以在
media/materials/
base.material
文件中添加新的材质。
在示例中,我们给新创建的平面赋予了名为Ground2
的Ogre材质。
Sandbox.lua:
function Sandbox_Initialize(sandbox)
local plane = Sandbox.CreatePlane(sandbox, 200, 200);
Core.SetMaterial(plane, "Ground2");
end
如果现在运行沙箱,你可能期待看到刚刚创建的平面,但实际上只会看到一个全黑的屏幕。沙箱中如果没有光照,即使创建了平面,你也看不到它。
沙箱中没有光照的话,我们的平面是看不到的。沙箱的核心包提供了函数来创建灯光并设置其漫发射和高光颜色。Core.CreateDirectionalLight
函数需要两个参数:沙箱和光照方向向量。使用(1
,-1
,1)
向量可以创建一个向斜下方照射的光源。
Sandbox.lua:
function Sandbox_Initialize(sandbox)
...
local directional = Core.CreateDirectionalLight(
sandbox, Vector.new(1, -1, 1));
--- Color is represented by a red, green, and blue vector.
Core.SetLightDiffuse(
directional, Vector.new(1.8, 1.4, 0.9));
Core.SetLightSpecular(
directional, Vector.new(1.8, 1.4, 0.9));
end
![]()
注意在使用
Vector.new(red,green,blue)
函数设置光源漫反射和高光时,颜色值是大于1的。这使你可以调整发射光的强度。
现在我们有了一个光源,可以运行沙箱来查看之前创建的平面了。使用鼠标可以调整相机的朝向,只需要按住鼠标右键并拖动鼠标到想要朝向的方向。也可以使用键盘的W、A、S和D键来控制相机在沙箱中漫游。在按下W、A、S和D键的同时按住Shift键,则可以大幅加快相机的移动速度。
看着无尽的黑暗不是一件有趣的事情。我们可以用Sandbox.CreateSkyBox
函数来创建一个天空盒。天空盒是由在Ogre材质media/materials/skybox.material
中指定的6张纹理来定义的。如果你还不知道天空盒是什么的话,可以认为天空盒就是一个有6 个面的盒子,每个面上都有一张不同的纹理。当相机在沙箱中移动时,天空盒也会跟着移动,让它看上去就像真实的天空一样。
![]()
沙箱有多个天空盒材质。你可以在任意的文本编辑器中查看
skybox.material
文件,以了解其他可用的天空盒纹理,以及如何创建全新的天空盒材质。
Sandbox.CreateSkyBox
函数需要传入3个参数:沙箱本身、Ogre材质名和旋转向量。旋转向量的单位是角度,它的作用是在我们的天空盒上添加一个初始化的偏移量。在我们的例子中,我们把天空盒旋转180度来配合之前创建的方向光源:
Sandbox.lua:
function Sandbox_Initialize(sandbox)
...
Sandbox.CreateSkyBox(
sandbox,
"ThickCloudsWaterSkyBox",
Vector.new(0, 180, 0));
end
现在运行沙箱,你会看到一个飘着云朵的天空和明亮的世界,如图2-2所示。
图2-2 带有天空盒和地表平面的新沙箱
Ogre网格在沙箱中只是一个可见的几何体,不包含任何物理表现。在沙箱中添加一个Ogre网格只需要调用Core.CreateMesh
函数,传入Ogre网格文件的路径和名称。
分别使用Core.SetPosition
和Core.SetRotation
函数设置网格的位置和旋转:
Sandbox.lua:
function Sandbox_Initialize(sandbox)
...
local mesh = Core.CreateMesh(
sandbox, "models/nobiax_modular/modular_block.mesh");
Core.SetPosition(mesh, Vector.new(0, 1, 0));
Core.SetRotation(mesh, Vector.new(0, 45, 0));
end
旋转函数的参数是一个角度向量,单位是度。例如,Vector.new(0,45,0)
将让网格绕y轴旋转45度。
如果你想让一个Ogre网格参与物理模拟,可以创建一个沙箱对象。Sandbox.Create Object
函数会为任何的Ogre网格生成一个凸包,并让它参与物理模拟。
![]()
由于为任意网格生成物理表现是在运行时进行的,复杂的网格会延长沙箱的加载时间。在内部实现中,bullet物理引擎会生成一个简化的近似Ogre网格的凸包网格,这两个网格的顶点不是一一对应的。要记住,任何Ogre网格的物理表现都只是对原网格的近似而不是精确的复制。
现在,我们可以把之前创建的方块网格转换为真正的物理模拟对象了。
Sandbox.lua:
function Sandbox_Initialize(sandbox)
...
local object = Sandbox.CreateObject(
sandbox, "models/nobiax_modular/modular_block.mesh");
-- Set the mass of the block in kilograms.
Core.SetMass(object, 15);
Core.SetPosition(object, Vector.new(0, 1, 0));
Core.SetRotation (object, Vector.new(0, 45, 0));
end
现在我们有了基本的光照和一个物理平面,还能创建和模拟物理对象,是时候发射一些东西了。在开始创建智能体之前,先让我们快速了解一下沙箱对象的另外一些物理属性,以及如何与输入控制器进行交互。
Sandbox_HandleEvent
函数让沙箱能够响应鼠标和键盘输入事件。事件参数是一个Lua表,存储了事件的生成来源、事件是由按下还是松开按钮产生的以及事件是哪个键产生的这类信息。鼠标移动事件也类似,但包含了鼠标指针的宽高位置。
我们已经知道如何创建一个沙箱对象了,要发射一个对象只需要把它放置在相机的位置,然后对它施加一个物理冲击。
现在我们打算在接收到空格键按下事件时创建并发射一个方块。相机的位置和朝向可以从沙箱中的Sandbox.GetCameraPosition
和Sandox.GetCameraForward
函数获取到。我们会把位置和朝向赋值给方块并对它沿相机面对的方向施加一个力。为了给物体添加一点自转,你可以使用Core.ApplyAngularImpulse
函数来让它在飞向天空时开始自转。
Sandbox.lua:
function Sandbox_HandleEvent(sandbox, event)
if (event.source == "keyboard" and
event.pressed and event.key == "space_key" ) then
local block = Sandbox.CreateObject(
sandbox,
"models/nobiax_modular/modular_block.mesh");
local cameraPosition =
Sandbox.GetCameraPosition(sandbox);
-- Normalized forward camera vector.
local cameraForward =
Sandbox.GetCameraForward(sandbox);
-- Offset the block's position in front of the camera.
local blockPosition =
cameraPosition + cameraForward * 2;
local rotation = Sandbox.GetCameraOrientation(sandbox);
-- Mass of the block in kilograms.
Core.SetMass(block, 15);
Core.SetRotation(block, rotation);
Core.SetPosition(block, blockPosition);
-- Applies instantaneous force for only one update tick.
Core.ApplyImpulse(
block, Vector.new(cameraForward * 15000));
-- Applies instantaneous angular force for one update
-- tick. In this case blocks will always spin forwards
-- regardless where the camera is looking.
Core.ApplyAngularImpulse(
block, Sandbox.GetCameraLeft(sandbox) * 10);
end
end
现在运行沙箱,我们就可以四处移动、转动相机和发射方块了,如图2-3所示。
图2-3 在沙箱中发射方块
为了创建一个新的智能体,我们需要创建另一个Lua脚本并实现Agent_Cleanup
、 Agent_HandleEvent
、 Agent_Initialize
和Agent_Update
函数。
创建一个Lua文件如下:
src/my_sandbox/script/Agent.lua
Agent.lua:
function Agent_Cleanup(agent)
end
function Agent_HandleEvent(agent, event)
end
function Agent_Initialize(agent)
end
function Agent_Update(agent, deltaTimeInMillis)
end
现在有了一个基础的智能体脚本,我们可以在沙箱中创建一个智能体实例了。修改沙箱的初始化函数,使用Sandbox.CreateAgent
函数创建AI代理。
![]()
要记住,每个AI智能体都在它自己的Lua虚拟机(Virtual Machine,VM)中运行。虽然这个智能体的逻辑是在一个单独的VM中运行,但是你仍然可以从沙箱的Lua脚本中访问并修改它的属性,因为智能体数据是在C++代码中管理的。
修改沙箱的初始化函数,使用Sandbox.CreateAgent
函数创建你的AI智能体。
Sandbox.lua:
function Sandbox_Initialize(sandbox)
...
Sandbox.CreateAgent(sandbox, "Agent.lua");
end
现在沙箱中已经有了一个可运行的智能体,我们还需要为它创建视觉表象以便能够观察它。这一次,我们使用Core.CreateCapsule
函数来程序生成一个胶囊网格,然后附加到智能体上。把智能体传入Core.CreateCapsule
函数中就会把生成的Ogre网格直接附加到智能体上面,并在它移动时自动更新这个胶囊的位置和旋转。
与Sandbox.CreateObject
对象相比,我们只需要创建一个视觉表象,因为智能体已经以胶囊的形式在物理世界中进行模拟了。
创建一个Lua文件如下:
src/my_sandbox/script/AgentUtilities.lua
AgentUtilities.lua:
function AgentUtilities_CreateAgentRepresentation(
agent, height, radius)
-- Capsule height and radius in meters.
local capsule = Core.CreateCapsule(agent, height, radius);
Core.SetMaterial (capsule, "Ground2");
end
Agent.lua:
function Agent_Initialize(agent)
AgentUtilities_CreateAgentRepresentation(
agent, agent:GetHeight(), agent:GetRadius());
end
现在运行沙箱就能看到智能体的视觉表象了,它是一个同样使用Ogre Ground2材质的胶囊,如图2-4所示。
图2-4 智能体的胶囊表象
我们可以设置智能体的位置来让它四处移动。由于智能体参与了物理模拟,如果放在空中它就会落向地面;如果被放到地面以下,则会被推到地面上来。
-- Position in meters.
local position = Vector.new(
xCoordinate, yCoordinate, zCoordinate);
Agent.SetPosition(agent, position);
改变智能体的朝向类似于设置位置向量,不同的是需要提供一个前进方向向量。因为沙箱模拟的是类人形的智能体,物理模拟会锁定它的方向以让它始终保持直立。当设置智能体的前进方向向量时,沙箱会把y轴看成是向上的轴向。
local forwardDirection = Vector.new(
xDirection, 0, zDirection);
Agent.SetForward(agent, forwardDirection);
现在已经可以创建智能体了,我们回过头来看看智能体都有哪些属性,以及它们的意义是什么。
每当需要返回智能体的朝向时,最简单的方法是使用前向向量,它通常代表了智能体的运动方向。朝向的左向量和上向量也可以访问到。每当你需要改变智能体的方向时,只需简单地设置它的前向向量。
为了获取和设置智能体的前向向量,我们可以使用内建的GetForward
和SetForward
辅助函数。
local forwardVector = agent:GetForward();
Agent.SetForward(agent, forwardVector);
可以使用GetLeft
辅助函数获取左向量。
local leftVector = agent:GetLeft();
类似的,可以使用GeUp
辅助函数获取上向量。
local upVector = agent:GetUp();
智能体的位置是它的胶囊表象的质心在物理模拟中的位置。如果你需要确定智能体的原点,只需简单地返回它的位置,然后用y分量减去它的高度的一半。
可以使用GetPosition
和SetPosition
辅助函数获取和设置智能体的位置。
local positionVector = agent:GetPosition();
agent:SetPosition(positionVector);
智能体和它们的胶囊表现的大小是由高度和半径来代表的。修改这两个值的时候,物理模拟也会进行调整,并根据修改来为智能体创建一个新的表现形式。
可以通过GetHeight
和SetHeight
函数来存取智能体的高度。
local height = agent:GetHeight();
agent:SetHeight(height);
可以通过GetRadius
和SetRadius
函数来获取和修改智能体的半径。
local radius = agent:GetRadius();
agent:SetRadius(radius);
虽然智能体都会参与物理模拟,但并不是智能体所有的物理参数都会在物理模拟层使用。比如,智能体的质量和在物理模拟中使用的质量相同,但智能体的MaxForce
和MaxSpeed
函数只会被它自身使用。这两个参数分别代表智能体能对自身施加的最大的力和在不受任何外部影响时能达到的最大速率。
为什么在处理智能体的物理模拟时做这样的区分是有意义的呢?一个直观的例子就是对重力的处理。当智能体加速到它的最大速率时,我们仍然希望重力能让智能体在下落时向下加速。这个加速度能让智能体达到比它的最大速率属性更大的速率。
可以使用智能体的GetMass
和SetMass
辅助函数来获取和修改它的质量。
local mass = agent:GetMass();
agent:SetMass(mass);
智能体的最大作用力可以使用GetMaxForce
和SetMaxForce
函数来获取和设置。
local maxForce = agent:GetMaxForce();
agent:SetMaxForce(maxForce);
为了设置和获取智能体的最大速率属性,可以使用GetMaxSpeed
和SetMaxSpeed
函数。
local maxSpeed = agent:GetMaxSpeed();
agent:SetMaxSpeed(maxSpeed);
可以通过GetSpeed
和SetSpeed
函数来设置和获取智能体的速率。
local speed = agent:GetSpeed();
agent:SetSpeed(speed);
类似地,可以通过GetVelocity
和SetVelocity
函数来获取和设置智能体的速度。
local velocityVector = agent:GetVelocity();
agent:SetVelocity(velocityVector);
智能体本身保存有一个基本的知识集,使得外部的Lua脚本(比如沙箱脚本)可以让它作为代理来保存一定的数据。例如,有时我们会创建一个智能体并让它移动到一个目标位置,我们可能想让沙箱来设置这个位置,而不是让智能体自己来确定它的目标。
智能体的目标是一个向量位置。通常,智能体使用目标来作为它们想到达的位置或者另一个智能体的已知位置。
local targetVector = agent:GetTarget();
agent:SetTarget(targetVector);
目标半径数量值是用来判定智能体是否足够靠近它的目标,而不必精确地处于目标的位置上。这个值可以帮助解决智能体在靠近它的目标时,由于微小的数目偏差而不断地在目标位置打转的问题。
local targetRadius = agent:GetTargetRadius();
agent:SetTargetRadius(targetRadius );
智能体的路径是一系列的向量点。在路径追踪过程中,智能体内部会利用这些点来确定要移动到的位置。让智能体记住它们的路径是一个小小的优化,能避免在路径追踪计算过程中把路径数据传来传去。当把路径设置给智能体时,还可以传递一个布尔值参数,用来指定该路径是否循环。
local pathTable = agent:GetPath();
local hasPath = agent:HasPath();
local path = {
Vector.new(0, 0, 0),
Vector.new(10, 0, 10),
Vector.new(0, 0, 10) };
agent:SetPath(path, cylic);
沙箱中所有的智能体都会通过物理系统自动进行物理模拟,因此有必要了解一下基本的牛顿物理学。
当智能体相互碰撞时,它的质量就会起作用了。智能体的质量决定了当它被施加一个力时,它的加速度会是多少。
速率定义了智能体移动的快慢而不考虑其移动的方向。在沙箱中速率值的单位都是米/秒,代表速度向量的模。
速度代表了智能体的移动速率和方向。它的单位是米/秒,用一个向量来表示的。
沙箱中的加速度单位是米/秒2,它代表了智能体的速度变化的快慢。
力是推动智能体四处移动的主要因素,它的单位是牛顿。
Newtons = kilograms * meters / second 2
一旦受力,物体就会而加速或减速,加减速的快慢则取决于它的质量。
理解沙箱中使用的这些概念非常重要,它可以让你对于速度、距离和质量等概念有一个直观的认识。
我们的沙箱有了基本的智能体属性和成熟的物理系统的支持,可以让智能体在力的作用下真实地移动了。这种运行系统通常被称为基于驾驶的运动系统。Craig Reynold的论文《Staring Behaviors For Autonomous Characters》(http://www.red3d.com/cwr/papers/1999/gdc99steer.html
)很好地描述了移动角色的驾驶系统。转向力能支持多种类型的移动,还能支持在一个角色上施加多个力。
由于沙箱使用OpenSteer库来做转向计算,在Lua中请求一个转向力就很容易了。既然OpenSteer处理了转向计算,对力的应用则留在Lua脚本中处理。
探索是核心的转向力之一,它计算一个让智能体向目标移动的力。OpenSteer会合并探索和抵达转向力,让智能体在接近目标位置时缓慢减速。
local seekForce = agent:ForceToPosition(destination);
创建一个探索型智能体需要两个主要组件。一个是在更新智能体的前进方向时对其施加力的计算,另一个就是把智能体的水平方向的速度限制在它的最大速率属性范围内。
每当我们对智能体施加力时,我们都会使用最大的力。这会让智能体在最短的时间内达到最大速度。如果不施加最大的力,则一些较小的力将来不及对智能体产生影响。这一点非常重要,因为系统不会比较转向力的大小。
首先,我们将实现Agent_ApplyForce
函数来处理对物理系统施加转向力,并在智能体的速度方向有变化时更新智能体的前进方向。
AgentUtilities.lua:
function AgentUtilities_ApplyPhysicsSteeringForce (
agent, steeringForce, deltaTimeInSeconds)
-- Ignore very weak steering forces.
if (Vector.LengthSquared(steeringForce) < 0.1) then
return;
end
--Agents with 0 mass are immovable.
if (agent:GetMass() <= 0) then
return;
end
-- Zero out any steering in the y direction
steeringForce.y = 0;
-- Maximize the steering force, essentially forces the agent
-- to max acceleration.
steeringForce =
Vector.Normalize(steeringForce) * agent:GetMaxForce();
--Apply force to the physics representation.
agent:ApplyForce(steeringForce);
-- Newtons(kg*m/s^2) divided by mass(kg) results in
-- acceleration(m/s^2).
local acceleration = steeringForce / agent:GetMass();
-- Velocity is measured in meters per second(m/s).
local currentVelocity = agent:GetVelocity();
-- Acceleration(m/s^2) multiplied by seconds results in
-- velocity(m/s).
local newVelocity =
currentVelocity + (acceleration * deltaTimeInSeconds);
-- Zero out any pitch changes to keep the Agent upright.
-- NOTE: This implies that agents can immediately turn in any
-- direction.
newVelocity.y = 0;
-- Point the agent in the direction of movement.
agent:SetForward(newVelocity);
end
在施加任何转向力之前,我们会移除所有y轴方向的力。如果你的智能体能够飞行的话,这可能不太合适。不过在沙箱中模拟的主要是类人形的智能体,而人除了跳跃是不能在y轴方向运动的。接下来,我们会把转向力归一化为一个单位向量,这样就可以把这个转向力缩放到被允许的最大的力。
![]()
虽然并不需要在所有的转向计算中使用最大的力,但它能产生理想的结果。你可以自由地选择如何对智能体施加力计算来体验不同的转向行为。
一旦计算好了力,我们只需通过ApplyForce
函数来施加这个力。现在我们使用力来计算智能体加速度的变化。有了加速度,我们可以把加速度乘上deltaTimeInSeconds
参数来得到速度的变化量。速度变化量再加到智能体的当前速度上,得到向量的就是它的前进方向。
这只是对智能体施加力计算的许多种方法中的一种,它对我们的智能体在运动方向和速率变化方面的表现做了很多的假设。稍后我们会对它进行进一步的优化,比如平滑方向的变化等。
![]()
要记住,所有的计算单位都是米、秒和千克。
接下来,我们想要限制智能体的速率。如果考虑施加到智能体上的力,你会想到,加速度的存在会让智能体的速度很快超过它们允许的最大速率。
在限制速度时,我们只想限制智能体的横向速度而忽略由重力产生的纵向速度。为了达到这种效果,我们先取出智能体的速度,把y轴方向的速度设为0,然后把速度的大小限制到最大速率范围内。
在把速度设置回智能体前,我们会把原来y轴方向的速度再设置回去。
AgentUtilities.lua:
function AgentUtilities_ClampHorizontalSpeed(agent)
local velocity = agent:GetVelocity();
-- Store downward velocity to apply after clamping.
local downwardVelocity = velocity.y;
-- Ignore downward velocity since Agents never apply downward
-- velocity themselves.
velocity.y = 0;
local maxSpeed = agent:GetMaxSpeed();
local squaredSpeed = maxSpeed * maxSpeed;
-- Using squared values avoids the cost of using the square
-- root when calculating the magnitude of the velocity vector.
if (Vector.LengthSquared(velocity) > squaredSpeed) then
local newVelocity =
Vector.Normalize(velocity) * maxSpeed;
-- Reapply the original downward velocity after clamping.
newVelocity.y = downwardVelocity;
agent:SetVelocity(newVelocity);
end
end
为了精确计算智能体的最大速率,我们限制了智能体的所有水平方向的速度,这还会造成一个重要的副作用。如果智能体已经达到它的最大速率,则所有能对它产生影响的外部力(比如推动智能体的物理对象)都不会对它的实际速度产生影响。
有了力的应用和最大速率的限制,我们可以创建移动的智能体了。探索智能体会计算一个推动力来让目标移动到目标位置,当达到目标位置的目标半径之内时,它将出发移动到一个新的目标。
首先,计算一个推动移动到目标位置的探索转向力并使用Agent_ApplyPhysics SteeringForce
函数来施加到智能体上。接下来,调用Agent_ClampHorizontalSpeed
函数来限制任何可能超限的速率。
在每一帧中,还会绘制一些额外的调试信息,以显示智能体的移动方向以及目标的半径大小。如果智能体移动到目标半径以内,则会随机计算一个新的目标位置,然后智能体就开始向这个新的目标移动,如图2-5所示。
Agent.lua:
require "AgentUtilities";
function Agent_Initialize(agent)
...
-- Assign a default target and acceptable target radius.
agent:SetTarget(Vector.new(50, 0, 0));
agent:SetTargetRadius(1.5);
end
function Agent_Update(agent, deltaTimeInMillis)
local destination = agent:GetTarget();
local deltaTimeInSeconds = deltaTimeInMillis / 1000;
local seekForce = agent:ForceToPosition(destination);
local targetRadius = agent:GetTargetRadius();
local radius = agent:GetRadius();
local position = agent:GetPosition();
-- Apply seeking force.
AgentUtilities_ApplyForce(
agent, seekForce, deltaTimeInSeconds);
AgentUtilities_ClampHorizontalSpeed(agent);
local targetRadiusSquared =
(targetRadius + radius) * (targetRadius + radius);
-- Calculate the position where the Agent touches the ground.
local adjustedPosition =
agent:GetPosition() -
Vector.new(0, agent:GetHeight()/2, 0);
-- If the agent is within the target radius pick a new
-- random position to move to.
if (Vector.DistanceSquared(adjustedPosition, destination) <
targetRadiusSquared) then
-- New target is within the 100 meter squared movement
-- space.
local target = agent:GetTarget();
target.x = math.random(-50, 50);
target.z = math.random(-50, 50);
agent:SetTarget(target);
end
-- Draw debug information for target and target radius.
Core.DrawCircle(
destination, targetRadius, Vector.new(1, 0, 0));
Core.DrawLine(position, destination, Vector.new(0, 1, 0));
-- Debug outline representing the space the Agent can move
-- within.
Core.DrawSquare(Vector.new(), 100, Vector.new(1, 0, 0));
end
重命名Lua文件。
src/my_sandbox/script/Agent.lua to
src/my_sandbox/ script/SeekingAgent.lua
图2-5 探索一个随机位置的智能体
创建追逐智能体和创建探索智能体的方法很相似,只是追逐智能体会预测另一个目标移动智能体的位置。首先创建一个新的PursuingAgent.Lua
脚本,实现基本的Agent_Cleanup
、 Agent_HandleEvent
、 Agent_Initialize
和Agent_Update
函数。
创建Lua文件如下:
src/my_sandbox/script/PursuingAgent.lua
追逐智能体需要有一个被追逐的敌人,我们将在Initialize
和Update
函数范围之外创建一个持久型Lua变量enemy。在智能体的初始化过程中,我们会查询沙箱中当前的所有智能体,然后把第一个智能体设置为敌人。
相比于探索智能体,我们对追逐智能体的一个小改变是使用PredictFuturePosition
函数来计算它的未来位置。我们把预测的时间秒数作为参数传递,以计算追逐智能体的目标位置。
我们甚至可以让追逐智能体的移动速度比它们的敌人更慢,但当敌人改变方向时,它们仍然会试图在新的预测位置追赶上来。
PursuingAgent.lua:
require "AgentUtilities";
local enemy;
function Agent_Cleanup(agent)
end
function Agent_HandleEvent(agent, event)
end
function Agent_Initialize(agent)
AgentUtilities_CreateAgentRepresentation(
agent, agent:GetHeight(), agent:GetRadius());
-- Assign an acceptable target radius.
agent:SetTargetRadius(1.0);
-- Randomly assign a position to the agent.
agent:SetPosition(Vector.new(
math.random(-50, 50),
0,
math.random(-50, 50)));
local agents = Sandbox.GetAgents(agent:GetSandbox());
-- Find the first valid agent and assign the agent as an
-- enemy.
for index = 1, #agents do
if (agents[index] ~= agent) then
enemy = agents[index];
agent:SetTarget(enemy:GetPosition());
break;
end
end
-- Make the pursuing Agent slightly slower than the enemy.
agent:SetMaxSpeed (enemy:GetMaxSpeed() * 0.8);
end
function Agent_Update(agent, deltaTimeInMillis)
-- Calculate the future position of the enemy agent.
agent:SetTarget (enemy:PredictFuturePosition(1));
local destination = agent:GetTarget();
local deltaTimeInSeconds = deltaTimeInMillis / 1000;
local seekForce = agent:ForceToPosition(destination);
local targetRadius = agent:GetTargetRadius();
local position = agent:GetPosition();
-- Apply seeking force to the predicted position.
AgentUtilities_ApplyForce(
agent, seekForce, deltaTimeInSeconds);
AgentUtilities_ClampHorizontalSpeed(agent);
-- Draw debug information for target and target radius.
Core.DrawCircle(
destination, targetRadius, Vector.new(1, 0, 0));
Core.DrawLine(position, destination, Vector.new(0, 1, 0));
end
由于追逐智能体需要一个敌人,我们会在追逐智能体初始化后在沙箱中创建这个敌人。
Sandbox.lua:
function Sandbox_Initialize(sandbox)
...
Sandbox.CreateAgent(sandbox, "SeekingAgent.lua");
Sandbox.CreateAgent(sandbox, "PursuingAgent.lua");
end
运行沙箱,你会看到一个追逐智能体在追逐一个探索智能体,如图2-6所示。
图2-6 一个智能体试图拦截另一个智能体
创建一个逃避行为基本上和创建一个探索行为类似。唯一区别在于智能体是从它的目标逃开,而不是向目标移动。可以通过Agent.ForceToFleePosition
函数来获取一个逃避的力。
local forceToFlee = agent:ForceToFleePosition(position);
躲避是一种让智能体躲开另一个智能体的转向行为。这种行为和追逐行为正好相反。我们从敌人将要去的位置逃开而不是向那个位置移动。
local forceToEvade = agent:ForceToFleePosition(
enemy:PredictFuturePosition(timeInSeconds));
漫游行为本质上就是在智能体的前进方向上施加一个切向的转向力。漫游会对智能体的移动添加偏离,它本身不会被用作一个转向力。把一个时间片段的毫秒值作为参数传入,可以得到一个恒定速率变化的漫游力。
local forceToWander = agent:ForceToWander(deltaTimeInMillis);
为了调整智能体的速率到想要的目标速率,可以使用ForceToTargetSpeed
函数来计算一个加速或者减速的转向力。
local forceToSpeed = agent:ForceToTargetSpeed(targetSpeed);
一个路径追踪智能体需要实现两种不同的转向行为。一个称为ForceToStayOn Path
,用于将智能体保持在路径上,另一个叫ForceToFollowPath
,用于让智能体沿着路径移动。
创建路径追踪智能体和在SeekingAgent.lua
中实现的探索型智能体很相似。 首先,创建一个新的Lua脚本PathingAgent.lua
。
创建如下的Lua文件:
src/my_sandbox/script/PathingAgent.lua
这一次,我们将使用DebugUtilities
函数来绘制一条路径。这个函数位于src/demo_ramework/
script/DebugUtilities.lua
文件中。当初始化寻路智能体时,我们将把这条路径赋给它。路径默认是循环的,因此智能体会一直绕着一个大圆转圈。
PathingAgent.lua:
require "AgentUtilities";
require "DebugUtilities";
function Agent_Initialize(agent)
AgentUtilities_CreateAgentRepresentation(
agent, agent:GetHeight(), agent:GetRadius());
-- Randomly assign a position to the agent.
agent:SetPosition(Vector.new(
math.random(-50, 50),
0,
math.random(-50, 50)));
end
function Agent_Update(agent, deltaTimeInMillis)
local deltaTimeInSeconds = deltaTimeInMillis / 1000;
-- Force to continue moving along the path, can cause the
-- agent to veer away from the path.
local followForce = agent:ForceToFollowPath(1.25);
-- Force to move to the closest point on the path.
local stayForce = agent:ForceToStayOnPath(1);
-- Slight deviation force to alleviate bumping other pathing
-- agents.
local wanderForce = agent:ForceToWander(deltaTimeInMillis);
-- Sum steering forces using scalars.
local totalForces =
Vector.Normalize(followForce) +
Vector.Normalize(stayForce) * 0.25 +
Vector.Normalize(wanderForce) * 0.25;
local targetSpeed = 3;
-- Accelerate pathing agents to a minimum speed.
if (agent:GetSpeed() < targetSpeed) then
local speedForce = agent:ForceToTargetSpeed(targetSpeed);
totalForces = totalForces + Vector.Normalize(speedForce);
end
-- Apply the summation of all forces.
AgentUtilities_ApplyPhysicsSteeringForce(
agent, totalForces, deltaTimeInSeconds);
AgentUtilities_ClampHorizontalSpeed(agent);
-- Draw the agent's path as a looping path.
DebugUtilities_DrawPath (agent:GetPath(), true);
end
Agent_Update
函数添加了一些新的代码,展示了如何把两个转向力加到一起。ForceToFollowPath
和ForeceToStayOnPath
都以一个与StayOnPath
力相关的较小的权重加到一起。寻路智能体中还添加了一个ForceToTargetSpeed
函数以确保它的速度不会小于一个最小速度。
在沙箱中创建寻路智能体和创建其他智能体类似,只不过这一次我们将创建20个速度各异的智能体,它们会跟随同一条路径。运行沙箱,你会看到这些智能体会互相碰撞而不能相互超越。对于一个行为良好的路径追踪来说,我们还缺少避免碰撞的功能。
Sandbox.lua:
-- Default path to assign to path following agents.
local path = {
Vector.new(0, 0, 0),
Vector.new(30, 0, 0),
Vector.new(30, 0, 50),
Vector.new(-30, 0, 0),
Vector.new(-30, 0, 20)};
function Sandbox_Initialize(sandbox)
...
for i=1, 20 do
local agent = Sandbox.CreateAgent(
sandbox, "PathingAgent.lua");
-- Assign the same path to every agent.
agent:SetPath(path, true);
-- Randomly vary speeds to allow agents to pass one
-- another.
local randomSpeed = math.random(
agent:GetMaxSpeed() * 0.85,
agent:GetMaxSpeed() * 1.15);
agent:SetMaxSpeed(randomSpeed);
end
end
现在运行沙箱,我们会看到20个独立的智能体沿着同一条预先定义的路径运动,如图2-7所示。
图2-7 追踪同一条路径的多个智能体
规避转向行为是避免移动的智能体和其他智能体或对象相互碰撞。当智能体相互靠近时,ForceToAvoidAgents
函数会在潜在智能体的切向方向上计算一个避免碰撞力。我们会使用预测性的移动计算来判断两个智能体是否会在给定的时间内相互碰撞。
另一方面,障碍物避免会使用球体来近似模拟沙箱对象,使用智能体的预测移动来创建一个在潜在碰撞切向方向上的转向力。
我们可以使用ForceToAvoidAgents
函数来计算避免碰撞所需要的力,函数的参数是与其他智能体可能相撞的最小时间。
local avoidAgentForce =
agent:ForceToAvoidAgents(minTimeToCollision);
类似地,ForceToAvoidObjects
函数可以用来计算避免与其他移动的障碍物相撞的力。
local avoidObjectForce =
agent:ForceToAvoidObjects(minTimeToCollision);
修改SeeingAgent.lua
脚本,添加ForceToAvoidAgents
和ForceToAvoidObjects
函数的加权合计值,可以让探索智能体规避可能的碰撞。运行沙箱,试着向在路径上移动的智能体发射箱子,观察它是如何避开箱子的。
SeekingAgent.lua
function Agent_Update(agent, deltaTimeInMillis)
local destination = agent:GetTarget();
local deltaTimeInSeconds = deltaTimeInMillis / 1000;
local avoidAgentForce = agent:ForceToAvoidAgents(1.5);
local avoidObjectForce = agent:ForceToAvoidObjects (1.5);
local seekForce = agent:ForceToPosition(destination);
local targetRadius = agent:GetTargetRadius();
local radius = agent:GetRadius();
local position = agent:GetPosition();
local avoidanceMultiplier = 3;
-- Sum all forces and apply higher priority to avoidance
-- forces.
local steeringForces =
seekForce +
avoidAgentForce * avoidanceMultiplier +
avoidObjectForce * avoidanceMultiplier;
AgentUtilities_ApplyForce(
agent, steeringForces, deltaTimeInSeconds);
...
end
Sandbox.lua:
require "DebugUtilities";
function Sandbox_Update(sandbox, deltaTimeInMillis)
-- Grab all Sandbox objects, not including agents.
local objects = Sandbox.GetObjects(sandbox);
-- Draw debug bounding sphere representations for objects with
-- mass.
DebugUtilities_DrawDynamicBoundingSpheres(objects);
end
现在运行沙箱,我们可以向探索智能体发射箱子,观察它是如何绕开每个对象的,如图2-8所示。
图2-8 使用躲避来绕开障碍物
群组移动可以拆分成3种主要的转向行为:对齐、聚拢和分离。对齐转向力让一个群组中的智能体都面朝同样的方向。聚拢转向力则保持群组中的智能体聚集在一起。分离与聚拢相反,它让群组中的智能体彼此之间都能保持一个最小的距离。
使用这3种行为的组合(也被称为群聚),可以构造一群智能体,它们一起移动,同时又不会互相穿透和重叠,如图2-9所示。
使用ForceToSperate
函数可以计算一个转向向量,用来把智能体与群组中的其他智能体对齐。
local forceToAlign =
agent:ForceToSeparate(maxDistance, maxAngle, agents);
使用ForceToCombine
函数则可以计算一个聚集的力,用来保持智能体和群组中的其他智能体待在一起。
local forceToCombine =
agent:ForceToCombine(maxDistance, maxAngle, agents);
使用ForceToSperate函数可以计算一个力来让智能体与群组中的其他智能体分开。
local forceToSeparate =
agent:forceToSeparate(minDistance, maxAngle, agents);
图2-9 使用分离、聚合以及对齐技术的群组移动
在这个示例中,我们将创建另一种AI类型,称为追随者代理。这里,一群追随者会聚集在一起并向它们的领导者移动。另一方面,领导者就是探索类型的智能体,它会在沙箱中随机地四处移动,完全不顾后面的追随者。
为创建追随者,我们对这些智能体使用多个转向力以让它们聚拢、分离和对齐它们追随的领导者。
创建一个Lua文件如下:
src/my_sandbox/script/FollowerAgent.lua
FollowerAgent.lua:
require "AgentUtilities";
local leader;
function Agent_Initialize(agent)
AgentUtilities_CreateAgentRepresentation(
agent, agent:GetHeight(), agent:GetRadius());
-- Randomly assign a position to the agent.
agent:SetPosition(Vector.new(
math.random(-50, 50), 0, math.random(-50, 50)));
-- Assign the first valid agent as the leader to follow.
local agents = Sandbox.GetAgents(agent:GetSandbox());
for index = 1, #agents do
if (agents[index] ~= agent) then
leader = agents[index];
break;
end
end
end
function Agent_Update(agent, deltaTimeInMillis)
local deltaTimeInSeconds = deltaTimeInMillis / 1000;
local sandboxAgents = Sandbox.GetAgents(agent:GetSandbox());
-- Calculate a combining force so long as the leader stays
-- within a 100 meter range from the agent, and has less than
-- 180 degree difference in forward direction.
local forceToCombine =
Agent.ForceToCombine(agent, 100, 180, { leader } );
-- Force to stay away from other agents that are closer than
-- 2 meters and have a maximum forward degree difference of
-- less than 180 degrees.
local forceToSeparate =
Agent.ForceToSeparate(agent, 2, 180, sandboxAgents );
-- Force to stay away from getting too close to the leader if
-- within 5 meters of the leader and having a maximum forward
-- degree difference of less than 45 degrees.
local forceToAlign =
Agent.ForceToSeparate(agent, 5, 45, { leader } );
-- Summation of all separation and cohesion forces.
local totalForces =
forceToCombine + forceToSeparate * 1.15 + forceToAlign;
-- Apply all steering forces.
AgentUtilities_ApplyPhysicsSteeringForce(
agent, totalForces, deltaTimeInSeconds);
AgentUtilities_ClampHorizontalSpeed(agent);
local targetRadius = agent:GetTargetRadius();
local position = agent:GetPosition();
local destination = leader:GetPosition();
-- Draw debug information for target and target radius.
Core.DrawCircle(
position, 1, Vector.new(1, 1, 0));
Core.DrawCircle(
destination, targetRadius, Vector.new(1, 0, 0));
Core.DrawLine(position, destination, Vector.new(0, 1, 0));
end
追随者智能体和寻路智能体很相似;首先,它会在初始化过程中找到一个领导者,然后使用聚合和对齐转向力来尝试尽可能地接近并保持与领导者的距离,同时使用一个分离的力来保持与其他智能体以及领导者至少两米的距离。
Sandbox.lua:
function Sandbox_Initialize(sandbox)
...
-- Create a pursuing agent to follow the seeking agent.
Sandbox.CreateAgent(sandbox, "PursuingAgent.lua");
-- Create a group of followers that follow the seeking agent.
for i=1, 5 do
Sandbox.CreateAgent(sandbox, "FollowerAgent.lua");
end
...
end
创建5个追随者并绘制围绕它们的调试圆以显示它们的分离区域, 现在就能很容易地看到每个应用到智能体移动上的力了,如图2-10所示。
图2-10 创建领导者和一群追随者
目前为止,我们已经把一些加权的转向力加到一起,并且当某些条件满足时来施加这些力。但这些对于智能体的运动究竟有什么用呢?把各种不同的转向力叠加到一起有两种通用的方法:加权合计法或者基于优先级的方法。
加权合计法总是把所有转向力纳入计算中,每个转向力根据与其他力的比较而得到一个固定的系数。当转向力数量比较少时,这种方法非常直观。但当数量庞大时,要平衡众多互相对抗的力就会非常困难了。
通常这种方法是让智能体移动的首选方法,但当需要处理更复杂的情况时,最好采用基于优先级的方法。
基于优先级的方法只会基于某种优先级或条件来使用某些转向力。例如,你可以忽略所有小于某个阀值的力,或者采用循环的方法,每次只让一个转向力在一段时间内起作用。这种循环的方法可以避免加权合计法可能造成的一个问题,即不同的转向力相互抵消,导致智能体无法移动。
加权合计法和基于优先级的方法都不完美。为了让智能体能按设想的方式运动,这两种方法都需要做大量细致的调整工作。
到目前为止,我们已经创建了探索、追逐、路径追踪和群聚的智能体。现在,你应该熟悉Lua和沙箱如何配合工作,以及数据和逻辑分别处于什么位置了。
在第3章中,我们将开始学习动画处理,以及如何创建动画状态机,用状态来管理动画的播放和转换。有了可移动的智能体和动画网格,我们离实现一个全功能的AI代理又进了一步。