Minecraft Fabric: GUIを持つブロックの作り方
お急ぎの人へ: 🛖実装 の節だけ見れば実装することができます。良く分からなかったときは他の部分も読んでみてください。
執筆時環境 | |
---|---|
言語 | Kotlin |
Minecraft version | 1.20.4 |
fabric_version | 0.96.11+1.20.4 |
fabric_kotlin_version | 1.10.19+kotlin.1.9.23 |
バニラにある "チェスト" はアイテムを中に保存しておくことができます。このようなブロックを作成するためには、アイテムの情報をブロックに保存するだけでなく、ブロックの中身を編集するためのGUIを表示できる必要があります。
このチュートリアルでは、ブロックにインベントリを持たせ、ディスペンサーのような9つのスロットを使えるようにします。このブロックを "🎁ボックス" と呼ぶことにしましょう。
↑ 今回はブロックにテクスチャを与えません。エラー時の見た目になります。
↑ 右クリックすると、インベントリが開きます。ディスペンサーと同じ見た目です。
↑ アイテムを出し入れすることができます。
この記事でやらないこと
🎁ボックス を実装する際に、GUIを表示することと無関係な機能は極力、省略することとします。例えば:
- 🎁ボックス にしまったアイテムは永続化されず、ワールドからログアウトすると消えてしまいます。
- 🎁ボックス にアイテムが詰まっていても、コンパレーターへレッドストーン信号を出力することはありません。
- Shift+クリック を使って即座にアイテムを移動させる操作はできません。
これらの機能を実装する手段について、記事の最後で触れます。
前提知識
ScreenHandler
ScreenHandler
クラスを使えば、インベントリの内容をサーバーとクライアントの間で同期させることができます。
これはMODの main
側で実装する必要があります。
Screen
Screen
クラスを実装することで、 ScreenHandler
で同期されたデータを実際に画面に描画します。
これはMODの client
側で実装する必要があります。
main/client
MODは main と client に分かれており、目的や利用可能なAPIが互いに異なります。 ScreenHandler
と Screen
クラスがどちらに属しているのか、頭の片隅に覚えておいてください。
↑ src/main/kotlin
と src/client/kotlin
のどちらに実装を書くのかは重要です。
全体像
後述する BoxBlock
, BoxBlockEntity
, BoxScreenHandler
, BoxScreen
クラスを書くことで 🎁ボックス を実装します。
また、エントリポイントである ScreenHandlerTry
, ScreenHandlerTryClient
にも触れます。
まずは実装を省略したコードでもって、各クラスの持つべき関数を確認しましょう。この次の節ではいよいよ実装していきます。
// BoxBlock.kt
internal class BoxBlock : BlockWithEntity
{
override fun createBlockEntity(pos: BlockPos?, state: BlockState?): BlockEntity?
override fun onUse(
state: BlockState?,
world: World?,
pos: BlockPos?,
player: PlayerEntity?,
hand: Hand?,
hit: BlockHitResult?
): ActionResult
override fun getCodec(): MapCodec<out BlockWithEntity>
override fun getRenderType(state: BlockState?): BlockRenderType
}
// BoxBlockEntity.kt
internal class BoxBlockEntity
: BlockEntity
, NamedScreenHandlerFactory
, ImplementedInventory
{
override fun createMenu(
syncId: Int,
playerInventory: PlayerInventory?,
player: PlayerEntity?,
): ScreenHandler?
override fun markDirty()
override fun getDisplayName(): Text
}
// BoxScreenHandler.kt
class BoxScreenHandler : ScreenHandler
{
// このコンストラクタを、関数オブジェクトとして ScreenHandlerType コンストラクタに渡す
constructor(syncId: Int, playerInventory: PlayerInventory)
: this(syncId, playerInventory, SimpleInventory(9))
// Shift+クリック でアイテムを移動しようとしたときの動作
override fun quickMove(player: PlayerEntity?, slot: Int): ItemStack = ItemStack.EMPTY
override fun canUse(player: PlayerEntity?): Boolean = inventory.canPlayerUse(player)
}
// BoxScreen.kt
internal class BoxScreen: HandledScreen<BoxScreenHandler>
{
override fun drawBackground(context: DrawContext?, delta: Float, mouseX: Int, mouseY: Int)
override fun render(context: DrawContext?, mouseX: Int, mouseY: Int, delta: Float)
}
🛖 実装
ScreenHandlerTry オブジェクト (ModInitializer実装)
どのチュートリアルでもそうですが、 ModInitializer
の実装クラスを作成する必要があります。
まだ BoxBlock
, BoxBlockEntity
, BoxScreenHandler
クラスを作成しないのでコンパイルエラーが気になるかもしれませんが、この後作成します。
object ScreenHandlerTry : ModInitializer
{
private var BlockId = Identifier("test", "demo_block_entity")
private val DemoBlock = BoxBlock(FabricBlockSettings.create().strength(4f))
// ::BoxBlockEntity コンストラクタは引数が (BlockPos, BlockState) である必要がある
internal val DemoBlockEntity = Registry.register(
Registries.BLOCK_ENTITY_TYPE,
BlockId,
FabricBlockEntityTypeBuilder.create(::BoxBlockEntity, DemoBlock).build()
)!!
// ::BoxScreenHandler コンストラクタは引数が (Int, PlayerInventory) である必要がある
val BoxScreenHandlerType = ScreenHandlerType(::BoxScreenHandler, FeatureSet.empty())
override fun onInitialize()
{
Registry.register(Registries.BLOCK, BlockId, DemoBlock)
Registry.register(Registries.ITEM, BlockId, BlockItem(DemoBlock, FabricItemSettings()))
Registry.register(Registries.SCREEN_HANDLER, BlockId, BoxScreenHandlerType)
}
}
ここでは以下のものをレジストリに登録しています:
- 🎁ボックス の Block 情報
- 🎁ボックス の Item 情報 (BlockItemとして)
- 🎁ボックス の ScreenHandler 情報
BoxBlock クラス (Block実装)
ワールドに最初に実体化するのはブロック実装です。
まだ BoxBlockEntity
クラスを実装していないのでコンパイルエラーが気になるかもしれません。
internal class BoxBlock(settings: Settings) : BlockWithEntity(settings)
{
// この関数によって BoxBlockEntity は作成される
override fun createBlockEntity(pos: BlockPos?, state: BlockState?): BlockEntity?
{
return pos?.let { state?.let { BoxBlockEntity(pos, state) } }
}
override fun onUse(
state: BlockState?,
world: World?,
pos: BlockPos?,
player: PlayerEntity?,
hand: Hand?,
hit: BlockHitResult?
): ActionResult
{
// サーバーで実行されている場合に、GUIを開く命令を発行する
if (world?.isClient != false) return ActionResult.PASS
state?.createScreenHandlerFactory(world, pos)?.let {
player?.openHandledScreen(it)
}
return ActionResult.SUCCESS
}
// これについてはよく分からなかったが、実装を略しても動いた
override fun getCodec(): MapCodec<out BlockWithEntity> {
TODO("Not yet implemented")
}
// これを実装するのを忘れないこと。さもないとブロックは透明になってしまう
override fun getRenderType(state: BlockState?): BlockRenderType = BlockRenderType.MODEL
}
ここで、 onUse
, getRenderType
関数に @Deprecated
アノテーションがついていることに警告から気づくかもしれません。Minecraft本体の仕様として、「オーバーライドして実装するためのものだが、勝手に呼ばないでほしい」という意図を表現するために @Deprecated
アノテーションを付けている場合があります。なので、オーバーライドしても大丈夫です。
BoxBlockEntityクラス (BlockEntity実装)
BoxBlock
によって BoxBlockEntity
は生成され、 BoxBlockEntity
は BoxScreenHandler
を生成します。
BoxScreenHandler
がまだ無いのでエラーが出ますが、この後作成します。
internal class BoxBlockEntity(pos: BlockPos, state: BlockState)
: BlockEntity(ScreenHandlerTry.DemoBlockEntity, pos, state)
, NamedScreenHandlerFactory
, ImplementedInventory
{
override val items = DefaultedList.ofSize(9, ItemStack.EMPTY)!!
override fun createMenu(
syncId: Int,
playerInventory: PlayerInventory?,
player: PlayerEntity?,
): ScreenHandler?
{
// ここで BoxScreenHandler コンストラクタの、引数が3つあるほうを呼び出す
return playerInventory?.let { BoxScreenHandler(syncId, playerInventory, this) }
}
override fun markDirty() = Unit
override fun getDisplayName(): Text = Text.literal("インベントリ・タイトル")
}
ImplementedInventory
クラスは 🎁ボックス を実装するために作成したクラスです。Fabricの使い方とは関係がないので詳しく解説しませんが、記事の末尾にコード載せておきますのでコピペして使ってください。
createMenu
関数内では、 BoxScreenHandler
コンストラクタの引数が3つあるほうを呼び出しています。その第3引数は Inventory
型になる予定であり、 BoxBlockEntity
が実装している ImplementedInventory
が、 Inventory
インターフェースを実装しています。
これにより、 BoxScreenHandler
のオブジェクトと ImplementedInventory
のオブジェクトが紐づき、インベントリの変化がGUIに反映されるようになります。
BoxScreenHandlerクラス (ScreenHandler実装)
GUIを表示すべきタイミングを検知して呼び出されるクラスです。インベントリの形状を保持しておき、サーバー・クライアント間でGUIの状態を同期します。
class BoxScreenHandler(
syncId: Int,
playerInventory: PlayerInventory,
private val inventory: Inventory
) : ScreenHandler(ScreenHandlerTry.BoxScreenHandlerType, syncId)
{
init
{
checkSize(inventory, 9)
// インベントリを開くときのイベントを発火させます
inventory.onOpen(playerInventory.player)
val gridSize = 18
// ホットバー(1x9)
for (column in 0..8)
{
addSlot(Slot(playerInventory,
column,
8 + column * gridSize,
142))
}
// プレイヤーインベントリ(3x9)
for (row in 0..2)
{
for (column in 0..8)
{
addSlot(Slot(playerInventory,
9 + column + row * 9,
8 + column * gridSize,
84 + row * gridSize))
}
}
// コンテナインベントリ(3x3)
for (row in 0..2)
{
for (column in 0..2)
{
addSlot(Slot(inventory,
column + row * 3,
62 + column * gridSize,
17 + row * gridSize))
}
}
}
// このコンストラクタを、関数オブジェクトとして ScreenHandlerType コンストラクタに渡す
constructor(syncId: Int, playerInventory: PlayerInventory)
: this(syncId, playerInventory, SimpleInventory(9))
// Shift+クリック でアイテムを移動しようとしたときの動作。今回は省略
override fun quickMove(player: PlayerEntity?, slot: Int): ItemStack = ItemStack.EMPTY
override fun canUse(player: PlayerEntity?): Boolean = inventory.canPlayerUse(player)
}
init
部分にはインベントリのスロットの形状を登録するコードを書きます。
🎁ボックス インベントリは9スロットだけですが、 🎁ボックス にアイテムを出し入れする際はプレイヤーのインベントリも操作できるのが一般的なので、プレイヤーのインベントリとホットバーの形状も登録します。
プレイ中、登録したスロットにはアイテムを入れることができますし、 🎁ボックス に後でアクセスしたときも前回入れたアイテムが表示されるようになります。
逆に、スロットを登録していない位置に対しては何も操作ができません。
ScreenHandlerTryClientオブジェクト (ClientModInitializer実装)
この後 BoxScreen
クラスを作成するのですが、そのクラスが呼び出されるようにするためには、クライアント側のエントリポイントを実装して BoxScreen
をレジストリに登録する必要があります。
ClientModInitializer
クラスの実装オブジェクトを書きましょう。 main
ではなく client
側に書く必要があることに注意してください。
object ScreenHandlerTryClient : ClientModInitializer
{
override fun onInitializeClient()
{
HandledScreens.register(ScreenHandlerTry.BoxScreenHandlerType, ::BoxScreen)
}
}
main 側で作成した BoxScreenHandlerType
を、 BoxScreen
に紐づけています。
まだ BoxScreen
クラスを作成していないので、コンパイルエラーが気になるかもしれません。
BoxScreenクラス (Screen実装)
Screen
の実装を作ります。 ここでは Screen
を直接実装するのではなく、その実装である HandledScreen
から派生して作ることにします。
internal class BoxScreen(
handler: BoxScreenHandler?,
inventory: PlayerInventory?,
title: Text?)
: HandledScreen<BoxScreenHandler>(handler, inventory, title)
{
private val texture = Identifier("minecraft", "textures/gui/container/dispenser.png")
override fun drawBackground(context: DrawContext?, delta: Float, mouseX: Int, mouseY: Int)
{
RenderSystem.setShader(GameRenderer::getPositionTexProgram)
RenderSystem.setShaderColor(1f, 1f, 1f, 1f)
RenderSystem.setShaderTexture(0, texture)
val x = (width - backgroundWidth) / 2
val y = (height - backgroundHeight) / 2
context?.drawTexture(texture, x, y, 0, 0, backgroundWidth, backgroundHeight)
}
override fun render(context: DrawContext?, mouseX: Int, mouseY: Int, delta: Float)
{
renderBackground(context, mouseX, mouseY, delta)
super.render(context, mouseX, mouseY, delta)
drawMouseoverTooltip(context, mouseX, mouseY)
}
}
GUIの背景画像として、バニラのディスペンサーのものを流用しています。これは texture
フィールドに代入している Identifier を通じて実現しています。
バニラのディスペンサーのGUIは、プレイヤーインベントリとホットバーの背景も描かれているので、 BoxScreenHandler
で登録したスロットの形状とも合っています。
完成
実装はこれだけです! ワールドに入り、 give
コマンドでブロックを入手し、設置し、右クリックするとGUIが開きます。ここに手持ちのアイテムを出し入れできるはずです。
追加機能
アイテムを永続化したい
🎁ボックス に入れたアイテムは、ワールドからログアウトすると消滅してしまいます。これは、セーブデータに保存するように設定されていないからです。
BoxBlockEntity
の markDirty
, writeNbt
, readNbt
を実装してください。
override fun markDirty() = super<BlockEntity>.markDirty()
override fun writeNbt(nbt: NbtCompound?)
{
super.writeNbt(nbt)
Inventories.writeNbt(nbt, items)
}
override fun readNbt(nbt: NbtCompound?)
{
super.readNbt(nbt)
Inventories.readNbt(nbt, items)
}
コンパレーターにレッドストーン信号を出力したい
hasComparatorOutput
, getComparatorOutput
関数を実装すると可能です。ここでは詳しく解説しません。
Shift+クリックでアイテムを移動したい
quickMove
関数を実装すると可能です。ここでは詳しく解説しません。
ImplementedInventoryインターフェース
internal interface ImplementedInventory : Inventory
{
val items: DefaultedList<ItemStack>
override fun clear()
{
items.clear()
markDirty()
}
override fun size(): Int = items.size
override fun isEmpty(): Boolean = items.all { it.isEmpty }
override fun getStack(slot: Int): ItemStack = items[slot]
override fun removeStack(slot: Int, amount: Int): ItemStack
{
val result = Inventories.splitStack(items, slot, amount)
if (!result.isEmpty)
{
markDirty()
}
return result
}
override fun removeStack(slot: Int): ItemStack
{
val stack = Inventories.removeStack(items, slot)
markDirty()
return stack
}
override fun setStack(slot: Int, stack: ItemStack?)
{
if (stack == null) return
items[slot] = stack
if (stack.count > stack.maxCount)
stack.count = stack.maxCount
markDirty()
}
override fun markDirty()
{
// Override me
}
override fun canPlayerUse(player: PlayerEntity?): Boolean = true
}
Discussion