🎁

Minecraft Fabric: GUIを持つブロックの作り方

2024/04/01に公開

お急ぎの人へ: 🛖実装 の節だけ見れば実装することができます。良く分からなかったときは他の部分も読んでみてください。

執筆時環境
言語 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つのスロットを使えるようにします。このブロックを "🎁ボックス" と呼ぶことにしましょう。

intro1

↑ 今回はブロックにテクスチャを与えません。エラー時の見た目になります。

intro2

↑ 右クリックすると、インベントリが開きます。ディスペンサーと同じ見た目です。

intro3

↑ アイテムを出し入れすることができます。

この記事でやらないこと

🎁ボックス を実装する際に、GUIを表示することと無関係な機能は極力、省略することとします。例えば:

  • 🎁ボックス にしまったアイテムは永続化されず、ワールドからログアウトすると消えてしまいます。
  • 🎁ボックス にアイテムが詰まっていても、コンパレーターへレッドストーン信号を出力することはありません。
  • Shift+クリック を使って即座にアイテムを移動させる操作はできません。

これらの機能を実装する手段について、記事の最後で触れます。

前提知識

ScreenHandler

ScreenHandler クラスを使えば、インベントリの内容をサーバーとクライアントの間で同期させることができます。

これはMODの main 側で実装する必要があります。

Screen

Screen クラスを実装することで、 ScreenHandler で同期されたデータを実際に画面に描画します。

これはMODの client 側で実装する必要があります。

main/client

MODは main と client に分かれており、目的や利用可能なAPIが互いに異なります。 ScreenHandlerScreen クラスがどちらに属しているのか、頭の片隅に覚えておいてください。

src/main/kotlinsrc/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 は生成され、 BoxBlockEntityBoxScreenHandler を生成します。

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が開きます。ここに手持ちのアイテムを出し入れできるはずです。

追加機能

アイテムを永続化したい

🎁ボックス に入れたアイテムは、ワールドからログアウトすると消滅してしまいます。これは、セーブデータに保存するように設定されていないからです。

BoxBlockEntitymarkDirty, 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
}

参考

Creating a Container Block [Fabric Wiki]

Discussion