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
endfly権限を剥奪する部分は後から追加した。 最初のテストで「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のモッドとして敵対モブを実装した。自分で書いたのは主に「仕様の言語化」と「動作確認」と「これじゃない」という判断だった。動作するものは結構簡単に作れたが、メンバーの反応はイマイチで、激怒モードに至ってはその場では動いてすらいなかった。「理不尽に強い敵を作った」という目標の達成度は微妙だが、まあそんなものだろう。