背景:前段時間自己居然迷路了,有感而發就想到寫一個可以讓人迷路的小遊戲,可以消(bao)遣(fu)時(she)間(hui)
沒有使用threejs,就連glMatrix也沒有用,純原生webgl幹,寫起來還是挺累的,不過程式碼結構還是挺清晰的,註釋也挺全的,點開全文開始迷宮之旅~
畢竟要賺一點PV,所以開頭沒有貼地址,現在貼地址:
github:https://github.com/westAnHui/3Dmaze
線上試玩:https://westanhui.github.io/3Dmaze/3Dmaze2.html
遊戲操作:滑鼠控制方向,w前進,s後退,切記方向鍵沒用啊!
迷宮本身的比較簡陋,沒加光和陰影啥的,挺趕的一個demo。不過這篇文章不是介紹webgl技術為主的,主要是講解整個遊戲開發的情況,let’s go~
1、生成2D迷宮
迷宮遊戲嘛,肯定迷宮是主體。大家可以從遊戲中看到,我們的迷宮分為2D迷宮和3D迷宮,首先說2D迷宮,它是3D迷宮的前提
生成迷宮有三種方式
a)深度優先
一言不合貼原始碼:https://github.com/westAnHui/3Dmaze/blob/master/maze1.html
先看一下用深度優先法生成迷宮的圖吧
我們看下迷宮的特點,發現有一條很明顯的主路,是不是能理解演算法名中“深度優先”的含義了。簡單介紹一下演算法的原理:
知道了原理,我們著手來製造2D迷宮~
首先得確定牆和路的關係,考慮到迷宮轉化為3D之後牆立體一點,我們就不要用1px的線來模擬牆了,那樣3D之後不夠飽滿~
這裡我們設定牆的厚度為路的寬度,都是10px,然後我們的底圖應該是這樣子的(注:理解這幅圖最為關鍵):
白色部分是路,也可以理解為原理中所說的鄰格,這是可以達到的
灰色部分是牆,這個牆可能會打通,也可能沒有打通
黑色部分是牆,這個牆是不可能打通的!!
如果腦子沒轉過來就看下圖,轉化理解
紅線就是玩家的路徑啦,其中我們看到穿過了三個黑色的矩形,這就是上面所說的灰色格,可能打通,也可能沒打通,藍色那塊就是沒打通的情況;而黑色部分正對應上面的黑色格,這是不可能打通的,如果把牆看成一個面(灰色藍色部分再壓縮),黑色就變成一個點,是橫牆與豎牆的交點,玩家不會走交點上面走的~
好,下面就是套演算法的過程啦,寫的過程中我把牆的部分給省略了,全部考慮成路
1 2 |
var maxX = 18; var maxY = 13; |
以寬度來解釋,Canvas寬度390px,有18列的路(20列的牆暫時被我無視),不理解的可以對照圖看一下
initNeighbor方法是獲得鄰格用的,注意最後有一個隨機,將它的鄰格打亂,這樣我們在getNeighbor 中獲取鄰格就很方便了
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 |
Grid.prototype.getNeighbor = function() { var x, y, neighbor; this.choosed = true; // 標記當前格 for(var i = 0; i < this.neighbor.length; i++) { x = this.neighbor[i].x; y = this.neighbor[i].y; neighbor = maze.grids[y][x]; if(!neighbor.choosed) { // 鄰格是否標記過 neighbor.parent = this; // 選中的鄰格父級為當前格 return neighbor; } } if(this.parent === firstGrid) { return 0; // 結束 } else { return 1; // 這裡是鄰格都被標記過,返回父級 } }; |
這裡比較核心,註釋給的也比較全,結合前面的原理圖應該很好懂
再看下maze裡面的findPath方法,在這裡面呼叫的getNeighbor方法
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 |
Maze.prototype.findPath = function() { var tmp; var curr = firstGrid; // 先確定起點 while(1) { tmp = curr.getNeighbor(); // 獲得鄰格 if(tmp === 0) { console.log('路徑找尋結束'); break; } else if(tmp === 1) { // 鄰格都被標記,回到父級 curr = curr.parent; } else { // 找到了一個沒被標記的鄰格,存起來 curr.children[curr.children.length] = tmp; curr = tmp; } } }; |
可以看到parent和children屬性是不是本能的就反應起樹的概念了,那不就是深度的思想麼~
核心的程式碼講解了,其他的畫圖部分就不介紹了,在drawPath方法裡面,原理就是先畫一個節點(一個格子),然後它的children格和它打通(前面圖中灰色格子轉為白色),再去畫children格……
注:開頭給的試玩demo用的不是深度優先演算法,下面這個是深度優先生成的迷宮遊戲,可以感受一下,這樣與開頭的有一個對比
https://westanhui.github.io/3Dmaze/3Dmaze1.html
b)廣度優先(prim隨機)
一言不合貼原始碼:https://github.com/westAnHui/3Dmaze/blob/master/maze2.html
再看一下廣度優先生成的迷宮圖~可以和上面的對比一下
前面說的深度優先演算法挺好理解的,人類語言表達出來就是“一直走,能走多遠走多遠,發現不通了,死路了,再回去想想辦法”。
但是,用深度優先演算法在迷宮遊戲中有很致命的一個缺點,就是簡單,那條明顯的主路讓玩家不看2D地圖都能輕鬆的繞出來(路痴退散),這明顯不符合開頭所說的消(bao)遣(fu)時(she)間(hui)的主題,那麼正主來啦~
prim(普里姆)演算法是傳統迷宮遊戲的標準演算法,岔路多,複雜。我覺得有廣度優先的思想,所有自己也稱廣度優先演算法,正好和上一個對應上。貼原理圖~
人類語言表達出來就是“隨機的方式將地圖上的牆儘可能打通”,還記得這個底圖麼,照著這個底圖我解釋一下
選擇1為起點,並標記。1的鄰牆有2,3,放入陣列中。
此時陣列[2, 3],隨機選擇一個,比如我們選到了2,2的對面格是4,此時4沒有被標記過,打通2(將2由灰變成白色),並將4標記,並把5,6放入陣列
此時陣列[2, 3, 5, 6],繼續隨機……
結合一下原始碼,發現這次寫法和上次的完全不同了,在深度優先中我們直接沒考慮牆的存在,主體是路(白色的格子),將他們變成樹的結構即可,在後面繪製部分再會考慮牆的位置
而在廣度優先中,我認為主體是牆(灰色的格子),所以演算法中一定要把牆的概念帶上,在initNeighbor方法中路(白色格)的鄰格已經是+2而不是之前的+1了,因為+1是牆(灰色格)
再看重要的getNeighbor方法
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 |
Grid.prototype.getNeighbor = function() { var x, y, neighbor, ret = []; this.choosed = true; for(var i = 0; i < this.neighbor.length; i++) { x = this.neighbor[i].x; y = this.neighbor[i].y; neighbor = maze.grids[y][x]; neighbor.wallX = this.x + (x - this.x)/2; // 重要! neighbor.wallY = this.y + (y - this.y)/2; // 重要! if(!neighbor.choosed) { ret.push(neighbor); } } return ret; }; |
看起來我們獲得的是鄰格,但實際上我們要的是掛載在鄰格上的wallX和wallY屬性,所以我們可以把neighbor抽象的就看成是牆!!在下面findPath方法中就是這樣用的
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 |
Maze.prototype.findPath = function() { var tmp; var curr = firstGrid; var index; var walls = this.walls; tmp = curr.getNeighbor(); curr.isClear = true; // 標記 walls.push.apply(walls, tmp); while(walls.length) { index = (Math.random() * walls.length) >> 0; // 隨機取 wall = walls[index]; if(!wall.isClear) { // 如果不是通路 wall.isClear = true; this.path.push({ x: wall.wallX, // 重要! y: wall.wallY // 重要! }); tmp = wall.getNeighbor(); walls.push.apply(walls, tmp); // 加入更多的牆 } else { walls.splice(index, 1); // 如果是通路了就移除 } } console.log('路徑找尋結束', this.path); }; |
如果感覺有點繞的話可以結合原理圖再慢慢的看程式碼,核心理解的一點就是getNeighbor方法返回的x,y對應是路(白色格),而它的wallX,wallY對應的是牆(灰色格)
畫圖部分很簡單
1 2 3 4 5 6 7 8 9 10 11 12 13 |
for(i = 0; i <= 290; i+=20) { // 隔行畫橫線(橫牆) ctx.fillRect(0, i, 390, 10); } for(i = 0; i <= 390; i+=20) { // 隔行畫豎線(豎牆) ctx.fillRect(i, 0, 10, 290); } ctx.fillStyle = 'white'; for(i = 0; i < this.path.length; i++) { // 打通牆 ctx.fillRect(10 + this.path[i].x * 10, 10 + this.path[i].y * 10, 10, 10); } |
c)遞迴分割法
這個實在是超級簡單,原理簡單,演算法簡單,我就不介紹啦。一來這個生成的迷宮也超級簡單,一般不用於傳統迷宮遊戲;二來後面還有很多要介紹的,不浪費口水在這了
2、生成3D迷宮
此時我們已經有一個2D迷宮,我們可以將其看成是俯檢視,下面就是將其轉化為3D頂點資訊
注:這篇文章不負責介紹webgl!!我也儘量避開webgl知識,通俗一點的介紹給大家~
將2D轉3D,首先非常重要的一點就是座標系的轉化
2D的座標系是這樣的
3D的座標系是這樣的
感覺到蛋疼就對了~後面考慮到攝像機近平面的碰撞計算還得蛋碎呢~
其實這個座標轉換並不難,首先我們先通過2D迷宮獲得牆面的資訊(黑色部分)
下面這段程式碼是獲得橫牆資訊的
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 |
function getRowWall() { var i = 0; var j = 0; var x1, x2; console.log('getRowWall'); for(; i < height; i += 10) { rowWall[i] = []; j = 0; while(j < width) { if(isBlack(j, i)) { x1 = j; // 記錄橫牆開始點 j += 10; while(isBlack(j, i) && j < width) { j += 10; } x2 = j; // 記錄橫牆結束點 if((x2 - x1) > 10) { // 這步很關鍵!! rowWall[i].push({ x1: 2 * (x1 / width) - 1, x2: 2 * (x2 / width) - 1 }); } } j += 10; } } // console.log(rowWall); } |
結果會得到一個陣列,注意一下注釋中很關鍵的一步,為什麼要大於10
下面兩張圖給你答案
總結就是小於等於10px的橫牆,那它的本體一定是豎牆,10px也是那一行正好看到的,我們就將他們過濾掉了
得到豎牆資訊同理,原始碼可見,我就不貼出來了
下面這段程式碼是2D座標轉化為頂點資訊
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 |
// k1和k2算作Z軸 for(i = 0; i < rowWall.length; i += 10) { // rowWall.length item = rowWall[i]; while((tmp = item.pop())) { k1 = (2 * i / height) - 1; k2 = (2 * (i + 10) / height) - 1; po_data.push.apply(po_data, [ tmp.x1*120+0.01, -1.09, k1*120, // 左下 tmp.x2*120+0.01, -1.09, k1*120, // 右下 tmp.x2*120+0.01, 0.2, k1*120, // 右上 tmp.x1*120+0.01, 0.2, k1*120, // 左上 tmp.x2*120+0.01, -1.09, k1*120, tmp.x2*120+0.01, -1.09, k2*120, tmp.x2*120+0.01, 0.2, k2*120, tmp.x2*120+0.01, 0.2, k1*120, tmp.x1*120+0.01, -1.09, k2*120, tmp.x2*120+0.01, -1.09, k2*120, tmp.x2*120+0.01, 0.2, k2*120, tmp.x1*120+0.01, 0.2, k2*120, tmp.x1*120+0.01, -1.09, k1*120, tmp.x1*120+0.01, -1.09, k2*120, tmp.x1*120+0.01, 0.2, k2*120, tmp.x1*120+0.01, 0.2, k1*120, tmp.x1*120+0.01, 0.2, k1*120, tmp.x2*120+0.01, 0.2, k1*120, tmp.x2*120+0.01, 0.2, k2*120, tmp.x1*120+0.01, 0.2, k1*120 ]); } } |
乘以120是我3D空間中X軸和Z軸各放大了120倍,沒有寫在模型變換矩陣裡面,Y軸的方法在模型變化矩陣中,不過那不重要。
陣列中三個單位為一點,四個點為一個面,五個面為3D迷宮中一堵牆(底面的不管)
後面是webgl裡面常規操作,各種矩陣、繫結buffer、繫結texture等等balabala,原生webgl寫起來是比較累,無視了光和陰影還要寫這麼多T_T
3、攝像機碰撞檢測
如果說前面的程式碼寫著很累看著累,那這裡的就更累了……
攝像機是什麼?在3D中攝像機就是玩家的視角,就是通過滑鼠和w,s來移動的webgl可視區,那麼在2D中攝像機對映為什麼呢?
2D中攝像機就是紅色的那個圈圈的右點,如圖!
那麼大的圈圈只是方便看而已……
碰撞檢測的作用是防止出現透視現象,透視現象如下圖所示:
要介紹透視現象出現的原因,就得先了解一下視錐體,如圖:
看到近平面了嗎,當物體穿過近平面,就會出現透視現象了
我們遊戲中近平面距離是0.1,所以可能看成圍繞原點有一個矩形,只要讓矩形碰到不邊,那就不會出現透視現象
矩形的寬度我設定為2,設大了一些,也沒必要讓玩家貼牆貼的那麼近……
我們通過呼叫攝像機的move方法觸發Role.prototype.update方法
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 |
move: function(e){ // 只考慮x和z軸移動,cx,cy是轉換為2D的方向 cx = Math.sin(-this.rot) * e; cy = Math.cos(-this.rot) * e; this.x += cx; this.z += cy; ret = role.check(-this.x/120, this.z/242, -cx, cy); // 後兩個引數代表方向 if(ret.x === 0) { this.x -= cx; } else { role.x = ret.x; } if(ret.y === 0) { this.z -= cy; } else { role.y = ret.y; } role.update(); } |
而update方法裡面更新x0,x2,y0,y2就是對應那四個點,這四個點在check方法裡面用到,check通過則移動攝像機,否則不移動
攝像機與牆的整體檢測在Role.prototype.isWall中,注意這裡有兩個引數,cx和cy,這個是方向,確切的說是將要移動的方向,然後我們根據方向,只會從這四個點中取三個來判斷會不會有碰撞
每個點的檢測通過Role.prototype.pointCheck方法,通過畫素來判斷的,發現是黑色值(rgb中的r為0)那麼就認為撞上了,會在2D中標記黃色。如果你貼著牆走,就會發現黑色的牆都被染成黃色啦~
結語:
寫累死,這還是在把webgl裡面知識點大部分丟掉的情況下。迷宮整體比較簡單,就兩張貼圖,地面也很簡陋,最近需求比較多,很忙,沒太多時間去美化。有興趣的同學可以做一款屬於自己棒棒的迷宮遊戲~
感興趣有疑問的可以留言一起交流~