本節書摘來異步社群《lua遊戲ai開發指南》一書中的第1章,第1.1節,作者: 【美】david young(楊) 譯者: 王磊 責編: 陳冀康,更多章節内容可以通路雲栖社群“異步社群”公衆号檢視。
ai沙箱是一個特别設計的軟體架構,它擺脫了應用管理、資源處置、記憶體管理、lua綁定這些無聊的工作,讓你能夠立即着手應用lua進行ai程式設計。雖然這個沙箱承擔了一個小型遊戲引擎的工作,但是它的内部結構是完全開放的。本章會詳盡描述和解析它的内部代碼,以便你在必要時對其進行擴充來獲得更多的功能。
我們在設計ai沙箱時使用了一組預先編譯好的開放源代碼庫,用以支援lua代碼實作的ai的快速原型開發和調試。c++代碼維護和管理ai資料,而lua腳本則管理ai的決策邏輯。資料和邏輯的分離使得lua邏輯可以進行快速疊代,而不用擔心目前ai狀态的崩潰或失效。
在開始建構ai之前,本章将介紹沙箱的内部結構和設定。由于所有的ai腳本都在lua端,我們很有必要了解lua如何與沙箱互動,以及與lua相對應的c++代碼的功能。
沙箱項目的檔案組織在共享媒體資源的同時可以輕松支援每個獨立的項目。一個叫demo_framework的項目提供了本書用到的所有通用代碼。各章c++代碼的差別在于設定的待運作的lua沙箱腳本不同。雖然從本書一開始,整個沙箱架構就是可用的,但在每章中都會繼續添加一些新的功能。
下面我們來逐一檢查每個檔案夾的内容。
根據在visual studio中選擇的建構配置,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版本的解決方案。
沙箱的解決方案檔案是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需要的依賴項有:
freeimage 3.15.4;
freetype 2.4.12;
libjpeg 8d;
openjpeg 1.5.1;
libpng 1.5.13;
libraw 0.14.7;
libtiff 4.0.3;
openexr 1.5.0;
imbase 0.9.0;
zlib 1.2.8;
zzip 0.13.62。
沙箱建構使用的是9.29.1962版本的directx sdk,但任何更新的directx sdk版本都可以使用。此外還有其他一些開源庫用于圖形的調試、輸出處理、實體模拟、轉向模拟和尋路算法等,列舉如下。
ogre3d procedural 0.2:這是一個程式幾何體庫,提供了建立諸如球體、平面、圓柱體和膠囊等幾何體的簡便方法。這些幾何體在沙箱中常用于關卡的調試和原型建構。
ois 1.3:這個平台無關的庫負責沙箱中所有的輸入處理和輸入裝置管理。
bullet physics 2.81-rev2613:這是沙箱的實體引擎,負責驅動ai的運動和碰撞檢測。
opensteer revision 190:這是一個本地轉向控制庫,用于計算ai智能體的轉向力。
recast 1.4:這個庫為沙箱提供了實時的導航網格生成。
detour 1.4:這個庫提供了基于生成的導航網格的a*尋路算法。
premake dev e7a41f90fb80是premake的一個開發版本,基于premake的開發分支。沙箱的premake配置檔案使用了一些隻在開發分支才有的最新特性。
decode 1.6 build 1034 通過檢查沙箱的調試符号檔案來提供lua代碼的無縫調試功能。
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所示。

2.設定command文本框來指向新的沙箱可執行程式。
3.設定working directory和symbols directory指向可執行程式所在目錄。
當使用調試符号時,decoda隻能調試32位應用程式。ai沙箱為release和debug建構配置都生成調試符号。
項目設定界面如圖1-2所示。
在decode中按下f5鍵,可以啟動沙箱的lua腳本調試。f5鍵将啟動沙箱應用程式,并把decoda附加到運作的程序上。從debug菜單選擇break,或者在執行的腳本中設定一個斷點,就可以暫停沙箱來進行調試,如圖1-3所示。
如果熟悉visual studio的watch視窗,你會發現decoda的watch視窗非常相似,如圖1-4所示。調試時可以在watch視窗中鍵入任意變量來監視該變量的值。decoda也允許你在watch視窗鍵入任意的lua語句。這些lua語句會在調試器的目前範圍内執行。
call stack視窗顯示目前執行的lua調用堆棧,如圖1-5所示。在任意一行上輕按兩下可以跳轉到調用處。watch視窗将根據調用堆棧的目前範圍自動重新整理。
virtual machines視窗顯示在沙箱中運作的每個lua虛拟機,如圖1-6所示。沙箱應用有一個單獨的虛拟機,每個運作中的智能體也有一個單獨的虛拟機。
有幾種方法可以同時調試c++沙箱和運作中的lua腳本。
如果沙箱是從decoda啟動的,可以通過visual studio的debug菜單下的attach to process選項附加到運作中的程序,如圖1-7所示。
decode也可以通過debug菜單附加到一個運作中程序。如果沙箱是通過visual studio運作的,你可以在任何時候把decoda附加到它上面,方法與visual studio附加到沙箱上一緻,如圖1-8所示。
可以在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選項。
為了讓decoda知道将哪個lua檔案聯系到目前正在運作的lua腳本上,需要使用lua api函數lual_loadbuffer來加載這個lua檔案,檔案名作為chunkname參數傳入。lual_loadbuffer函數是在lauxlib.h檔案中提供的一個lua輔助函數。
lua虛拟機是由一個定義在lstate.h頭檔案中的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所示。
》lua和大多數程式設計語言的一個差别在于它是從1而不是0開始索引。
在lua中有8種基礎類型:nil(空)、boolean(布爾)、number(數字)、string(字元串)、function(函數)、userdata(自定義類型)、thread(線程)和table(表)。
nil:空值對應于c中的null值。
boolean:對應于c++中的布爾類型,代表true或者false。
number:lua數值類型在内部用double實作,可存儲整數、長整數、單精度浮點數和雙精度浮點數。
string:可表示任意的字元序列。
function:lua把函數也看作基礎類型,是以可以把函數指派給變量。
userdata:這種特别的lua類型用于将一個lua變量映射到一個在c代碼中管理的資料。
thread:lua使用線程類型來實作協程(coroutine)。
table:表示一種關聯數組,将一個索引映射到一個其他基礎類型變量。lua表可以使用任意的lua基礎類型來索引。
lua中的元表是一個表類型,利用元表可以用自定義函數來覆寫已有的通用操作,例如加、減、指派等待。沙箱中大量使用了元表來為由c++管理的自定義類型提供通用操作。
lua中擷取元表的函數是getmetatable,函數參數就是要擷取其元表的對象:
lua中設定元表函數是setmetatable,兩個參數分别是要設定其元表的對象和新的元表:
由于沙箱在自定義類型上大量使用了元表,你總可以使用getmetatable函數來擷取自定義類型的元表,以檢視該自定義類型支援的操作。
元方法是元表中的特殊表項,它會在lua需要某個被覆寫的操作時被調用。通常,所有的lua元方法函數名都以兩個下劃線作為開頭。
在元表中添加元方法的方法是,把函數指派給由方法名索引的元表表項。例如:
自定義類型是一塊任意的資料,它的生命周期是由lua的垃圾收集器管理的。每當代碼建立一個自定義類型對象并推入lua時,lua_newuserdata函數會請求一塊由lua管理的記憶體。
雖然沙箱大量使用了自定義類型,它使用的記憶體的構造和析構仍然是在沙箱内部處理的。這使得運作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函數接收的參數個數、傳回值個數和錯誤處理的方式。
例如,agentutiltities類使用下面的方式來調用agent_initialize lua腳本函數:
首先,這個lua函數在lua中通過名字擷取并推入堆棧中。接下來,将智能體本身作為agent_initalize函數的唯一參數推入堆棧。最後,調用lua_pcall函數會執行這個腳本函數并檢查它是否執行成功,如果未成功,則沙箱會生成一個斷言。
可以通過函數綁定過程将c++函數暴露給lua。任何暴露給lua的被綁定的函數可以作為一個全局函數來通路,或者通過一個包來通路。lua中的包類似于c++中的名空間,它是使用lua中的一個全局表來實作的。
函數綁定
任何暴露給lua的函數都必須符合lua_cfunction聲明。一個lua_cfunction聲明接受一個lua虛拟機作為參數,并傳回被推入lua堆棧中的傳回值的個數。
例如,沙箱中暴露的c++函數getradius在luascriptbidings.h檔案中是這樣聲明的:
函數的實際實作定義在luascriptbindings.cpp檔案中,它包含了從堆棧中擷取參數和将資料推入堆棧的代碼。getradius函數需要一個智能體指針作為第一個也是唯一一個參數,然後使用agentutilities類中的一個輔助函數從堆棧中擷取這個指針引用的自定義資料。由一個額外的輔助函數來實際計算智能體的半徑然後把結果推入到堆棧中:
為了完成綁定,我們定義一個常數數組來把lua中的函數映射到實際調用的c函數上。這個映射數組必須以一個空的lua_reg類型結構來結束。當處理函數映射時,lua使用這個空的lual_reg類型結構作為終止符:
函數綁定到lua虛拟機實際發生在lual_register輔助函數中。這個注冊函數将表中的函數名稱綁定到它們對應的c回調函數。同時還可以指定一個包名稱并在映射時關聯到每個函數上。
如果傳入null作為包名,lua會查詢位于lua堆棧頂部的表。lua會将c函數添加到這個堆棧頂部的表中。
沙箱使用自定義類型來傳遞智能體和沙箱本身,同時也用來添加一些基礎類型。這些基礎類型完全由lua的垃圾收集器控制。
沙箱中的向量類型就是一個完全由lua控制的自定義類型的例子。向量基本上隻是包含3個數值的一個結構體,是以讓lua來管理它的建立和銷毀是正确的選擇。與lua向量互動的c++代碼不能夠持有lua傳回的記憶體位址,而是拷貝資料并儲存在本地。
向量資料類型
把向量實作為lua的一個基礎類型意味着需要支援使用者可能對向量進行的所有操作。向量應該支援加、減、乘、索引以及所有其他lua支援的基礎操作符。
為實作這些操作,向量資料類型使用元方法來支援基礎的數學運算符,并用點操作符來支援“.x”、“.y”和“.z”這樣的文法。
為了讓代碼支援這些功能,lua需要在配置設定記憶體時知道它正在操作的自定義類型的具體類型。luascriptutilities頭檔案定義了向量類型的元表名:
當把c++函數綁定到lua虛拟機時,需要一個額外的步驟來支援向量。lual_newmetatable函數會建立一個新的元表,并把它關聯到向量自定義類型上。在建立元表并推入lua堆棧之後,調用lual_register函數來把列在luavector3metatable中的元方法加入到元表中:
每當在lua中建立一個向量時,lua_newuserdata函數會配置設定所需記憶體,lua會擷取向量的元表并關聯到這個自定義類型上。這使得lua知道自定義類型的具體類型以及它支援的所有函數。
1.1.28 demo架構
demo架構的設計遵循了沙箱中許多其他類的設計,包含了簡單的初始化、更新和清理的功能。
baseapplication.h頭檔案的類概覽圖如圖1-11所示。
baseapplication類的主要功能有配置應用程式視窗、處理輸入指令以及和配置并處理ogre3d。baseapplication類還包含cleanup、draw、initialize和update函數,但這些函數的實作都是空的。baseapplication類的繼承類可以重載這些函數以插入自定義的邏輯。
在繼承類中,initialize函數會在應用程式啟動時在ogre初始化之後調用一次。
cleanup函數會在應用程式準備關閉時,在ogre清理之前調用。
draw函數會在圖形處理單元(graphics processing unit,gpu)渲染目前應用程式幀之前調用。
update函數的調用緊跟在gpu将所有處理目前幀的渲染調用列隊之後。這使得gpu可以和cpu開始準備下一個渲染幀時同步工作。
1.ogre
ogre3d處理沙箱的全局更新循環和視窗管理。baseapplication實作了ogre:: framelistener接口以實作沙箱的update和draw調用。
ogreframelistener.h頭檔案的類概覽圖如圖1-12所示。
baseapplication實作的另一個接口是ogre::windoweventlistener,它使沙箱能夠接受特定的視窗事件,比如視窗移動、尺寸調整關閉前關閉後以及視窗焦點變化等。
ogrewindoweventlistener.h頭檔案中的類概覽圖如圖1-13所示。
這兩個接口的函數都是在ogre的主線程中調用的,是以在處理事件時不會存在競争條件。
2.面向對象輸入系統
面向對象輸入系統(object-oriented input system,ois)庫負責處理沙箱中所有的鍵盤和滑鼠事件。baseapplication類實作了ois系統中的兩個接口來接收來自按鍵點選、滑鼠點選和滑鼠移動的事件。baseapplication一旦接收到這些事件,就把它們依次轉發到沙箱中。
oiskeyboard.h頭檔案的類概覽圖如圖1-14所示。
oismouse.h頭檔案中的類概覽圖如圖1-15所示。
3.sandboxapplication
sandboxapplication類是ai沙箱的主應用程式類,它繼承自baseapplication基類,實作了基類中的cleanup、draw、initialize和update函數。createsandbox函數建立一個沙箱執行個體,然後把它關聯到一個由檔案名參數指定的lua腳本上。
sandboxapplication.h頭檔案中的類概覽圖如圖1-16所示。
4.sandbox類
沙箱類封裝了沙箱資料并處理對lua沙箱腳本的調用。構造沙箱對象需要一個scenenode對象來定位它在遊戲世界中的位置。沙箱的scenenode執行個體也是其他所有用于渲染的幾何體scenenode的父節點,也包含沙箱中的ai智能體類。
sandbox.h頭檔案中的類概覽圖如圖1-17所示。
5.agent類
代理類封裝了代理資料,還能執行通過loadscript函數綁定的lua腳本。構造agent執行個體時需要一個scenenode對象,用來維持代理對象在遊戲世界中的方向和位置。
agent.h頭檔案的類概覽圖如圖1-18所示。
6.工具類
ai沙箱使用了很多工具模式來分離邏輯和資料。沙箱和代理類各自儲存它們自己相關的資料,和lua虛拟機互動的資料的處理則由工具類來完成。
比如,agentutilities類處理lua ai代理執行的所有動作,而sandboxutilities類處理lua沙箱執行的所有動作。
任何通用功能或與lua虛拟機的其他各種互動都由luascriptutilities類來處理。
7.lua綁定
luascriptbindings.h頭檔案描述了沙箱暴露給lua虛拟機的所有c++函數。你可以把這個檔案作為ai沙箱的應用程式接口(api)的參考文檔。每個函數都有功能描述、函數參數、傳回值和lua代碼調用示例。