Luanti(旧Minetest)向けの鬼ごっこ用のModを作った

はじめに

誰が誰を殴った!

最近はよくLuanti(旧Minetest)内で複数人で遊んでいるのだが、口頭でルールを決めて鬼ごっこのようなミニゲームをしていると、だいたいこうなる。ゲーム内で独自のミニゲームをする時、みんながルールを守るというのは案外難しい。それなら、システム側でルールを強制すればいい。そこで、鬼ごっこModを自作することにした。

ルール

ルールはシンプルにした。

  • 鬼は「札」というアイテムを持つ
  • 札を手に持った状態で別のプレイヤーをパンチすると、札が相手に渡る
  • パンチしてもダメージは入らない
  • 鬼になったプレイヤーは赤く光り、10秒間動けなくなる

では作っていく。

札アイテムを作る

まず minetest.register_tool で札を登録した。

minetest.register_tool("oni_tag:fuda", {
    description = "鬼の札\n(パンチで相手に渡す)",
    inventory_image = "oni_tag_fuda.png",
    on_use = function(itemstack, user, pointed_thing)
        return itemstack
    end,
})

右クリックでは何もしない。アクションはすべてパンチ(左クリック)で起こす。

パンチを横取りする

minetest.register_on_punchplayer を使うと、プレイヤーが殴られた瞬間に割り込める。戻り値に true を返すとダメージがキャンセルされる。

minetest.register_on_punchplayer(function(player, hitter, ...)
    if not hitter or not hitter:is_player() then
        return
    end
    if hitter:get_wielded_item():get_name() ~= "oni_tag:fuda" then
        return
    end
    -- ダメージをキャンセルして札を移動させる
    ...
    return true
end)

最初は hitter:get_inventory():contains_item("main", "oni_tag:fuda") でインベントリ全体を確認していたが、これでは「インベントリに入れておくだけ」でも発動してしまう。手に持って殴ったときだけ反応させたいので get_wielded_item():get_name() に切り替えた。

赤いパーティクルをまとわせる

鬼になったことを見た目でわかるようにするために minetest.add_particlespawner を使った。 attached にプレイヤーオブジェクトを渡すと、パーティクルがプレイヤーに追従する。

local id = minetest.add_particlespawner({
    amount = 20,
    time = 0,       -- 0 で永続
    attached = player,
    minpos = {x = -0.6, y = -0.1, z = -0.6},
    maxpos = {x = 0.6, y = 2.0, z = 0.6},
    minvel = {x = -0.3, y = 0.2, z = -0.3},
    maxvel = {x = 0.3, y = 0.8, z = 0.3},
    minacc = {x = 0, y = 0.1, z = 0},
    maxacc = {x = 0, y = 0.2, z = 0},
    minexptime = 0.4,
    maxexptime = 0.9,
    minsize = 0.8,
    maxsize = 1.2,
    collisiondetection = false,
    color = "#FF1100FF",
})

collisiondetection = false にしておかないとパーティクルが地面に当たって処理が重くなる。 amountmaxsize を大きくしすぎると負荷が上がるので、見た目と負荷のバランスで amount = 20maxsize = 1.2 に落ち着いた。

ネームタグも赤くした。

player:set_nametag_attributes({
    color = {r = 255, g = 80, b = 80, a = 255},
})

鬼になったら10秒間動けなくする

set_physics_override でプレイヤーの移動速度とジャンプを0にできる。

player:set_physics_override({speed = 0, jump = 0})

ただし古いバージョンのLuantiではこれが効かない場合がある。そこで register_globalstep を使って毎フレームプレイヤーの座標を強制リセットする方法を併用した。

minetest.register_globalstep(function(dtime)
    for name, pos in pairs(frozen_positions) do
        local p = minetest.get_player_by_name(name)
        if p then
            p:set_pos(pos)
        end
    end
end)

停止開始時にその座標を frozen_positions に記録しておき、毎フレーム set_pos で戻し続ける。新旧どちらのバージョンでも動く。

10秒後に解除するのは minetest.after に任せる。

minetest.after(10, function()
    local p = minetest.get_player_by_name(name)
    if p and frozen_players[name] then
        frozen_players[name] = nil
        frozen_positions[name] = nil
        p:set_physics_override({speed = 1, jump = 1})
    end
end)

タッチ成功時に前の鬼の停止を解除し忘れていた

鬼がタッチに成功して札を渡したとき、前の鬼のパーティクルとネームタグは解除していたが、停止(freeze)を解除していなかった。タッチされた側は新しく10秒停止するのに、タッチした側も残り時間のあいだ動けないままになってしまっていた。

clear_oni_effect の中に unfreeze_player を入れず、パンチ処理の中で明示的に呼ぶようにした。

if oni_name then
    local prev_oni = minetest.get_player_by_name(oni_name)
    if prev_oni then
        clear_oni_effect(prev_oni)
        unfreeze_player(prev_oni)   -- これを忘れていた
    end
end

チャットコマンド

ゲームの開始・終了・確認を操作するコマンドを3つ用意した。

コマンド 説明
/oni_start [プレイヤー名] 鬼ごっこを開始し、最初の鬼を指定する
/oni_stop 鬼ごっこを終了する
/oni_who 現在の鬼を確認する

完成したコード

-- oni_tag/init.lua

local oni_name = nil
local particle_spawners = {}
local frozen_players = {}
local frozen_positions = {}

minetest.register_globalstep(function(dtime)
    for name, pos in pairs(frozen_positions) do
        local p = minetest.get_player_by_name(name)
        if p then p:set_pos(pos) end
    end
end)

local function freeze_player(player)
    local name = player:get_player_name()
    frozen_players[name] = true
    frozen_positions[name] = player:get_pos()
    player:set_physics_override({speed = 0, jump = 0})
    minetest.chat_send_player(name, "※ 10秒間動けません!")
    minetest.after(10, function()
        local p = minetest.get_player_by_name(name)
        if p and frozen_players[name] then
            frozen_players[name] = nil
            frozen_positions[name] = nil
            p:set_physics_override({speed = 1, jump = 1})
            minetest.chat_send_player(name, "※ 動けるようになりました!")
        end
    end)
end

local function unfreeze_player(player)
    local name = player:get_player_name()
    if frozen_players[name] then
        frozen_players[name] = nil
        frozen_positions[name] = nil
        player:set_physics_override({speed = 1, jump = 1})
    end
end

local function apply_oni_effect(player)
    local name = player:get_player_name()
    if particle_spawners[name] then
        minetest.delete_particlespawner(particle_spawners[name])
    end
    local id = minetest.add_particlespawner({
        amount = 20,
        time = 0,
        attached = player,
        minpos = {x = -0.6, y = -0.1, z = -0.6},
        maxpos = {x = 0.6, y = 2.0, z = 0.6},
        minvel = {x = -0.3, y = 0.2, z = -0.3},
        maxvel = {x = 0.3, y = 0.8, z = 0.3},
        minacc = {x = 0, y = 0.1, z = 0},
        maxacc = {x = 0, y = 0.2, z = 0},
        minexptime = 0.4,
        maxexptime = 0.9,
        minsize = 0.8,
        maxsize = 1.2,
        collisiondetection = false,
        color = "#FF1100FF",
    })
    particle_spawners[name] = id
    player:set_nametag_attributes({color = {r = 255, g = 80, b = 80, a = 255}})
    freeze_player(player)
end

local function clear_oni_effect(player)
    local name = player:get_player_name()
    if particle_spawners[name] then
        minetest.delete_particlespawner(particle_spawners[name])
        particle_spawners[name] = nil
    end
    player:set_nametag_attributes({color = {r = 255, g = 255, b = 255, a = 255}})
end

minetest.register_tool("oni_tag:fuda", {
    description = "鬼の札\n(パンチで相手に渡す)",
    inventory_image = "oni_tag_fuda.png",
    on_use = function(itemstack, user, pointed_thing)
        return itemstack
    end,
})

minetest.register_on_punchplayer(function(player, hitter, time_from_last_punch,
                                          tool_capabilities, dir, damage)
    if not hitter or not hitter:is_player() then return end
    if hitter:get_wielded_item():get_name() ~= "oni_tag:fuda" then return end

    local hitter_name = hitter:get_player_name()
    local player_name = player:get_player_name()
    if hitter_name == player_name then return end

    if oni_name then
        local prev_oni = minetest.get_player_by_name(oni_name)
        if prev_oni then
            clear_oni_effect(prev_oni)
            unfreeze_player(prev_oni)
        end
    end

    hitter:get_inventory():remove_item("main", "oni_tag:fuda 1")
    player:get_inventory():add_item("main", "oni_tag:fuda 1")
    oni_name = player_name
    apply_oni_effect(player)
    minetest.chat_send_all("*** " .. player_name .. " が鬼になりました! ***")
    return true
end)

minetest.register_on_leaveplayer(function(player)
    local name = player:get_player_name()
    if particle_spawners[name] then
        minetest.delete_particlespawner(particle_spawners[name])
        particle_spawners[name] = nil
    end
    unfreeze_player(player)
    if oni_name == name then
        oni_name = nil
        minetest.chat_send_all("鬼がいなくなりました。")
    end
end)

minetest.register_chatcommand("oni_start", {
    description = "鬼ごっこを開始する",
    privs = {interact = true},
    func = function(caller, param)
        local target_name = (param ~= "" and param or caller)
        local target = minetest.get_player_by_name(target_name)
        if not target then
            return false, "プレイヤー '" .. target_name .. "' が見つかりません"
        end
        if oni_name then
            local prev = minetest.get_player_by_name(oni_name)
            if prev then
                clear_oni_effect(prev)
                unfreeze_player(prev)
                prev:get_inventory():remove_item("main", "oni_tag:fuda 1")
            end
        end
        target:get_inventory():add_item("main", "oni_tag:fuda 1")
        oni_name = target_name
        apply_oni_effect(target)
        minetest.chat_send_all("*** 鬼ごっこスタート! " .. target_name .. " が鬼です! ***")
        return true, "開始しました"
    end,
})

minetest.register_chatcommand("oni_stop", {
    description = "鬼ごっこを終了する",
    privs = {interact = true},
    func = function(caller, param)
        if not oni_name then
            return false, "現在、鬼ごっこは開始されていません"
        end
        local prev = minetest.get_player_by_name(oni_name)
        if prev then
            clear_oni_effect(prev)
            unfreeze_player(prev)
            prev:get_inventory():remove_item("main", "oni_tag:fuda 1")
        end
        oni_name = nil
        minetest.chat_send_all("*** 鬼ごっこを終了しました ***")
        return true, "終了しました"
    end,
})

minetest.register_chatcommand("oni_who", {
    description = "現在の鬼を確認する",
    privs = {},
    func = function(caller, param)
        if not oni_name then return true, "現在、鬼はいません" end
        return true, "現在の鬼: " .. oni_name
    end,
})

まとめ

Modを作ってみた感触としては、LuantiのModに関連するAPIは思ったよりシンプルだった。パンチの横取りもダメージのキャンセルも1つの関数を登録するだけで済む。インベントリの操作も remove_itemadd_item で直感的に書ける。

ただ、動作確認をしながら進めると細かいバグが出てたりもした。「インベントリ全体ではなく手持ちで判定する」「タッチ成功時に前の鬼の停止も解除する」といった点は、実際に複数人で動かしてみないと気づきにくい。

移動停止については set_physics_override だけでは旧バージョンで動かないことがあるため、 register_globalstep で座標を固定する方法を併用した。こういった互換性の問題はドキュメントだけではわからないので、実際に試してみることが大事だと思った。

おかげで最近は「誰が誰を殴った」という話にならない。Modに聞けば一発でわかる。