這篇文章討論如何在基於Babylon.js的WebGL場景中,實現多個簡單卡牌類對象的顯示、選擇、分組、排序,同時建立一套實用的3D場景代碼框架。由於作者美工能力有限,所以示例場景視覺效果可能欠佳,本文的重點在於對相關技術的探討。 因為文章比較長,讀者可以考慮將網頁導出為mhtml格式,使用Word ...
這篇文章討論如何在基於Babylon.js的WebGL場景中,實現多個簡單卡牌類對象的顯示、選擇、分組、排序,同時建立一套實用的3D場景代碼框架。由於作者美工能力有限,所以示例場景視覺效果可能欠佳,本文的重點在於對相關技術的探討。
因為文章比較長,讀者可以考慮將網頁導出為mhtml格式,使用Word瀏覽。Chrome瀏覽器導出mhtml文件的方法見末尾。
一、顯示效果:
1、訪問https://ljzc002.github.io/CardSimulate/HTML/TEST2.html查看“卡牌模擬頁面”:
場景中間是三個作為參照物的小球,視口平面的中間是一個用Babylon.js GUI製作的準星,預設滑鼠與準星鎖定在一起,直接移動滑鼠即可改變相機視角,使用WASD Shift 空格鍵可以控制相機前、左、後、右、下、上運動(可能將Ctrl鍵設為向下更符合傳統,但是沒有找到禁用瀏覽器Ctrl+s快捷鍵的方法,只好用Shift代替)。因為游標被鎖定,將這種瀏覽狀態命名為“first_lock”。
2、按下Alt鍵,75張卡片通過動畫移入相機視野,同時相機的位置被固定(但仍可以通過拖動滑鼠改變視角):
點擊右側的“向上兩行”和“向下兩行”按鈕可以上下滾動卡片,再次按下Alt鍵將隱藏卡片,同時恢復相機的移動和游標的鎖定。因為這種瀏覽狀態主要用來點選場景中的物體,將它命名為“first_pick”。
3、滑鼠左鍵單擊一張卡片,卡片將處於“選中狀態”(綠色邊緣),再次左鍵單擊處於選中狀態的卡片,卡片將被放大拉近顯示,再左鍵單擊將恢複原位:
執行動畫時會禁用用戶的控制,完全由動畫控制視角,所以將這種瀏覽狀態命名為“first_ani”。
4、模仿Windows的文件多選編寫了卡片多選功能,按下Ctrl時可以點選多個卡片,按下Shift時可以選取首尾之間的所有卡片:
5、選中若幹張卡片後,按1-5鍵可以將被選中的卡片編為1-5隊,被編隊的卡片將按編隊順序顯示在最高處,同時編隊的前面會顯示隊號標記:
6、在first_pick狀態可以使用上下左右方向鍵進行場景漫游,可以看到場景中的所有對象:
二、代碼實現:
1、文件結構:
CardSimulate工程的文件結構如下圖所示:
其中LIB目錄下是從網上下載的代碼庫
babylon.32.all.maxs.js是Babylon.js引擎庫
earcut.dev.js是一個Babylon.js擴展,其功能是在網格上挖洞
stat.js是用來顯示幀數的代碼
MYLIB是自己編寫的代碼庫
Events.js是一些用來處理事件的方法
FileText.js是與文件處理相關的代碼
newland.js是自己編寫的一些Babylon.js輔助類
View.js是html視圖的一些相關方法
PAGE是直接操縱這個頁面(WebGL場景)的代碼庫
Character.js是場景中出現的各種對象的類(比如卡牌網格、相機網格)
Control20180312.js是用來處理滑鼠鍵盤輸入的代碼
DrawCard.js是用來繪製卡牌的代碼
FullUI.js是用來繪製全局(全屏)UI的代碼
Game.js是游戲類,存儲用來調度整個場景的信息
HandleCard.js是用來處理已經繪製出的卡牌的代碼,後期考慮和DrawCard.js整合在一起
HandleCard2.js是一個分枝修改版
Moves.js是運動計算代碼
tab_carddata.js里是卡牌種類信息
tab_somedata.js里是其他輔助信息
2、代碼入口與場景初始化:
A、代碼由TEST2.html開始執行,其中一部分和前面幾篇文章用到的相似:
1 <!DOCTYPE html> 2 <html lang="en"> 3 <head> 4 <meta charset="UTF-8"> 5 <title>第二個場景測試,手牌的顯示、排列、分組排序,顯示瓷磚地面</title> 6 <link href="../CSS/newland.css" rel="stylesheet"> 7 <link href="../CSS/stat.css" rel="stylesheet"> 8 <script src="../JS/LIB/babylon.32.all.maxs.js"></script> 9 <script src="../JS/LIB/stat.js"></script> 10 <script src="../JS/MYLIB/Events.js"></script> 11 <script src="../JS/MYLIB/FileText.js"></script> 12 <script src="../JS/MYLIB/newland.js"></script> 13 <script src="../JS/MYLIB/View.js"></script> 14 <script src="../JS/PAGE/Game.js"></script> 15 <script src="../JS/PAGE/Character.js"></script> 16 <script src="../JS/PAGE/Control20180312.js"></script> 17 <script src="../JS/PAGE/Moves.js"></script> 18 <script src="../JS/PAGE/DrawCard.js"></script> 19 <script src="../JS/PAGE/tab_carddata.js"></script> 20 <script src="../JS/PAGE/tab_somedata.js"></script> 21 <script src="../JS/PAGE/HandleCard2.js"></script> 22 <script src="../JS/PAGE/FullUI.js"></script> 23 </head> 24 <body> 25 <div id="div_allbase"> 26 <canvas id="renderCanvas"></canvas> 27 <div id="fps" style="z-index: 301;"></div> 28 </div> 29 </body> 30 <script> 31 var VERSION=1.0,AUTHOR="[email protected]"; 32 var machine,canvas,engine,scene,gl,MyGame={}; 33 canvas = document.getElementById("renderCanvas"); 34 engine = new BABYLON.Engine(canvas, true); 35 engine.displayLoadingUI(); 36 gl=engine._gl;//決定在這裡結合使用原生OpenGL和Babylon.js; 37 scene = new BABYLON.Scene(engine); 38 var divFps = document.getElementById("fps"); 39 40 var MyGame={}; 41 window.onload=beforewebGL; 42 function beforewebGL() 43 { 44 if(engine._webGLVersion==2.0)//輸出ES版本 45 { 46 console.log("ES3.0"); 47 } 48 else{ 49 console.log("ES2.0"); 50 } 51 MyGame=new Game(0,"first_pick","","http://127.0.0.1:8082/");//建立MyGame對象用來進行全局調度 52 /*0-startWebGL 53 * */ 54 webGLStart(); 55 }
。。。
56 </script> 57 </html>
但與前面的簡單場景將主要代碼都寫在webGLStart方法中不同,對於較為複雜的流程最好將流程的每個階段寫在單獨的方法里,對於較多的對象則最好提取對象的共同點作為一個“類”,將每個對象作為類的實例。這樣可以將程式的複雜度分解,每次只關註其中的一小部分,降低編程難度。(設計模式的本質是對變數名進行管理,理論上講,如果編程者的記憶力足夠強、編程者之間的溝通效率足夠高,則這些所謂的“設計模式”都可以省略)
B、在webGLStart方法中對場景初始化流程進行了劃分,各個流程如註釋所示:
1 //對象框架架構 2 function webGLStart() 3 { 4 //initWebSocket();//如何確保上一環結成功才開啟下一環節? 5 initScene();//初始化場景,包括最初入門教程里的那些東西 6 initArena();//初始化地形,包括天空盒,參照物等 7 initEvent();//初始化事件 8 initUI();//初始化場景UI 9 initObj();//初始化一開始存在的可交互的物體 10 initLoop();//初始化渲染迴圈 11 MyGame.init_state=1;//更新初始化狀態 12 engine.hideLoadingUI();//隱藏載入UI 13 //MyGame.flag_startr=1;//這個是通過nohurry計時器自動啟動的,不需要手動啟動 14 }
C、初始化場景
1 function initScene() 2 {//光照 3 var light0 = new BABYLON.HemisphericLight("light0", new BABYLON.Vector3(0, 1, 0), scene); 4 light0.diffuse = new BABYLON.Color3(1,1,1);//這道“顏色”是從上向下的,底部收到100%,側方收到50%,頂部沒有 5 light0.specular = new BABYLON.Color3(0,0,0); 6 light0.groundColor = new BABYLON.Color3(1,1,1);//這個與第一道正相反 7 MyGame.lights.light0=light0;//將光照變數交給MyGame對象管理 8 mesh_arr_cards=new BABYLON.Mesh("mesh_arr_cards", scene); 9 //相機對象 10 var camera0= new BABYLON.FreeCamera("FreeCamera", new BABYLON.Vector3(0, 0, 0), scene); 11 //camera0.layerMask = 2; 12 //camera0.position=new BABYLON.Vector3(0, 0, -20); 13 camera0.minZ=0.001; 14 scene.activeCameras.push(camera0); 15 16 //用BallMan作為CameraMesh的mesh 17 var player = new BallMan(); 18 var obj_p={};//初始化參數 19 //計劃不使用物理引擎 20 var mesh_ballman=new BABYLON.Mesh("mesh_ballman",scene); 21 obj_p.mesh=mesh_ballman; 22 obj_p.name="本機";//顯示的名字 23 obj_p.id="本機";//WebSocket Sessionid 24 obj_p.image="../ASSETS/IMAGE/Rainbow.jpg"; 25 player.init( 26 obj_p,scene 27 ); 28 29 var cameramesh=new CameraMesh(); 30 var obj_p={};//初始化參數 31 obj_p.mesh=mesh_ballman; 32 obj_p.mesh.isVisible=false; 33 obj_p.mesh.position=new BABYLON.Vector3(0,0,-20); 34 if(obj_p.mesh.ballman) 35 { 36 obj_p.mesh.ballman.head.position=obj_p.mesh.position.clone(); 37 } 38 obj_p.methodofmove="host20171018"; 39 obj_p.name="FreeCamera";//顯示的名字 40 obj_p.id="FreeCamera";//WebSocket Sessionid 41 obj_p.camera=camera0; 42 //obj_p.image="assets/image/play.png"; 43 obj_p.flag_objfast=5; 44 cameramesh.init( 45 obj_p,scene 46 ); 47 MyGame.arr_myplayers[obj_p.name]=cameramesh; 48 MyGame.player=cameramesh; 49 MyGame.Cameras.camera0=camera0; 50 camera0.position=cameramesh.mesh.position.clone(); 51 cameramesh.mesh.rotation=camera0.rotation.clone(); 52 mesh_arr_cards.position=MyGame.player.mesh.ballman.backview._absolutePosition.clone(); 53 }
其中mesh_arr_cards是所有手牌的父網格,用來對手牌進行定位,事實上這個對象放在initArena或者initObj階段更加合理,但是因為相機對象的一些事件和這個網格有關,只好放在場景初始化階段。BallMan的外觀是一個球體網格,用來代表場景中的玩家,其用法可以參考https://www.cnblogs.com/ljzc002/p/7274455.html;CameraMesh是一個網格與相機的結合體,在第三人稱時用戶將能看見自己操縱的單位(關於BallMan和CameraMesh類的參數將在後面詳細介紹)。最後把各種對象都交給MyGame統一管理。
D、初始化環境
1 function initArena() 2 { 3 var mesh_base=new BABYLON.MeshBuilder.CreateSphere("mesh_base",{diameter:1},scene); 4 mesh_base.material=MyGame.materials.mat_green; 5 mesh_base.position.x=0; 6 mesh_base.renderingGroupId=2; 7 //mesh_base.layerMask=2; 8 var mesh_base1=new BABYLON.MeshBuilder.CreateSphere("mesh_base1",{diameter:1},scene); 9 mesh_base1.position.y=10; 10 mesh_base1.position.x=0; 11 mesh_base1.material=MyGame.materials.mat_green; 12 mesh_base1.renderingGroupId=2; 13 //mesh_base1.layerMask=2; 14 var mesh_base2=new BABYLON.MeshBuilder.CreateSphere("mesh_base2",{diameter:1},scene); 15 mesh_base2.position.y=-10; 16 mesh_base2.position.x=0; 17 mesh_base2.material=MyGame.materials.mat_green; 18 mesh_base2.renderingGroupId=2; 19 //mesh_base2.layerMask=2; 20 for(var i=0;i<5;i++)//建立五個標示組號的標記網格,標記從一(而不是零)開始 21 { 22 var plane=new BABYLON.MeshBuilder.CreatePlane("mesh_groupicon"+(i+1),{size:5},scene); 23 var mat_plane = new BABYLON.StandardMaterial("mat_plane"+(i+1), scene); 24 var texture_plane= new BABYLON.DynamicTexture("texture_plane"+(i+1), {width:100, height:100}, scene); 25 mat_plane.diffuseTexture =texture_plane; 26 plane.material=mat_plane; 27 var font = "bold 60px monospace"; 28 texture_plane.drawText((i+1), 40, 70, font, "white", "green", true, true);//第一個是文字顏色,第二個則是完全填充的背景色 29 plane.position.x=-16; 30 plane.position.z=-2; 31 plane.renderingGroupId=2; 32 //plane.rotation.x=-Math.PI/2;//這會導致自由相機的視角發生bug??Y與Z軸混淆? 33 plane.isPickable=false; 34 plane.isVisible=false; 35 arr_mesh_groupicon.push(plane); 36 //plane.parent=mesh_arr_cards; 37 } 38 }
建立了三個小綠球作為場景的參照物,建立了五個小平面作為分組標記,這五個標記暫時不可見(在調試分組標記的過程中Babylon.js發生了bug,相機輸入的Y軸和Z軸發生混淆,但沒有深入分析原因)。
E、初始化事件
1 function initEvent() 2 { 3 InitMouse(); 4 window.addEventListener("keydown", onKeyDown, false);//按鍵按下 5 window.addEventListener("keyup", onKeyUp, false);//按鍵抬起 6 window.addEventListener("resize", function () { 7 if (engine) { 8 engine.resize(); 9 } 10 },false); 11 }
InitMouse中是對滑鼠的四種事件監聽,具體代碼在Control20180312.js文件中,接下來監聽了按鍵按下、按鍵抬起、視窗尺寸變化。
F、初始化UI
1 function initUI() 2 { 3 MakeFullUI(); 4 //var advancedTexture = MyGame.fsUI; 5 6 }
代碼主體在FullUI.js文件中
G、初始化對象
1 function initObj() 2 {//添加75個(?)實驗對象 3 4 DrawCard4(); 5 SortCard(); 6 }
具體代碼在DrawCard.js中
H、初始化渲染迴圈(也是邏輯迴圈)
1 function initLoop() 2 { 3 var _this=MyGame; 4 scene.registerBeforeRender(function() { //比runRenderLoop更早 5 }); 6 scene.registerAfterRender( 7 function() { 8 if(MyGame.flag_startr==1)//如果開始渲染了 9 {//如果正在使用相機網格進行漫游 10 if(MyGame.player.prototype=CameraMesh&&MyGame.flag_view=="first_lock") 11 { 12 host20171018(MyGame.player); 13 } 14 } 15 } 16 ); 17 18 engine.runRenderLoop(function () //場景邏輯和AI也從這裡引入 19 { 20 if (divFps) { 21 // Fps 22 divFps.innerHTML = engine.getFps().toFixed() + " fps"; 23 } 24 MyGame.HandleNoHurry();//這裡包含了運動使用的計時器 25 if(_this.flag_startr==1||_this.flag_view!="first_pick") 26 { 27 //主相機和小地圖相機都隨著玩家的位置變化 28 CamerasFollowActor(_this.player); 29 } 30 31 _this.scene.render(); 32 33 }); 34 }
其中registerBeforeRender是在每一幀渲染之前執行的代碼,registerAfterRender是在每一幀渲染之後執行的代碼,除了scene之外mesh類對象也可以使用這樣的方法,這也意味著可以將渲染前後的代碼分散寫在多個地方,但這裡為了方便管理統一寫在一處。host20171018是根據按鍵狀態和視角計算player運動的方法,具體代碼在Moves.js文件中。
runRenderLoop里是每一幀渲染時執行的代碼,這裡首先更新了當前幀數顯示,然後通過HandleNoHurry(代碼在Game類中)執行一些“需要周期性執行,但沒有必要每一幀都執行的代碼”,接下來通過CamerasFollowActor(Moves.js文件中)讓“和player關聯但不是player子元素”的其他對象跟隨player運動,最後調用場景的渲染方法。
關於運動,上述代碼的計算流程是這樣的:
player的position-》計算出player的_absolutePosition(?)-》registerBeforeRender-》根據player的_absolutePosition計算關聯對象的新位置-》渲染-》registerAfterRender-》host20171018更新player的position。
考慮到player也許是其他元素的子元素,其position(位置)和_absolutePosition(絕對位置)可能不同(相差物體的世界矩陣),需要使用_absolutePosition來定位關聯對象;而Babylon.js根據position計算_absolutePosition的操作發生在registerBeforeRender之前,所以如果我們把host20171018放在registerBeforeRender中則會導致position更新而_absolutePosition仍為舊的,表現的效果就是相機運動時對象抖動。所以我們把host20171018放在registerAfterRender中,當然如果position和_absolutePosition完全相同,則從理論上講不存在這種限制,但並未測試過。
3、Game類
A、初始化方法:
1 Game=function(init_state,flag_view,wsUri,h2Uri) 2 {//參數:初始化時的狀態代號,初始化時的瀏覽模式,webSocket的伺服器地址,h2資料庫地址 3 var _this = this; 4 this.scene=scene; 5 this.loader = new BABYLON.AssetsManager(scene);//資源管理器,用於預先載入資源 6 //控制者數組 7 this.arr_myplayers={}; 8 this.arr_npcs={};//NPC數組 9 this.count={};//綜合計數器對象 10 this.count.count_name_npcs=0;//NPC命名計數器,每產生一個NPC則加一,避免NPCID重覆 11 this.Cameras={};//scene里也有?,綜合相機對象 12 this.websocket; 13 this.lights={};//綜合光源對象 14 this.fsUI=BABYLON.GUI.AdvancedDynamicTexture.CreateFullscreenUI("ui1");//全屏GUI對象 15 this.hl=new BABYLON.HighlightLayer("hl1", scene);//高光層對象,下麵是高光層的一些參數 16 this.hl.blurVerticalSize = 1.0;//這個影響的並不是高光的粗細程度,而是將它分成 多條以產生模糊效果,數值表示多條間的間隙尺寸 17 this.hl.blurHorizontalSize =1.0; 18 this.hl.innerGlow = false;//取消內部光暈 19 this.hl.alphaBlendingMode=3; 20 //this.hl.isStroke=true; 21 //this.hl.blurTextureSizeRatio=2; 22 //this.hl.mainTextureFixedSize=100; 23 //this.hl.renderingGroupId=3; 24 //this.hl._options.mainTextureRatio=1000; 25 26 this.wsUri=wsUri; 27 this.init_state=init_state;//當前運行狀態 28 /*0-startWebGL 29 1-WebGLStarted 30 2-PlanetDrawed 31 * */ 32 this.h2Uri=h2Uri; 33 //我是誰 34 this.WhoAmI=newland.randomString(8); 35 36 this.materials={};//綜合材質對象,下麵初始化了幾種常用的材質 37 var mat_frame = new BABYLON.StandardMaterial("mat_frame", scene); 38 mat_frame.wireframe = true; 39 this.materials.mat_frame=mat_frame; 40 var mat_red=new BABYLON.StandardMaterial("mat_red", scene); 41 mat_red.diffuseColor = new BABYLON.Color3(1, 0, 0); 42 var mat_green=new BABYLON.StandardMaterial("mat_green", scene); 43 mat_green.diffuseColor = new BABYLON.Color3(0, 1, 0); 44 var mat_blue=new BABYLON.StandardMaterial("mat_blue", scene); 45 mat_blue.diffuseColor = new BABYLON.Color3(0, 0, 1); 46 this.materials.mat_red=mat_red; 47 this.materials.mat_green=mat_green; 48 this.materials.mat_blue=mat_blue; 49 50 this.models={};//綜合模型對象 51 this.textures={};//綜合紋理對象 52 this.texts={};//綜合文本對象 53 54 this.flag_startr=0;//開始渲染並且地形初始化完畢 55 this.flag_starta=0;//開始執行NPC的AI邏輯 56 this.list_nohurry=[];//需要周期性進行的工作 57 this.nohurry=0;//一個計時器,讓一些計算不要太頻繁 58 this.flag_online=false;//是否是線上場景 59 this.flag_view=flag_view;//first/third/input/free 60 this.flag_controlEnabled = false; 61 this.arr_keystate=[];//按鍵狀態數組 62 }
這段代碼中初始化了一些場景中可能會用到的變數,最整潔的情況是把所有的全局變數都作為MyGame的屬性加以管理,但很難做到。
B、原型方法:
每個Game類的實例都會繼承這些方法:
1 Game.prototype={ 2 AddNohurry:function(name,delay,lastt,todo,count) 3 {//名字,每次執行之間的間隔(最小間隔),上一次執行時間,要執行的函數名,已經執行的次數 4 if(this.list_nohurry[name])//如果已經有叫做這個名字的任務 5 { 6 return; 7 } 8 this.list_nohurry[name]={delay:delay,lastt:lastt,todo:todo 9 ,count:count}; 10 }, 11 RemoveNohurry:function(name) 12 { 13 delete this.list_nohurry[name]; 14 }, 15 HandleNoHurry:function() 16 { 17 var _this=this; 18 if( _this.flag_startr==0)//開始渲染並且地形初始化完畢!! 19 { 20 engine.hideLoadingUI();//隱藏載入UI 21 _this.flag_startr=1;//標誌開始渲染 22 _this.lastframet=new Date().getTime(); 23 _this.firstframet=_this.lastframet; 24 _this.DeltaTime=0; 25 } 26 else 27 {//如果已經開始渲染 28 _this.currentframet=new Date().getTime();//當前幀的時間 29 _this.DeltaTime=_this.currentframet-_this.lastframet;//取得兩幀之間的時間 30 _this.lastframet=_this.currentframet; 31 /*_this.nohurry+=_this.DeltaTime;//這個代碼用於只執行一個定時任務的情況 32 33 if(MyGame&&_this.nohurry>1000)//每一秒進行一次 34 { 35 _this.nohurry=0; 36 37 }*/ 38 //var time_start=_this.currentframet-_this.firstframet;//當前時間到最初過了多久 39 for(var i=0;i<_this.list_nohurry.length;i++