進階篇,第一章:礦物的生成

<基於1.8 Forge的Minecraft mod製作經驗分享>

一、礦物的生成器

  1. 生成器實例的創建

    MC裏面提供了一個簡單的可開採礦石生成器:WorldGenMinable。要使用WorldGenMinable,你得先擁有一個它的實例對象。那麼我們就來分析它的構造器好了:

    public WorldGenMinable(IBlockState p_i45630_1_, int p_i45630_2_);
    public WorldGenMinable(IBlockState p_i45631_1_, int p_i45631_2_, Predicate p_i45631_3_);

    閉着眼睛也猜得到,第一個構造器肯定內部調用了第二個構造器來實現。它幫我們填上了一個Predicate參數。那麼這個參數是什麼呢?它是一個接口,源碼中描述它是用來“判定”的。有一個BlockHelper類實現了它,用來比較MC中的方塊。第一個構造器默認傳入的就是一個BlockHelper實例,作用是判定方塊是否是石頭。

    基於以上分析,你應該基本認識到了兩點:1、要在自然環境下生成礦物,你只需要調用第一個構造器來建立生成器的實例就夠了。2、如果需要的話,你可以用第二個構造器來指定替換掉什麼礦物。

    我們就拿第一個構造器來實例化礦石生成器吧,另一個你自己同理。

    首先,IBlockState是方塊的一個簡單的描述接口,每個方塊都有各自的IBlockState實例,可以通過Block的getDefaultState();方法取得。IBlockState也有一格getBlock();方法,可以反向取得相應的方塊的對象。用這個作爲參數,比起直接傳入Block更輕型,而且接口做參數會有更好的可擴展、可維護性。

    其次,第二個參數int p_i45630_2_是每次生成器工作時最多可能生成的礦石數量。它的實際工作原理是,生成器循環p_i45630_2_次生成礦石的過程,但每次是否成功生成取決於隨機概率,以及那個將要生成礦石的地點是否已被不可替代的礦石佔據(調用第一個構造器的話,就是非stone的方塊),所以p_i45630_2_就成了最大生產數量了。

    那麼現在,我們就可以創建一個生成器了,就拿我的斗羅大陸mod的鐵母礦舉例吧:

    WorldGenMinable ironEssenceOreGenerator = new WorldGenMinable(DouroMod.ironEssenceOre.getDefaultState(), 12);

  2. 使用生成器生成礦物

    當我們成功創建了一個生成器後,我們就要用它來生成礦石了。WorldGenMinable類的生成方法原形:

    public boolean generate(World worldIn, Random p_180709_2_, BlockPos p_180709_3_);

    第一個參數是世界的實例,衆所周知MC裏面有主世界、下界以及末地三個世界,甚至還有各mod自己創建的世界。那麼你要生成礦石,就得告訴它你要在哪個世界生成對吧。對於第一個參數World,你當然不可能立即新建一個,一般是由上層方法傳入,後面會說。但你也可以主動獲取, Minecraft.getMinecraft().theWorld,取得當前的世界。

    第二個參數,隨機數發生器,Java的一個標準庫,裏面封裝好了非常科學的隨機數取得的方法,一般我們都會從這裏而不是Math來取得隨機數,因爲Math取得隨機數有很多坑,很容易弄錯。這個Random參數你倒是可以自己創建,但請注意兩個問題:1、玩MC的應該知道,生成世界時會有一個隨機種子,通過這個種子我們總是能重構出同樣的地貌,玩Java的你應該立刻意識到,這個種子即Random的種子,每次都能重構世界正是因爲這個種子的唯一性。如果你自己創建了一個新的Random,那麼你就會打破這一點,玩家會發現你的mod的特有礦物會隨着每次重構而變化,甚至引起MC本身地形地貌的變化,我猜這不是你想要的吧?2、新建Random開銷不算大,但你要知道,一般生成礦物這種事情是會短時間內發生非常多次的,尤其在世界第一次創建時,這樣累計下來的開銷還是很可觀的。不過在某些情況下你可能真的需要自己創建一個Random,比如你只是一次性生成一片礦石,這可能是由於玩家的某個技能引起,它與世界本身的隨機種子無關,與重構無關。再比如你是在遊戲開始前的UI界面,搞了些隨機事件,比如隨機顯示一些東西,這時世界還沒有創建呢,何談的隨機種子與隨機對象。

    第三個參數BlockPos,要生成礦石的位置。這個參數本身沒問題,問題出在它容易與另一個概念:Chunk搞混。Chunk是區塊,代表了x、z軸平面上16x16個單位的一個區域,Chunk也有座標值,但這個值是每個區塊在所有區塊單位上的座標,大多數情況下你可以這麼換算它:chunkX * 16 =blockPosX,chunkZ同理,但我不確定是不是存在某些mod修改了這一規則。如果你不幸的把Chunk與BlockPos弄混了,你會遭遇這兩種尷尬:1、滿世界找都找不到你生成的礦石,無論你把概率調多大。2、某個地方滿滿的都是礦石,無論你把概率調多小。原因你應該能想到了吧?

    分析完了這些,接下來的事情就簡單了,在之前創建的實例,ironEssenceOreGenerator 上調用generate方法,

    ironEssenceOreGenerator.generate(world, random, genPos);
    就這麼簡單。

二、礦物的生成算法

不要看到算法就嚇到,很簡單。

  1. 預備知識:礦脈的稀有度

    在前文的WorldGenMinable裏,我們似乎並沒有看到與礦石稀有度相關的設定,其構造器的第二個參數int p_i45631_2_只是控制了每次最大生成礦石數量,這會影響礦脈的大小,不會影響礦脈的出現頻率,而礦脈的稀有度實際上是反映在一定範圍內,礦脈出現的頻率。而這個頻率其實不是WorldGenMinable,礦石生成器能決定的,而是由調用生成器的generate方法的次數決定的。在MC中,我們以區塊爲單位,那麼對於每個區塊,調用生成器的次數越多,自然生成的礦脈越多,礦脈的稀有度就越低。

  2. 零散常見的礦物的生成。比如煤、鐵這樣的

    public void generateMainWorld(Random random, int posX, int posZ, World world)
    {
        for(int i = 0; i < 30; i++)          //循環生成30次礦脈
        {
            //隨機一個地點,作爲生成礦脈的中心
            BlockPos genPos = new BlockPos(
                            event.pos.getX() + event.rand.nextInt(16),
                event.rand.nextInt(64),
                event.pos.getZ() + event.rand.nextInt(16));
            generator.generate(event.world, event.rannd, genPos);
        }
    }

    其中,循環生成30次,意味着以傳入的座標爲起點的x、y軸平面上16x16單位圍成的立體區域,即一個區塊中有30條礦脈。而generator實例的創建,new WorldGenMinable();中,傳入的第二個參數則會設定的較小,比如4。那麼,你就可以在一個區塊內找到很多零散的但每次都只有幾塊的礦藏。那麼每個區塊平均可能找到的礦石數量是(4 / 2) * 30 = 60個。

  3. 稀有但巨大的礦脈的生成。大概類似與村莊這樣,雖然它不是礦物

    public void generateMainWorld(Random random, int posX, int posZ, World world)
    {
        if (random.nextInt() % 30 == 0)    //1/30的概率生成礦脈
        {
            //隨機一個地點,作爲生成礦脈的中心
            BlockPos genPos = new BlockPos(
                event.pos.getX() + event.rand.nextInt(16),
                event.rand.nextInt(64),
                event.pos.getZ() + event.rand.nextInt(16));
            generator.generate(event.world, event.rannd, genPos);
        }
    }

    其中random.nextInt() % 30 == 0意味着平均每30個區塊,也就是480 * 480的區域才能找到1條礦脈。相應的,你需要把generator創建的參數中的最大生成數量調大,比如3600。平均下來,每個區塊可能找到的礦石數量(3600 / 2) /30 = 60,還是60個!礦脈非常稀有,但由於儲量很大,礦石稀有度不變。弄明白了這些,接下來你就可以簡單的設計你的礦石生成規則了。是的,簡單的設計,因爲你只是在調節稀有度、礦石稀有度。

  4. 一個建議:不要 在生成方法內部創建WorldGenMinable實例

    需要注意,我之所以不把generator的實例創建方法寫上去,就是爲了防止你在generateMainWorld();方法內部創建實例!!!試想,MC裏創建一個世界要生成多的區塊,每個區塊生成30次礦脈,你如果用這種寫法:new WorldGenMinable(...).generate(...);,會造成多少不必要的開銷!所以記住,爲你要生成的礦物創建一個生成器實例,然後hold it!還有,循環次數不要設置的太高,尤其在最大生成礦物數量也不低的情況下。

    至於BlockPos,那是實在沒辦法的,雖然它真正意義上需要的只是三個座標,但卻是不可變的。如果上層傳入的是座標,你基本上也只能新建一個BlockPos。如果上層傳入的是一個BlockPos,比如public void generateMainWorld(Random random, BlockPos pos, World world);,那麼你可以選擇這麼用:pos.add(int x, int y, int z);。但實際上意義不大,BlockPos裏全是final域,add();方法也並未對此做出任何優化。你看看add方法內的源碼就懂了:

    public BlockPos add(int x, int y, int z) { return new BlockPos(this.getX() + x, this.getY() + y, this.getZ() + z); }

三、礦物的生成時機

  1. 生成世界的辨識

    前文說過,你需要一個World實例作爲參數,來確定礦石產生在哪個世界。這個參數可以主動從MC實例中獲取(Minecraft.getMinecraft().theWorld),也可以由上層方法傳入。那麼怎麼識別這個World實例是哪個世界呢?你需要用到這個類:WorldProvider,世界提供器。你可以從中獲取到詳細的WorldType實例,但更簡單的用法是,直接調用它的getDimensionId();方法獲取世界的dimensionId,維度值。這是個int值,一般情況下-1代表下界,0代表主世界,1代表末地。


  2. 生成算法的插入

    我們之前已經寫好了生成算法,但它還一直處於No usages狀態。要是它能起作用,我們必須將它插入到MC裏面去。我們可以觸發式的調用這個方法,比如玩家釋放了某個技能,但更一般的,我們希望在世界生成時能執行到這個方法,來自然生成我們的礦石。也就是說,我們需要對我們的算法進行註冊,使它插入到生成世界、創建區塊的流程裏。這種情況下,Forge爲我們準備了兩套方案:


    a、註冊到GameRegistry裏面去:

    通過

    GameRegistry.registerWorldGenerator(IWorldGenerator generator, int modGenerationWeight);
    方法,我們可以向MC的世界生成過程裏註冊一個實現了IWorldGenerator接口的對象。這個接口只有一個
    public void generate(Random random, int chunkX, int chunkZ, World world, IChunkProvider chunkGenerator, IChunkProvider chunkProvider);
    方法,一般情況下,你需要做的就是先switch (world.provider.getDimensionId()),以區分當前世界,上文已經講的很清楚了。然後根據不同的case調用你的生成算法。之後在MC需要創建新的區塊時,就會在你註冊的實例上調用IWorldGenerator的generate方法,執行你的算法,生成你的礦石。記得注意,傳進來的參數是chunkX,chunkZ,要轉換成BlockPos才能用!


    b、註冊到ORE_GEN_BUS裏面去:

    ORE_GEN_BUS是Forge提供的事件總線之一,專門負責礦物的生成。關於Forge的事件總線,後面會具體講解。

    通過

    MinecraftForge.ORE_GEN_BUS.register(Object target);
    方法,你可以把一個類註冊到ORE_GEN_BUS裏面。但這個類需要一個帶有@SubscribeEvent註解的方法,作爲事件的監聽器。@SubscribeEvent也是通過參數識別的,它的參數可以是任意Forge中的事件,後面會具體的講。現在,我們可以翻看源碼,找到OreGenEvent.GenerateMinable這個事件,顧名思義,當生成可開採的礦藏時會觸發這個事件,準確的說是發出這個事件的廣播。那麼,ORE_GEN_BUS中已註冊的全部帶有@SubscribeEvent註解,且含有唯一參數OreGenEvent.GenerateMinable event的方法都會收到這個事件廣播,於是機會來了,你可以開始做羞羞事了,把你的生成方法加入到這個方法裏,然後視情況設置event的返回值,event.setResult(),來決定原來要生成的礦物還是否繼續生成了,具體Ctrl + clickL自己看。但要注意,這個方法是每次區塊要生成一種礦石時調用,MC裏原本自帶的幾種可開採礦物生成時都會觸發這個方法。


  3. 生成前的挑剔

    現在,我們有了兩種把自定義生成算法插入到MC裏的方案,當然,不算觸發式的生成。我先簡單分析一下兩種方法的優劣:前者簡單易用,但可擴展性較差,在某些情況下性能也存在一定問題,畢竟要多生成一種礦物。後一種稍微複雜些,但提供了更大的可“挑剔”的空間,一些情況下會有一定性能優勢。爲什麼這一小節叫做“挑剔”呢?因爲這裏主要講的是如何通過一些條件來決定生成礦物的算法。這樣,我們就可以真正定義出比較複雜的生成算法了。

    那麼先從一般的開始,一些基本的“挑剔”方法:

    a、

    world.getBlockState(BlockPos pos).getBlock();
    這個方法可以獲取到某個位置當前的方塊實例,有了這個方塊實例,對它做instanceof,你就可以據此來做出些有意思的事情。但instanceof不總是有效,記得吧,有些方塊是直接用Block實例化的,如果獲取到的方塊是個用繼承自Block的類實例化的對象,那麼instanceof這種方塊,得到的總是true!所以,你就需要用到本文開始時提到的那個實現了Predicate接口的BlockHelper了。

    b、

    world.setBlockState(BlockPos pos, IBlockState newState, int flags);
    world.setBlockState(BlockPos pos, IBlockState newState);
    ,後面那個內部調用前面的,flags置爲了3,這個值實際上是(0|=1)|=2的結果,2會在改變BlockPos位置上的方塊,1會把周圍的方塊逐一刷新重一遍,如果有新的面露出才能及時渲染上。所以在世界生成的時候,請一定記得用前面的那個方法,傳入flags的值爲2,這樣只改變而不刷新,世界生成時所有方塊都還沒渲染在屏幕上呢。

    然後是對於註冊到ORE_GEN_BUS裏的情況的特殊高級用法:

    a、高級用法:

    註冊到ORE_GEN_BUS的生成方法需要一個註解和一個OreGenEvent.GenerateMinable event參數,還記得吧,我們要善用這個event。它的父類OreGenEvent有3個公開的域:

    public final World world;
    public final Random rand;
    public final BlockPos pos;

    顧名思義,這幾個域剛好對應了生成器的generate方法的幾個參數,不用我再介紹了吧。這個event本身卻還有3個公開域:

    public static enum EventType { COAL, DIAMOND, DIRT, GOLD, GRAVEL, IRON, LAPIS, REDSTONE, QUARTZ, DIORITE, GRANITE, ANDESITE, CUSTOM }
    
    public final EventType type;
    public final WorldGenerator generator;

    第一個是要生成的礦物的類型的枚舉,可以看到諸如煤、鑽石、泥土等等都囊括在內,甚至還有一個CUSTOM的。第二個就是一個枚舉的實例,通過它,你就可以很容易的知道這次要生成什麼了。第三個是一個比WorldGenMinable複雜且高端的生成器,它有一個抽象的generate方法,但很顯然這個方法現在已經被具象好了,隨時恭候你的差遣。

    如何?看到這麼多好東西心裏有想法沒?納尼?沒有?回去再學兩年Java去。你先switch (event.type),如果發現要生成的是你想要修改的東東,把event的result設定爲DENY,即否決掉該事件,這樣原來的生成方法就不會自動運行。然後呢,先對event.BlockPos做個隨機變化,它現在只是區塊的開始位置,因爲真正的生成方法還沒有執行呢。接着,把變換後的pos,與event.world、event.rand一起,傳入早已等候多時的event.generate();方法,手動生成原來的礦石,在以這個pos爲中心,生成一些你自己的東西,就可以做到讓你的礦物伴隨其它礦物生成了。如果你野心更大,就乾脆別用event.generate();了,自己寫一個新的方法,這樣你可以調整更多的東西。

    b、注意事項:

    又來了,而且這次居然單獨提了出來,可見以下注意事項的重要!

    首先要強調的就是BlockPos!這裏的BlockPos是區塊的起點,因爲這是某個區塊要生成礦物的時候播出的事件,這時候生成礦物的方法還未執行呢!所以要對其座標做區塊內隨機變化,比如pos.getX() + rand.nextInt(16)或是pos.add(rand.nextInt(16), ...);!

    然後注意事件發出的原因是某個區塊要生成某個可開採的礦物!也就是說當前區塊正常情況下還沒有該礦物,且該區塊也只發出這一次事件廣播!如果你理解成沒生成一條礦脈就廣播一次就錯了!如果你理解成每生成一個礦石都發出一次廣播就可以去面壁了,試想那得廣播多少次?遊戲不炸了纔怪!

    最後也是最重要的,不要在event上直接調用setCanceled();,可能會有倒黴孩子,在event後面打了個點,意外看到了這個倒黴的setCanceled();方法,激動的用

    event.setCancel(true);
    代替了
    setResult(Event.Result.DENY);
    然後你就會杯具的發現運行崩潰了,因爲OreGenEvent.GenerateMinable事件和它的父類都不允許取消!Ctrl + clickL可以看到,源碼中的它帶有一個@HasResult註解,表明它有返回值,可以setResult(),同樣的,帶有@Cancelable註解的Event才允許取消!坑爹的Forge~

OK,本章終於結束,https://github.com/zhengxiaoyao0716/DouroMod,快來吧,如果你認真看到了這裏,你已經完全有資格加入了!最後吐槽一下,lofter最大的缺點就是這個噁心的難用的排版,太不利於寫技術博客了。唉~當初選這個的時候真心考慮少了。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章