Claude CodeでLuantiのmodを作った、という程度の話

はじめに

もう3年以上になるだろうか。ゲーム配信のメンバーと一緒にLuanti(旧称Minetest)で遊んでいる。オープンソースのボクセルゲームエンジンで、Minecraftクローンの親戚みたいなものだと思えばだいたい合っている。

一応、敵対モブは何種類か入れてはいるのだが、さすがに変化がないと飽きてくる。そこで、新しいモブを追加することにした。

Claude Codeに丸投げする

このゲームを触るようになってLuaは結構書くようになった。今までは手作業でコードを書いていたが、最近ではAIコーディングエージェントに書かせることも増えた。それらのツールは、セキュリティ面での不安や組織のポリシーによって使えないこともある。しかし、自分達が管理しているゲームサーバであれば好き勝手できる。そこで本稿ではClaude Codeに全部やらせることにした。

適当にCLAUDE.mdを書いて、仕様の策定と実装を依頼し、ゲームを実際にプレイしながら調整を行った。コードもだが、テクスチャやb3d形式の3Dモデルも生成させた。僕がやったのは主に「これは違う」「こっちの方向に直して」という指示だ。それでmodができてしまうのだから、便利というか、なんというか、複雑な気分ではある。

風霊(Wind Spirit)という敵を作る

コンセプトは「風属性・空中を飛ぶ・理不尽に強い」だった。

具体的には:

  • 空中を浮遊して移動する
  • 射程内のプレイヤーにカマイタチ(飛び道具)を発射する
  • 空中にいるプレイヤーを竜巻で吹き飛ばす
  • 被弾すると風ダッシュで回避し即反撃する
  • HPが200を切ると激怒し、速度2倍・連射・ボディが赤くなる

「理不尽に強い」の部分は意図的だ。 メンバーが「弱すぎる」と言ったなら、次は「強すぎる」と言わせてやろうという考えが少しあった。

modのディレクトリ構成

minetest_game/mods/wind_spirit/
├── mod.conf
├── init.lua
├── gen_textures.py          # テクスチャ生成スクリプト
├── textures/
│   ├── wind_spirit.png              # mobスプライト(水色)
│   ├── wind_spirit_enraged.png      # 激怒時スプライト(赤)
│   ├── wind_spirit_kamaitachi.png   # カマイタチアイコン
│   ├── wind_spirit_particle.png     # 竜巻パーティクル(水色)
│   ├── wind_spirit_rage.png         # 激怒突入バーストパーティクル(赤)
│   ├── wind_spirit_egg.png          # スポーンエッグ
│   ├── wind_spirit_shrine_top.png
│   ├── wind_spirit_shrine_side.png
│   └── wind_spirit_shrine_bottom.png
└── models/
    └── wind_spirit.b3d              # 生成済みだが現在 visual=sprite のため未使用

テクスチャは画像編集ソフトを使わず、Pythonで直接PNGバイナリを生成している。 16×32ピクセルのスプライトをピクセル単位でコードに書くというのは、正気とは言えない作業だが、依存ライブラリをゼロにできるという点では気に入っている。

mod.conf

name = wind_spirit
description = 風属性の敵対mob「風霊」を追加するmod
depends = mobs
author = symdon

depends = mobs で mobs_redo への依存を宣言する。これだけでAPIが全部使える。

init.lua の実装

定数定義

local TORNADO_INTERVAL = 5.0   -- 竜巻攻撃の間隔(秒)
local TORNADO_RANGE   = 10     -- 竜巻の有効範囲
local TORNADO_DAMAGE  = 2      -- 竜巻の当たりダメージ
local TORNADO_FORCE   = 8      -- 吹き飛ばし速度
local KAMAITACHI_DMG  = 8      -- カマイタチのダメージ
local ENRAGE_HP       = 200    -- 激怒しきい値

数字を直接コードに埋めるのが嫌いなので定数にまとめている。 調整のたびにここだけ変えればいい。

カマイタチ(飛び道具)

mobs_redoには mobs:register_arrow() という飛び道具登録APIがあって、当たり判定や速度を設定するだけで飛び道具が完成する。思ったより簡単だった。

core.register_craftitem("wind_spirit:kamaitachi_item", {
    description = "カマイタチ",
    inventory_image = "wind_spirit_kamaitachi.png",
    groups = {not_in_creative_inventory = 1},
})

mobs:register_arrow("wind_spirit:kamaitachi", {
    visual = "wielditem",
    visual_size = {x = 0.4, y = 0.4},
    velocity = 20,
    textures = {"wind_spirit:kamaitachi_item"},

    hit_player = function(self, player)
        player:punch(self.object, 1.0, {
            full_punch_interval = 1.0,
            damage_groups = {fleshy = KAMAITACHI_DMG},
        }, nil)
    end,

    hit_mob = function(self, mob)
        mob:punch(self.object, 1.0, {
            full_punch_interval = 1.0,
            damage_groups = {fleshy = KAMAITACHI_DMG},
        }, nil)
    end,

    hit_node = function(self, pos, node)
    end,
})

velocity = 20 はかなり速い。避けられないことはないが、余裕を持って避けられるほどでもない、という絶妙な速度にしたつもりだ。

竜巻攻撃

空中にいるプレイヤーだけを対象にする攻撃だ。足元のノードが air かどうかで判定している。

local function tornado_attack(self_pos)
    for _, player in ipairs(minetest.get_connected_players()) do
        local ppos = player:get_pos()

        if vector.distance(self_pos, ppos) > TORNADO_RANGE then
        elseif minetest.get_node({x = ppos.x, y = ppos.y - 0.1, z = ppos.z}).name ~= "air" then
        else
            -- 竜巻パーティクル(外側の渦)
            minetest.add_particlespawner({
                amount = 200, time = 1.5,
                minpos = vector.add(ppos, {x = -2, y = -1, z = -2}),
                maxpos = vector.add(ppos, {x =  2, y =  5, z =  2}),
                minvel = {x = -6, y =  4, z = -6},
                maxvel = {x =  6, y = 10, z =  6},
                minacc = {x =  0, y = -2, z =  0},
                maxacc = {x =  0, y = -1, z =  0},
                minexptime = 0.5, maxexptime = 1.8,
                minsize = 0.8,    maxsize = 2.5,
                texture = "wind_spirit_particle.png",
            })
            -- 内側の細かいパーティクル(竜巻の芯)
            minetest.add_particlespawner({
                amount = 120, time = 1.5,
                minpos = vector.add(ppos, {x = -0.4, y =  0, z = -0.4}),
                maxpos = vector.add(ppos, {x =  0.4, y =  4, z =  0.4}),
                minvel = {x = -2, y =  6, z = -2},
                maxvel = {x =  2, y = 12, z =  2},
                minacc = {x =  0, y = -1, z =  0},
                maxacc = {x =  0, y =  0, z =  0},
                minexptime = 0.3, maxexptime = 1.0,
                minsize = 0.3,    maxsize = 1.0,
                texture = "wind_spirit_particle.png",
            })

            -- 水平方向を正規化
            local dx = ppos.x - self_pos.x
            local dz = ppos.z - self_pos.z
            local len = math.sqrt(dx * dx + dz * dz)
            if len > 0 then dx = dx / len; dz = dz / len
            else dx = 1; dz = 0 end

            -- fly権限を3秒間剥奪
            local pname = player:get_player_name()
            local privs = minetest.get_player_privs(pname)
            if privs.fly then
                privs.fly = nil
                minetest.set_player_privs(pname, privs)
                minetest.after(3, function()
                    local p = minetest.get_player_by_name(pname)
                    if p then
                        local pr = minetest.get_player_privs(pname)
                        pr.fly = true
                        minetest.set_player_privs(pname, pr)
                    end
                end)
            end

            -- 移動先がairなら瞬間移動
            local target = {x = ppos.x + dx * 10, y = ppos.y + 3, z = ppos.z + dz * 10}
            if minetest.get_node(target).name == "air" then
                player:set_pos(target)
            else
                player:set_pos({x = ppos.x, y = ppos.y + 5, z = ppos.z})
            end

            player:set_velocity({x = dx * TORNADO_FORCE, y = TORNADO_FORCE * 0.5, z = dz * TORNADO_FORCE})

            local hp = player:get_hp()
            player:set_hp(math.max(1, hp - TORNADO_DAMAGE))
        end
    end
end

fly権限を剥奪する部分は後から追加した。 最初のテストで「flyで浮いてたら竜巻に巻き込まれても全然怖くない」ということに気づいたからだ。 3秒間だけ権限を消す、という処置は少し意地悪だと思う。でもそれでいい。

風霊 mob 登録

mobs:register_mob("wind_spirit:wind_spirit", {
    type = "monster",
    passive = false,
    attack_type = "shoot",
    arrow = "wind_spirit:kamaitachi",
    shoot_interval = 3.0,
    shoot_offset = 1.0,
    hp_min = 500,
    hp_max = 1000,
    armor = 80,
    collisionbox = {-0.4, -0.0, -0.4, 0.4, 1.9, 0.4},
    visual = "sprite",
    visual_size = {x = 1, y = 2},
    textures = {"wind_spirit.png"},
    glow = 2,
    fly = true,
    fly_in = "air",
    walk_velocity = 3,
    run_velocity = 7,
    floats = 1,
    view_range = 20,
    drops = {
        {name = "wind_spirit:wind_essence", chance = 1,  min = 2, max = 5},
        {name = "default:mese_crystal",     chance = 3,  min = 1, max = 3},
        {name = "default:diamond",          chance = 10, min = 1, max = 1},
    },
    water_damage = 0,
    lava_damage = 1,
    light_damage = 0,

    do_custom = function(self, dtime)
        local pos = self.object:get_pos()

        -- 常時: 最寄りプレイヤーを積極的にターゲット
        local nearest, nearest_dist = nil, math.huge
        for _, pl in ipairs(minetest.get_connected_players()) do
            local d = vector.distance(pos, pl:get_pos())
            if d < nearest_dist and d <= self.view_range then
                nearest_dist = d
                nearest = pl
            end
        end
        if nearest then
            self.attack = nearest
            self.state  = "attack"
        end

        -- 被弾検知 → 風ダッシュで回避+即反撃
        self._prev_health = self._prev_health or self.health
        if self.health < self._prev_health then
            local angle = math.random() * math.pi * 2
            self.object:set_velocity({
                x = math.cos(angle) * 10, y = 5, z = math.sin(angle) * 10,
            })
            self.shoot_timer = 0
            minetest.add_particlespawner({
                amount = 40, time = 0.5,
                minpos = vector.add(pos, {x = -0.5, y = 0, z = -0.5}),
                maxpos = vector.add(pos, {x =  0.5, y = 1.5, z =  0.5}),
                texture = "wind_spirit_particle.png",
            })
        end
        self._prev_health = self.health

        -- 激怒突入(HP200未満かつ未激怒)
        if not self._enraged and self.health < ENRAGE_HP then
            self._enraged = true
            self.walk_velocity = 6
            self.run_velocity  = 14
            self.shoot_interval = 0.8
            self.shoot_timer    = 0
            self.view_range     = 40
            self._tornado_timer = 0

            -- ボディを赤テクスチャに差し替え
            self.object:set_properties({
                textures = {"wind_spirit_enraged.png"},
                glow = 14,
            })

            -- 激怒突入バースト(1回のみ)
            minetest.add_particlespawner({
                amount = 150, time = 1.0,
                minpos = vector.add(pos, {x = -1, y = 0, z = -1}),
                maxpos = vector.add(pos, {x =  1, y = 2, z =  1}),
                minvel = {x = -8, y = 4, z = -8},
                maxvel = {x =  8, y = 12, z =  8},
                texture = "wind_spirit_rage.png",
            })
        end

        -- 竜巻タイマー(激怒中は間隔が半分)
        local tornado_interval = self._enraged and (TORNADO_INTERVAL * 0.5) or TORNADO_INTERVAL
        self._tornado_timer = (self._tornado_timer or tornado_interval) - dtime
        if self._tornado_timer <= 0 then
            self._tornado_timer = tornado_interval
            tornado_attack(pos)
        end
    end,

    on_die = function(self, pos)
    end,
})

mobs:register_egg("wind_spirit:wind_spirit", "Wind Spirit", "wind_spirit_egg.png", 0)

HPを500〜1000にしたのはかなり意図的だ。 すぐに死なないMobが欲しかった。戦闘が数秒で終わるのは面白くない。

do_custom コールバックはフレームごとに呼ばれる。ここに複数の行動ロジックをまとめて書いている。 mobs_redoが提供するデフォルト動作(ターゲット追跡・射撃)に加えて、最寄りプレイヤーへの強制ターゲッティングと被弾回避と激怒を全部ここで処理している。 若干ごった煮感があるが、Luaでゲームロジックを書くのはそういうものだと思っている。

激怒モード

HPが200を切ると外見が変わる。赤いスプライトに差し替えるだけだ。

self.object:set_properties({
    textures = {"wind_spirit_enraged.png"},
    glow = 14,
})

最初はパーティクルスポーナーで赤いオーラを毎フレーム出力しようとしたが、上手く機能しなかったのでやめた。 set_properties でテクスチャを差し替えれば確実だし、コードもシンプルになる。 動作原理が単純な方が後でデバッグしやすい、という教訓をここで再確認した。

水色のボディが赤くなった瞬間、「あっやばい」と思わせたかった。 動かしてみると確かにそう見える。少し満足している。

激怒時のパラメータ変化:

項目 通常 激怒
移動速度 walk:3/run:7 walk:6/run:14
射撃間隔 3.0秒 0.8秒
竜巻間隔 5.0秒 2.5秒
索敵範囲 20 40
発光 2 14
ボディ色 水色

テクスチャ生成

テクスチャはPythonスクリプトで生成している。 16×32ピクセルのスプライトをピクセル単位でコードとして書いたものをPNGに変換する、という方法だ。 Pillowなどの画像ライブラリは使わず、PNG仕様に従ってバイナリを直接生成している。

激怒テクスチャは通常テクスチャのカラーパレットだけ変えたものだ。 水色系を赤/橙系に対応させている:

R  = (220,  60,  60, 255)   # 赤(CYAN の対応)
LR = (255, 140, 100, 255)   # 明るい赤橙(LCYAN の対応)
DR = (140,  20,  20, 255)   # 暗い赤(DCYAN の対応)
OR = (255, 160,  60, 255)   # オレンジ(AZURE の対応)

風の祠

破壊すると風霊が出現するノードも作った。 フィールドに置いておくと、壊した瞬間に敵が湧く。罠のように使える。

minetest.register_node("wind_spirit:wind_shrine", {
    description = "風の祠",
    tiles = {
        "wind_spirit_shrine_top.png",
        "wind_spirit_shrine_bottom.png",
        "wind_spirit_shrine_side.png",
    },
    groups = {cracky = 2, oddly_breakable_by_hand = 1},
    light_source = 4,
    after_dig_node = function(pos, oldnode, oldmetadata, digger)
        minetest.add_entity({x = pos.x, y = pos.y + 1, z = pos.z}, "wind_spirit:wind_spirit")
    end,
})

クラフトレシピは石×4・シアン染料×2・葉×2・メセ結晶×1。 入手難度は意図的に低めにしている。量産できる方が罠として機能する。

スポーン設定

mobs:spawn({
    name = "wind_spirit:wind_spirit",
    nodes = {"default:dirt_with_grass"},
    neighbors = "air",
    min_light = 10,
    chance = 6000,
    active_object_count = 2,
    min_height = 20,
    max_height = 200,
})

高所にスポーンする設定にした。上空から突然現れる敵というコンセプトに合わせている。

メンバーと遊んでみた

実際に配信のメンバーと一緒に遊んだ。「強い敵を作った」とだけ伝えて、詳細は何も教えなかった。はじめは「あれ、ちょっと強いね」くらいの反応だった。カマイタチが飛んでくる速度がそこそこあるので、最初は驚いていた。目玉機能の竜巻攻撃もそこそこ機能していたが、メンバーの反応は総じてイマイチだった。エフェクトとか効果音が少なく迫力がないし、行動パターンが一辺倒でサンドバッグを叩いているといった感想だった。もう少し工夫をしてもよさそうだ。折を見て改善に取り組みたい。

激怒モードはメンバーと遊んだ時には動いていなかったので、その後に修正したものだ。これも感想を聞いてみたい。

まとめ

Claude CodeでLuanti/Minetestのモッドとして敵対モブを実装した。自分で書いたのは主に「仕様の言語化」と「動作確認」と「これじゃない」という判断だった。動作するものは結構簡単に作れたが、メンバーの反応はイマイチで、激怒モードに至ってはその場では動いてすらいなかった。「理不尽に強い敵を作った」という目標の達成度は微妙だが、まあそんなものだろう。