3-地圖生成-從零開始寫一個武俠冒險遊戲

自由布魯斯發表於2016-06-13

從零開始寫一個武俠冒險遊戲-3-地圖生成

概述

前面兩章我們設計了角色的狀態, 繪製出了角色, 並且賦予角色動作, 現在是時候為我們的角色創造一個舞臺了, 那就是遊戲地圖(我們目前做的是一個2D 遊戲, 因此叫地圖, 如果是 3D, 則叫地形).

地圖生成也是遊戲開發的一項基本技術, 涉及到方方面面的技能, 而且地圖的資料結構要考慮到遊戲裡的其他景物跟角色的顯示和互動, 對於整個遊戲程式的效率起著決定性的影響, 不過我們這裡先解決有沒有的問題, 目標不太高, 能流暢執行就可以了.

最簡原型

跟我們一向提倡的大思路一致, 一切從簡出發, 先弄個原型跑起來再說.

經驗之談: 很多開發過程中的難題都是因為我們一開始就引入了過於複雜的問題, 制定了太大的目標, 試圖一開始就把方方面面都考慮到, 結果無形中就增加了難度, 不得不承認, 這種頂層設計的思路是不太符合事物發展的規律的, 也不符合生物的進化規律, 所以實現起來就比較困難, 如果我們遵循從簡單到複雜, 從原型到成品的開發思路, 就會發現開發過程變得順利很多.

遊戲地圖原理

簡單說來, 遊戲地圖有兩個層面, 一個是顯示到螢幕上的圖形影像, 一個是隱藏在影像後面的資料結構, 前者是遊戲跟玩家互動的介面, 後者是遊戲中繪製出來的各種物件跟程式互動的介面.

比如玩家操縱一個遊戲角色從左邊一個位置走到右邊一個位置, 玩家看到的是螢幕上角色的移動過程, 而程式在後面要記錄玩家每時每刻的座標, 以及該座標在地圖上對應的位置.

如果玩家看到地圖上某個位置有一個可以操作的物體, 比如一個箱子, 玩家的角色想要靠近這個箱子然後開啟它, 那麼後臺的地圖資料庫裡首先要在地圖的某個位置上有一個箱子, 然後再判斷角色距離箱子的距離, 如果小於某個值, 那麼就說明允許操作, 玩家開過箱子後, 還要把箱子的當前狀態(已開啟)再寫回到資料庫裡, 等等諸如此類.

最簡單的地圖

最簡單的地圖就是一張事先畫好的圖, 角色在這張圖上移來移去, 這個功能我們在第2章就已經實現了, 但是按照這種方法實現的地圖角色很難跟地圖上的物體進行互動, 而且使用事先畫好的圖做地圖還有一個問題就是如果整個遊戲場景比較大的話就需要很多畫預先儲存到遊戲中, 這樣會導致較大的體積.

所以, 我們採取另一種做法, 因為遊戲場景中很多物體物件都是可以重複使用的, 比如樹木, 岩石等等, 所以我們可以把這些基本物件提取出來事先繪製好, 或者使用預先做好的素材, 這樣我們需要事先儲存的內容就大大減少了, 然後再根據實際需要動態繪製上去, 這就是隨機生成場景地圖的做法.

恰好我之前寫過一個簡單的隨機地圖生成器, 雖然比較簡陋, 不過為了減少工作量, 還是可以拿來用用的, 當然, 直接用是不行的, 主要是以它做一個基礎來進行改寫.

原型目標

首先明確一下我們這個地圖原型的基本需求點:

  • 可以靈活調整地圖大小
  • 可以隨機插入樹木/礦物/建築等固定物體
  • 角色可以跟地圖上的這些物體互動

這是三個最基本的需求, 我們一步一步來實現這三個需求.

格子地面地圖

綜合效能和實現難度方面的考慮, 我們的地圖以網格的形式進行繪製和儲存, 也就是以我們之前寫好的那個隨機地圖生成器為基本原型, 這樣一方面可以靈活控制資料表的大小, 資料表中儲存的最小單位就是一個預先設定好大小的格子, 另一方面寫起來也比較簡單, 還有不錯的效率表現.

首先確定我們的初始化引數和資料結構, 用這個函式來實現:

function initParams()
    print("Simple Map Sample!!")
    textMode(CORNER)
    spriteMode(CORNER)

    --[[
    gridCount:網格數目,範圍:1~100,例如,設為3則生成3*3的地圖,設為100,則生成100*100的地圖。
    scaleX:單位網格大小比例,範圍:1~100,該值越小,則單位網格越小;該值越大,則單位網格越大。
    scaleY:同上,若與scaleX相同則單位網格是正方形格子。
    plantSeed:植物生成機率,範圍:大於4的數,該值越小,生成的植物越多;該值越大,生成的植物越少。
    minerialSeed:礦物生成機率,範圍:大於3的數,該值越小,生成的礦物越多;該值越大,生成的礦物越少。
    --]]
    gridCount = 50
    scaleX = 50
    scaleY = 50
    plantSeed = 20.0
    minerialSeed = 50.0

    -- 根據地圖大小申請影像
    local w,h = (gridCount+1)*scaleX, (gridCount+1)*scaleY
    imgMap = image(w,h)

    -- 整個地圖使用的全域性資料表
    mapTable = {}

    -- 設定物體名稱
    tree1,tree2,tree3 = "松樹", "楊樹", "小草"    
    mine1,mine2 = "鐵礦", "銅礦"

    -- 設定物體影像
    imgTree1 = readImage("Planet Cute:Tree Short")
    imgTree2 = readImage("Planet Cute:Tree Tall")
    imgTree3 = readImage("Platformer Art:Grass")
    imgMine1 = readImage("Platformer Art:Mushroom")
    imgMine2 = readImage("Small World:Treasure")

    -- 存放物體: 名稱,影像
    itemTable = {[tree1]=imgTree1,[tree2]=imgTree2,[tree3]=imgTree3,[mine1]=imgMine1,[mine2]=imgMine2}

    -- 3*3 
    mapTable = {{pos=vec2(1,1),plant=nil,mineral=mine1},{pos=vec2(1,2),plant=nil,mineral=nil},
                {pos=vec2(1,3),plant=tree3,mineral=nil},{pos=vec2(2,1),plant=tree1,mineral=nil},
                {pos=vec2(2,2),plant=tree2,mineral=mine2},{pos=vec2(2,3),plant=nil,mineral=nil},
                {pos=vec2(3,1),plant=nil,mineral=nil},{pos=vec2(3,2),plant=nil,mineral=mine2},
                {pos=vec2(3,3),plant=tree3,mineral=nil}}

end

接下來是繪製地面單位格子的函式, 現在是在每個格子上繪製一個矩形, 引數 position 是一個二維向量, 形如 vec(1,2) 則表示該格子位於第1行, 第2列, 程式碼如下:

-- 繪製單位格子地面
function drawUnitGround(position)
    local x,y = scaleX * position.x, scaleY * position.y
    pushMatrix()
    stroke(99, 94, 94, 255)
    -- 網格線寬度
    strokeWidth(1)
    -- 地面顏色
    fill(5,155,40,255)
    -- fill(5,155,240,255)
    rect(x,y,scaleX,scaleY)
    popMatrix()
end

用這兩個函式來呼叫它:

-- 新建地圖資料表, 插入地圖上每個格子裡的物體資料
function createMapTable()
    for i=1,gridCount,1 do
        for j=1,gridCount,1 do
            mapItem = {pos=vec2(i,j), plant=nil, mineral=nil}
            table.insert(mapTable, mapItem)
        end
    end
    updateMap()
end

-- 更新地圖
function updateMap()
    setContext(imgMap)   
    for i = 1,gridCount*gridCount,1 do
        local pos = mapTable[i].pos
        -- 繪製地面
        drawUnitGround(pos)
    end
    setContext()
end

-- 繪製地圖
function drawMap() 
    -- 繪製地圖
    sprite(imgMap,-scaleX,-scaleY)
end

最基本原型的完整程式碼

下面我們把實現這個最基本原型的完整程式碼列出來:

-- MapSample

-- 初始化地圖引數
function initParams()
    print("地圖初始化開始...")
    textMode(CORNER)
    spriteMode(CORNER)

    --[[ 引數說明:
    gridCount:網格數目,範圍:1~100,例如,設為3則生成3*3的地圖,設為100,則生成100*100的地圖。
    scaleX:單位網格大小比例,範圍:1~100,該值越小,則單位網格越小;該值越大,則單位網格越大。
    scaleY:同上,若與scaleX相同則單位網格是正方形格子。
    plantSeed:植物生成機率,範圍:大於4的數,該值越小,生成的植物越多;該值越大,生成的植物越少。
    minerialSeed:礦物生成機率,範圍:大於3的數,該值越小,生成的礦物越多;該值越大,生成的礦物越少。
    --]]
    gridCount = 50
    scaleX = 50
    scaleY = 50
    plantSeed = 20.0
    minerialSeed = 50.0

    -- 根據地圖大小申請影像
    local w,h = (gridCount+1)*scaleX, (gridCount+1)*scaleY
    imgMap = image(w,h)

    -- 整個地圖使用的全域性資料表
    mapTable = {}

    -- 設定物體名稱
    tree1,tree2,tree3 = "松樹", "楊樹", "小草"    
    mine1,mine2 = "鐵礦", "銅礦"

    -- 設定物體影像
    imgTree1 = readImage("Planet Cute:Tree Short")
    imgTree2 = readImage("Planet Cute:Tree Tall")
    imgTree3 = readImage("Platformer Art:Grass")
    imgMine1 = readImage("Platformer Art:Mushroom")
    imgMine2 = readImage("Small World:Treasure")

    -- 存放物體: 名稱,影像
    itemTable = {[tree1]=imgTree1,[tree2]=imgTree2,[tree3]=imgTree3,[mine1]=imgMine1,[mine2]=imgMine2}

    -- 3*3 
    mapTable = {{pos=vec2(1,1),plant=nil,mineral=mine1},{pos=vec2(1,2),plant=nil,mineral=nil},
                {pos=vec2(1,3),plant=tree3,mineral=nil},{pos=vec2(2,1),plant=tree1,mineral=nil},
                {pos=vec2(2,2),plant=tree2,mineral=mine2},{pos=vec2(2,3),plant=nil,mineral=nil},
                {pos=vec2(3,1),plant=nil,mineral=nil},{pos=vec2(3,2),plant=nil,mineral=mine2},
                {pos=vec2(3,3),plant=tree3,mineral=nil}}

end

-- 新建地圖資料表, 插入地圖上每個格子裡的物體資料
function createMapTable()
    for i=1,gridCount,1 do
        for j=1,gridCount,1 do
            mapItem = {pos=vec2(i,j), plant=nil, mineral=nil}
            table.insert(mapTable, mapItem)
        end
    end
    updateMap()
end

-- 跟據地圖資料表, 重新整理地圖
function updateMap()
    setContext(imgMap)   
    for i = 1,gridCount*gridCount,1 do
        local pos = mapTable[i].pos
        -- 繪製地面
        drawUnitGround(pos)
    end
    setContext()
end

-- 繪製單位格子地面
function drawUnitGround(position)
    local x,y = scaleX * position.x, scaleY * position.y
    pushMatrix()
    stroke(99, 94, 94, 255)
    -- 網格線寬度
    strokeWidth(1)
    -- 地面顏色
    fill(5,155,40,255)
    -- fill(5,155,240,255)
    rect(x,y,scaleX,scaleY)
    popMatrix()
end

-- 遊戲主程式框架
function setup()
    displayMode(OVERLAY)

    initParams()
end

function draw()
    background(40, 40, 50)    

    -- 繪製地圖
    drawMap()
end

看看截圖:

只有地面的地圖原型

很好, 基本的格子地圖寫好了, 接著我們來解決在格子地圖上隨機插入樹木/礦物/建築等固定物體的功能.

插入物體

因為我們已經在設計資料表時就考慮到了要插入固定物體, 所以現在需要做的就是寫幾個相關的函式, 首先是兩個隨機選取物體名字的函式:

-- 隨機生成植物
function randomPlant()
    local seed = math.random(1.0, plantSeed)
    local result = nil

    if seed >= 1 and seed < 2 then result = tree1
    elseif seed >= 2 and seed < 3 then result = tree2
    elseif seed >= 3 and seed < 4 then result = tree3
    elseif seed >= 4 and seed <= plantSeed then result = nil end

    -- 返回隨機選取的物體名字
    return result
end

-- 隨機生成礦物
function randomMinerial()
    local seed = math.random(1.0, minerialSeed)
    local result = nil

    if seed >= 1 and seed < 2 then result = mine1
    elseif seed >= 2 and seed < 3 then result = mine2
    elseif seed >= 3 and seed <= minerialSeed then result = nil end

    -- 返回隨機選取的物體名字
    return result
end

然後增加兩個繪製函式, 來繪製出物體的影像:

-- 繪製單位格子內的植物
function drawUnitTree(position,plant)
    local x,y = scaleX * position.x, scaleY * position.y
    pushMatrix()
    -- 繪製植物影像
    sprite(itemTable[plant], x, y, scaleX*6/10,scaleY)

    --fill(100,100,200,255)
    --text(plant,x,y)
    popMatrix()
end

-- 繪製單位格子內的礦物
function drawUnitMineral(position,mineral)
    local x,y = scaleX * position.x, scaleY * position.y
    pushMatrix()
    -- 繪製礦物影像
    sprite(itemTable[mineral], x+scaleX/2, y, scaleX/2, scaleX/2)

    --fill(100,100,200,255)
    --text(mineral,x+scaleX/2,y)
    popMatrix()
end

最後需要修改函式 createMapTable()updateMap(), 在其中增加對 plantmineral 的處理, 修改後的程式碼如下:

-- 新建地圖資料表, 插入地圖上每個格子裡的物體資料, 目前為 plant  和 mineral 為空
function createMapTable()
    --local mapTable = {}
    for i=1,gridCount,1 do
        for j=1,gridCount,1 do
            mapItem = {pos=vec2(i,j), plant=randomPlant(), mineral=randomMinerial()}
            --mapItem = {pos=vec2(i,j), plant=nil, mineral=nil}
            table.insert(mapTable, mapItem)
        end
    end
    updateMap()
end

-- 跟據地圖資料表, 重新整理地圖
function updateMap()
    setContext(imgMap)   
    for i = 1,gridCount*gridCount,1 do
        local pos = mapTable[i].pos
        local plant = mapTable[i].plant
        local mineral = mapTable[i].mineral
        -- 繪製地面
        drawUnitGround(pos)
        -- 繪製植物和礦物
        if plant ~= nil then drawUnitTree(pos, plant) end
        if mineral ~= nil then drawUnitMineral(pos, mineral) end
    end
    setContext()
end

非常好, 第二個基本目標也完成了, 截個圖:

插入植物礦物的完整地圖

看看現在的截圖效果, 是不是感覺我們的原型正在一步步走向完善? 緊接著就要想辦法實現角色跟地圖上物體的互動了, 想做到這一點, 首先需要建立角色跟地圖在地圖資料表中的資料關聯.

建立角色跟地圖的關聯

現在地圖繪製好了, 角色也可以自由地在地圖上活動了, 不過這只是我們看到的表面現象, 實際在隱藏於螢幕後面的程式程式碼中, 角色的位置跟地圖的座標(方格)並沒有建立任何關聯.

例如, 角色在地圖上看到一棵樹, 他想要對這棵樹做一些動作(觀察/澆水/砍伐 等)進行互動, 如果角色選擇了砍伐樹, 那麼最終樹被砍倒之後我們還需要更新地圖資料表, 把對應位置的樹的圖片更換成樹根, 而實現角色跟樹的互動, 就需要根據角色位置座標跟樹的位置座標進行判斷.

我們知道樹的位置座標已經儲存在地圖的資料表中了, 但是角色的座標跟地圖的資料表還沒有任何關係, 因為角色經常移動, 所以我們可以寫一個函式, 根據角色的螢幕畫素點座標來計算所處的地圖方格座標, 程式碼如下:

-- 根據畫素座標值計算所處網格的 i,j 值
function where(x,y)
    local i = math.ceil((x+scaleX) / scaleX)
    local j = math.ceil((y+scaleY) / scaleY)
    return i,j
end

有了這個函式, 我們只要把角色當前位置的畫素點座標輸入, 就可以得到它所處網格的座標, 這樣就把角色跟地圖從資料層面建立了關聯. 後續就可以方便地通過這個介面來處理他們之間的互動了.

為方便後續程式碼維護, 我們要把上述程式碼改寫為一個地圖生成類, 改寫後的完整程式碼如下:

-- MapSample

Maps = class()

function Maps:init()
    --[[
    gridCount:網格數目,範圍:1~100,例如,設為3則生成3*3的地圖,設為100,則生成100*100的地圖。
    scaleX:單位網格大小比例,範圍:1~100,該值越小,則單位網格越小;該值越大,則單位網格越大。
    scaleY:同上,若與scaleX相同則單位網格是正方形格子。
    plantSeed:植物生成機率,範圍:大於4的數,該值越小,生成的植物越多;該值越大,生成的植物越少。
    minerialSeed:礦物生成機率,範圍:大於3的數,該值越小,生成的礦物越多;該值越大,生成的礦物越少。
    --]]
    self.gridCount = 50
    self.scaleX = 50
    self.scaleY = 50
    self.plantSeed = 20.0
    self.minerialSeed = 50.0

    -- 根據地圖大小申請影像
    local w,h = (self.gridCount+1)*self.scaleX, (self.gridCount+1)*self.scaleY
    self.imgMap = image(w,h)

    -- 整個地圖使用的全域性資料表
    self.mapTable = {}

    -- 設定物體名稱
    tree1,tree2,tree3 = "松樹", "楊樹", "小草"    
    mine1,mine2 = "鐵礦", "銅礦"

    -- 設定物體影像
    imgTree1 = readImage("Planet Cute:Tree Short")
    imgTree2 = readImage("Planet Cute:Tree Tall")
    imgTree3 = readImage("Platformer Art:Grass")
    imgMine1 = readImage("Platformer Art:Mushroom")
    imgMine2 = readImage("Small World:Treasure")

    -- 存放物體: 名稱,影像
    self.itemTable = {[tree1]=imgTree1,[tree2]=imgTree2,[tree3]=imgTree3,[mine1]=imgMine1,[mine2]=imgMine2}

    -- 尺寸為 3*3 的資料表示例
    self.mapTable = {{pos=vec2(1,1),plant=nil,mineral=mine1},{pos=vec2(1,2),plant=nil,mineral=nil},
                {pos=vec2(1,3),plant=tree3,mineral=nil},{pos=vec2(2,1),plant=tree1,mineral=nil},
                {pos=vec2(2,2),plant=tree2,mineral=mine2},{pos=vec2(2,3),plant=nil,mineral=nil},
                {pos=vec2(3,1),plant=nil,mineral=nil},{pos=vec2(3,2),plant=nil,mineral=mine2},
                {pos=vec2(3,3),plant=tree3,mineral=nil}}


    print("地圖初始化開始...")
    -- 根據初始引數值新建地圖
    self:createMapTable()
    print("OK, 地圖初始化完成! ")
end

-- 新建地圖資料表, 插入地圖上每個格子裡的物體資料
function Maps:createMapTable()
    --local mapTable = {}
    for i=1,self.gridCount,1 do
        for j=1,self.gridCount,1 do
            self.mapItem = {pos=vec2(i,j), plant=self:randomPlant(), mineral=self:randomMinerial()}
            --self.mapItem = {pos=vec2(i,j), plant=nil, mineral=nil}
            table.insert(self.mapTable, self.mapItem)
        end
    end
    self:updateMap()
end

-- 根據地圖資料表, 重新整理地圖
function Maps:updateMap()
    setContext(self.imgMap)   
    for i = 1,self.gridCount*self.gridCount,1 do
        local pos = self.mapTable[i].pos
        local plant = self.mapTable[i].plant
        local mineral = self.mapTable[i].mineral
        -- 繪製地面
        self:drawGround(pos)
        -- 繪製植物和礦物
        if plant ~= nil then self:drawTree(pos, plant) end
        if mineral ~= nil then self:drawMineral(pos, mineral) end
    end
    setContext()
end

function Maps:drawMap() 
    sprite(self.imgMap,-self.scaleX,-self.scaleY)
end

-- 根據畫素座標值計算所處網格的 i,j 值
function Maps.where(x,y)
    local i = math.ceil((x+self.scaleX) / self.scaleX)
    local j = math.ceil((y+self.scaleY) / self.scaleY)
    return i,j
end

-- 隨機生成植物
function Maps:randomPlant()
    local seed = math.random(1.0, self.plantSeed)
    local result = nil

    if seed >= 1 and seed < 2 then result = tree1
    elseif seed >= 2 and seed < 3 then result = tree2
    elseif seed >= 3 and seed < 4 then result = tree3
    elseif seed >= 4 and seed <= self.plantSeed then result = nil end

    return result
end

-- 隨機生成礦物
function Maps:randomMinerial()
    local seed = math.random(1.0, self.minerialSeed)
    local result = nil

    if seed >= 1 and seed < 2 then result = mine1
    elseif seed >= 2 and seed < 3 then result = mine2
    elseif seed >= 3 and seed <= self.minerialSeed then result = nil end

    return result
end

function Maps:getImg(name)
    return self.itemTable[name]
end

-- 重置  
function Maps:resetMapTable()
    self.mapTable = self:createMapTable()
end

-- 繪製單位格子地面
function Maps:drawGround(position)
    local x,y = self.scaleX * position.x, self.scaleY * position.y
    pushMatrix()
    stroke(99, 94, 94, 255)
    strokeWidth(1)
    fill(5,155,40,255)
    -- fill(5,155,240,255)
    rect(x,y,self.scaleX,self.scaleY)
    --sprite("Documents:3D-Wall",x,y,scaleX,scaleY)
    popMatrix()
end

-- 繪製單位格子內的植物
function Maps:drawTree(position,plant)
    local x,y = self.scaleX * position.x, self.scaleY * position.y
    pushMatrix()
    -- 繪製植物影像
    sprite(self.itemTable[plant],x,y,self.scaleX*6/10,self.scaleY)

    --fill(100,100,200,255)
    --text(plant,x,y)
    popMatrix()
end

-- 繪製單位格子內的礦物
function Maps:drawMineral(position,mineral)
    local x,y = self.scaleX * position.x, self.scaleY * position.y
    pushMatrix()
    -- 繪製礦物影像
    sprite(self.itemTable[mineral],x+self.scaleX/2,y,self.scaleX/2,self.scaleX/2)

    --fill(100,100,200,255)
    --text(mineral,x+self.scaleX/2,y)
    popMatrix()
end

-- 遊戲主程式框架
function setup()
    displayMode(OVERLAY)

    myMap = Maps()
end

function draw()
    background(40, 40, 50)    

    -- 繪製地圖
    myMap:drawMap()
end
```

到目前為止, 我們在地圖生成原型章節的目標基本完成, 下一章我們會嘗試把 狀態 , 幀動畫地圖生成 這三個模組整合起來, 一般來說事物發展到 的階段會由量變觸發質變, 我們這個程式也一樣, 會在這次整合之後, 從一個個零散簡陋的原型, 一躍而成一個還能看得過去的基本框架, 是不是很期待?

激動人心的新起點

事實上, 把角色螢幕位置跟地圖資料表建立關聯之後, 我們的角色就真正存在於這個遊戲世界中了, 它可以自由地跟地圖上的每一個物體進行互動, 這意味著一個全新的激動人心的開始! 到現在為止, 我們遊戲世界的基本框架已經搭建起來了, 我們可以在這個框架上試驗自己對於武俠冒險遊戲的各種新想法.

所有章節連結

從零開始寫一個武俠練功遊戲-1-狀態原型
從零開始寫一個武俠練功遊戲-2-幀動畫
從零開始寫一個武俠練功遊戲-3-地圖生成

相關文章