はじめに
どうもこんにちは。
今回はマインクラフトに存在しているあるブロックの実装について解説します!
具体的には?
マイクラにはスポンジというブロックが存在します。
これ↓

このブロックは周りの水を吸い取るような動作をします。
具体的には、周囲の水ブロックを空気ブロックに置き換えるような動きをとっています。
ではここで質問です。
皆さんはこの「スポンジ」というブロックを実装してほしいと頼まれたら、どのように実装するでしょうか?
最初に思いつきそうなこと
まず思いつくのは、for文を3回使うというやり方でしょうか。
下のような周囲の水ブロックを空気ブロックに置き換えるメソッドを作成し、ブロックが置かれた際にメソッドを呼び出すという形で実装してみます。
package com.example.examplemod.mc_01_myblock;
import net.minecraft.core.BlockPos;
import net.minecraft.tags.FluidTags;
import net.minecraft.world.level.Level;
import net.minecraft.world.level.block.Block;
import net.minecraft.world.level.block.Blocks;
import net.minecraft.world.level.block.LiquidBlock;
import net.minecraft.world.level.block.state.BlockState;
import net.minecraft.world.level.material.Material;
public class TryToBeSpongeBlock extends Block {
private static final int RANGE=6;
public TryToBeSpongeBlock()
{
super(Properties.of(Material.SPONGE));
}
@Override
public void onPlace(BlockState pState, Level pLevel, BlockPos pPos, BlockState pOldState, boolean pIsMoving) {
if(!pOldState.is(pState.getBlock())) {
this.changeSurroundedWaterToAir(pLevel, pPos);
}
super.onPlace(pState, pLevel, pPos, pOldState, pIsMoving);
}
private void changeSurroundedWaterToAir(Level level, BlockPos pos)
{
for(int i=-RANGE; i<RANGE; i++)
{
for(int j=-RANGE; j<=RANGE; j++)
{
for(int k=-RANGE; k<RANGE; k++)
{
BlockPos detectedPos=new BlockPos(pos.getX()+i, pos.getY()+j, pos.getZ()+k);
if(level.getBlockState(detectedPos).getFluidState().is(FluidTags.WATER) && level.getBlockState(detectedPos).getBlock() instanceof LiquidBlock)
{
level.setBlock(detectedPos, Blocks.AIR.defaultBlockState(), 3);
}
}
}
}
}
}
水を吸収する機能はchangeSurroundedWaterToAirというメソッドが担っています。
iがx座標、jがy座標、kがz座標にそれぞれ対応しており、このブロックを置いた座標を中心にして、RANGEという定数の範囲分座標をずらし、その座標にあるブロックが水であれば空気に置き換えるという処理になっているのがわかると思います。
実際に挙動を見てみましょう。
なんか変ですね...実装を見れば当たり前ですが、放射状に水を吸い取るというよりは、立方体の形だけ水をくり抜いているような挙動です。
それにこの実装には致命的な欠点があります。
それは、スポンジからは到達できない場所の水も吸い取ってしまう可能性があるということです。
例えば以下のような水場があるとします。

2×2の水場に先ほど実装したスポンジブロックを置くと、本当は1ブロック分離れている1マスの水は吸い取られてはいけないのですが、それも一緒に消えてしまい、不自然な挙動となってしまうのです!
グラフ理論の中で、今回のスポンジにピッタリなのが「幅優先探索(BFS)」というアルゴリズムです。 名前は難しそうですが、やっていることは単純です。
「スタート地点から、近い順にしらみつぶしに調べていく」
これだけです。
イメージとしては、水面に石を投げ込んだときの「波紋」です。 中心(スポンジ)から、距離1のブロック、距離2のブロック……と、同心円状に手当たり次第に処理を広げていく動きです。
このアルゴリズムの最大のメリットは、「繋がっている場所しか調べない」ということです。 つまり、間に壁があったり、1マス空いていたりして「繋がり(エッジ)」が切れていれば、波紋はそこで止まります。これこそが、私たちが求めていた挙動です!
具体的な実装
では、これをプログラムに落とし込むにはどうすればいいでしょうか? ここで活躍するのが「キュー(Queue / 待ち行列)」というデータ構造です。 「先に入れたものが先に出る(First In, First Out)」という、お店のレジ待ちの列のようなリストです。
スポンジの処理は、このキューを使って以下のようなループを回すことで実現できます。
1 スポンジを置いた場所をキューに入れる。
2 キューの先頭から座標を1つ取り出し、その座標は削除する(最初はスポンジの位置)。
3 その座標の「上下左右前後」を見る。
4 もしそこが「水」なら、空気に変えて、その座標をキューの最後尾に追加する。
繰り返し: キューが空になる(または回数制限に達する)まで2に戻る。
こうすることで、「発見した水を次の探索の起点にする」というリレーが行われ、繋がっている水だけを根こそぎ吸い取ることができるのです。
これが分かればあとは実装するだけです。今回は範囲を強化したスポンジを作ってみましょう。
package com.example.examplemod.mc_01_myblock;
import com.google.common.collect.Lists;
import net.minecraft.core.BlockPos;
import net.minecraft.core.Direction;
import net.minecraft.tags.FluidTags;
import net.minecraft.util.Tuple;
import net.minecraft.world.level.Level;
import net.minecraft.world.level.block.Block;
import net.minecraft.world.level.block.Blocks;
import net.minecraft.world.level.block.BucketPickup;
import net.minecraft.world.level.block.LiquidBlock;
import net.minecraft.world.level.block.state.BlockState;
import net.minecraft.world.level.material.FluidState;
import net.minecraft.world.level.material.Material;
import java.util.Queue;
public class AuthenticSponge extends Block {
public AuthenticSponge(){
super(Properties.of(Material.SPONGE));
}
@Override
public void onPlace(BlockState pState, Level pLevel, BlockPos pPos, BlockState pOldState, boolean pIsMoving) {
if (!pOldState.is(pState.getBlock()))
{
this.removeWaterBFS(pLevel, pPos);
}
}
private void removeWaterBFS(Level level, BlockPos spongePos)
{
Queue<Tuple<BlockPos, Integer>> blockQueue= Lists.newLinkedList();
blockQueue.add(new Tuple<>(spongePos, 0));
int range=0;
while(!blockQueue.isEmpty())
{
Tuple<BlockPos, Integer> tuple=blockQueue.poll();
BlockPos pos=tuple.getA();
int depth=tuple.getB();
for(Direction direction:Direction.values())
{
BlockPos searchedPos=pos.relative(direction);
BlockState blockState=level.getBlockState(searchedPos);
FluidState fluidState=level.getFluidState(searchedPos);
if(fluidState.is(FluidTags.WATER))
{
if(blockState.getBlock() instanceof BucketPickup &&!((BucketPickup)blockState.getBlock()).pickupBlock(level,searchedPos,blockState).isEmpty())
{
range++;
if (depth<12)
{
blockQueue.add(new Tuple<>(searchedPos, depth+1));
}
}
else if (blockState.getBlock() instanceof LiquidBlock)
{
level.setBlock(searchedPos, Blocks.AIR.defaultBlockState(), 3);
range++;
if(depth<12)
{
blockQueue.add(new Tuple<>(searchedPos, depth+1));
}
}
}
}
if(range>128)
{
break;
}
}
}
}
簡単に実装の解説をすると
Queue<Tuple<BlockPos, Integer>> blockQueue= Lists.newLinkedList();
で新しいキューを作成しています。入れるものはTuple(タプル)というマイクラの中で独自実装されている型で、同時に2つの型を保持できるようになっています。今回はBlockPos(座標)とInteger(整数)を入れており、BlockPosはそのまま探索する座標、Integerは探索した場所に番号を振るために使います。
blockQueue.add(new Tuple<>(spongePos, 0));
int range=0;
作成したキューに探索の開始地点であるスポンジを設置した座標を入れ、番号として0を与えます。
そして、どれだけの範囲を探索するかという整数型の変数rangeを作って0で初期化しておきます。
while(!blockQueue.isEmpty())
このwhile文によってキューの中身が空になるまで探索が行われます。
Tuple<BlockPos, Integer> tuple=blockQueue.poll();
BlockPos pos=tuple.getA();
int depth=tuple.getB();
先程作成したキューにpoll()を使用すると、キューの一番先頭にある値が取り出され、そのまま値が消去されます。既に探索を行った座標はもう必要ないため、この処理で問題なく進むことができます。
getA()で左側の値(この場合はBlockPos)
getB()で右側の値(この場合はInteger)を取得できます。
for(Direction direction:Direction.values())
この拡張for文は上下前後左右のそれぞれの方向を探索するために使います。
BlockPos searchedPos=pos.relative(direction);
BlockState blockState=level.getBlockState(searchedPos);
FluidState fluidState=level.getFluidState(searchedPos);
BlockPos型の変数それぞれの方向へ1つ分ずれた座標を格納し、その座標のBlockState(ブロック状態)とFLuidState(流体状態)を取得しておきます。
if(fluidState.is(FluidTags.WATER))
取得した流体の状態が「水」であれば処理を開始します。
if(blockState.getBlock() instanceof BucketPickup &&!((BucketPickup)blockState.getBlock()).pickupBlock(level,searchedPos,blockState).isEmpty())
このif文は少し複雑ですが、含水した階段ブロックや、ハーフブロック、サンゴ、昆布などの水と共存できるブロックは、そのまま空気ブロックに変換すると、そのブロックごと消えてしまうので個別で処理するために書かれています。
具体的には
blockState.getBlock() instanceof BucketPickup
によってそのブロックがバケツで水を汲み取る機能を持っているかが判定され、
!((BucketPickup)blockState.getBlock()).pickupBlock(level,searchedPos,blockState).isEmpty()
によって実際に水を汲み取る処理が成功したかどうかが判定されます。
これが通ればrangeの変数が1増え、
更に、取得したブロックに割り振られた番号が12より小さければ、キューに新しくTupleを追加し、探索を進めるという処理が続きます。
blockState.getBlock() instanceof LiquidBlock
は単純に、その座標にあるブロックが流体ブロックの機能を持っているかが判定されており、ここが通ると、その座標のブロックは空気ブロックに変換されます。後の処理は上と同じです。
if(range>128)
{
break;
}
キューが空ではないとしても、128ブロック分探索したらbreakして処理を抜けるようになっています。このブロックの数を増やせばそれだけ範囲が広がっていくことになります。(普通のスポンジは64ブロック)
完成!
本来スポンジは水を吸い取ると、濡れたスポンジというブロックに置き換わり、吸い取る能力を失いますが、今回は実装していません。どうやって実装するか考えてみましょう!(ヒント: 今回は水を吸い取るメソッドの戻り値がない(void)ですが、ここを変えると、「水を吸い取ったこと」が検知できそうです。)
皆様も良きModライフを!!