「共通」拡張性もつアイテムシステムの作り方 (Godot/Unityコードあり) (August’s学習記録)

記事8?9?へようこそ. Coding課のAugustです. 数えるのが面倒くさいので今後はもう番号は含まないようにします.


今回のテーマは, コンポーネントベース,「拡張性をもつアイテム」とはかと, なぜこのシステムがいいか, 他のシステムの何が良くないか を 紹介したいと思います.

因みに, このシステムは(解釈があっていれば) 大規模タイトルでも一般的なアプローチです(データ駆動+コンポジション). 理由は, この記事を読んだらわかるかと思います.

(Nightlyビルド, あくまで参考, まだ実践的に使ってない)
Godotソースコード: https://github.com/August13742/augusts_item_system_Godot
Unityソースコード: https://github.com/August13742/AugustsUtility-Unity

Warning

本記事に関わる概念は, 正直自分でもまだまだ模索中です. この記事を書いてる途中で気付いた罠とかもありました. できるだけ正しく伝わるよう, 努力しますが, 丸呑みはおすすめしません.

アイテムシステムに求められるものは?

どんなアイテムシステムでも, まずアイテムを定義しないといけない.

全てのアイテムは, 大まかにこういう属性が求められる:

  • プログラミング用識別名(ID), 例えば seed_tomato, sword_woodなど
  • 表示用名, 例えば “トマトの種”
  • アイコン
  • スタックサイズ
  • タグなど, ある種の検索用分類識別子

その後, 特定種類のアイテムが持つべき情報, 例えば:

  • クラフト可能なら, 必要アイテム, 数 と, 工作台種類
  • 消費可能なら, 使うときの動作. 例えば:HP薬, 使用後HP+50
  • 装備品なら, 装備に追加する属性と量
  • などなど


この中, 「アイテムが持つべき情報 」はどう定義したほうがいいかに, かなり違いが生じる.

パターン1: 各自定義 aka. システムなし

別に全てのゲームがアイテムシステムが必要なわけではなく, アイテム量が非常に少ないゲームジャンルに, わざわざシステム導入する必要はない, という考えから, 「各自定義すればいい」という考え方である.

これに関してあんまり言えることはないから, 次に移る.

パターン2: オブジェクト指向(Class)によるアイテムシステムと, その問題点

ある程度オブジェクト指向に詳しい人なら, 「クラス使って, 共通する部分を親クラス, 各自特殊な部分は子クラス」で定義できる.


このシステムは一見何の問題もない, 多くのゲームには十分使えるが, 以下の問題がある

例えば, HPとMPを同時に回復する「万能薬」は, 機能自体は新しくないのに, 既存のクラスでは作れない.

新しいクラスが必要 = コード触る必要がある = プログラマーが必要

つまり, 「新しいアイテム追加したい」という プランナーやデザイナーの要求に, 新しい機能がないのにも関わらず, プログラマーが対応しなければならない.



その他, 例えば, 「クラフト可能」だが, 「工作台によってレシピが異なる」アイテムになると, どう作ればいい?

sword_wood_1, sword_wood_2 で分けるの? 流石にないっか.

或は, enum 工作台{手作り, 作業台}など定義して, 辞書A{アイテム:数}定義して, 辞書B{工作台:辞書A} という, 辞書の中の辞書で管理するか?
まぁいいけど, これを対応できない言語・エンジンもある. (Godot4.5のGDScriptは静的型指定された辞書を, 静的型指定された辞書に入れることはできない, 例えばDictionary[StringName, Dictionary[StringName, int]], これに警告がでる, 理由は恐らく, 専用Resourceの方が設計上優れてるため(後述), まぁそれか, 静的型指定にしなければいいよ)
更に, こうなると効率がかなり悪くなる(殆どのゲームや, 現代ハードウェアでは無視できるが). UIメニューを開いて, まずすべてのクラフト可能 アイテムを探す必要があって, そこから, すべて結果に対して if item.has_key(工作台.手作り) をチェックする必要がある.


合わせて

for item in items: //このコードはO(1)に軽量化できる. +α(その1)で解説
    if item is クラフト可能 && item.has_key(工作台.手作り):
        ...


(この問題はまた後半に出てくる)


例は他にもある. 例えば,
Item -> クラフト可能 -> クラフト可能装備品 -> 剣_クラフト可能品

こういう継承関係で剣アイテムを定義する.


じゃぁ, クラフト不可能の剣がもしほしかったら?
Item -> クラフト不可能装備品 -> 剣_クラフト不可能装備品

更に意地悪い例挙げると, “クラフト不可能”, “投げれる”, “購入できる”, ”売れる” 剣は, どう作る?
もういっそクラスで管理するのを諦めた方がいい? でももし同じ属性のアイテム, 他に追加したくなると, どうする?

このような問題点から, 継承に基づくシステムは, RPGなど, アイテム規模が大きくなると, 非常に管理しづらいし, 定義が重複している.

Composition Over Inheritance

プログラマーコミュニティではこういう言葉がある. 「Composition Over Inheritance, 継承よりコンポネント化」.

生物をクラス継承で定義していくと, こうなる:
DNAを持つ生命 → 生物 → 真核生物 → 動物 → 脊椎動物 → 哺乳類 → 霊長類 → 人間

きれいだけど, ちょっと硬直的すぎる.

たとえば「人間は手足が2本ずつ, 指は各5本」みたいに定義すると, 生まれつき指が少ない人は“クラス外”になってしまう. いやいや、人間は人間だろ。指の本数は「本質」じゃなくて「オプション」ではなかろうか?

同じことがスマホにも言える.

なぜアイテムシステムはCompositionの方がいいか

この図から, スマホBは, スマホAより, カメラ,スピーカーが抜けてる. 「じゃぁスマホBはスマホじゃないのか?」っていうと, 「いや, 構成要素が違うけれど, スマホはスマホだ」

だからアイテムシステムも同じ発想で作るべきではなかろうか.

“クラフト可能”とか“使用できる”とか“売れる”といった属性は, 人間でいう“指の本数”やスマホでいう“カメラの有無”みたいなもので,
本質ではなく, 「おまけ」であり, 「拡張」である. 拡張は拡張らしく, クラスに入れるのをやめよう.

「本質」の上に追加機能を乗せる感じで使うアイテムシステムが, 今回のテーマである.

本題: コンポーネントから築くアイテムシステム

さて, ここから本題に入る.

前のブロックでいったように, 「「本質」の上に追加機能を乗せる感じで使うアイテムシステム」を作ろう.

で, これは実はかなり簡単. 思いつけば簡単に作る.

(CapはCapabilityの略)
(実行可能CapはCommand Patternという思想を使っていて, +α(その2)で解説する. このシステムには必須ではない)

+α(その1) データベースによる検索軽量化

このあとに進むにはデータベースの解説が免れない.

データベースの考えは非常に簡単:

for item in items:
    for cap in item.CapabilityArray: 
        if cap is 手作りクラフト可能Cap:
            ...

ここ, すべてのアイテムのCapability配列見て, このCapが持つかどうかを検索するステップ. 非常に効率悪いと思わない?
一つのアイテムならまだしも, 例えば「すべての消耗品がみたい」となると, これは(進展をキープする場合でも) O(N*C)計算量.
まぁ, 精々アイテム100個で平均Capが2ぐらいなら, 200回ぐらいで解決する. 思うより悪くはない, とはいえ, 実に醜い.


この解決として, ItemDatabaseというSingletonを作る. (Singletonでなくてもいいが, その場合 対応が変わる)

例えば, 初回(ゲーム起動時 或い 事前に焼入れる)だけこの検索行い, すべてのアイテムに対して, (主に)3つの辞書を作る:
1. item_id : Resource/Scriptable Object アイテムIDから対応する画像アイコンや, スタックサイズ, 表示名などのResource情報が手に入る

2. Capability_Scipt : item_id Capのスクリプトで検索することで, 正しくCapのクラス名入力したら, IDEが色変えて伝わる. (入力ミスを防げる)

3. item_id : Array[Capability] 迅速にアイテムが所持するCapabilityがわかる

その後, 外部検索用APIとして, 対応する関数を作ればいい. 例えばget_item_by_id(id:StringName) でアイテム情報を検索する
こうするとこで, ほぼすべて通常用途においての検索がO(1)になる. (なぜが聞きたいなら, 自分でHash Table / Hash Mapを調べな)

「あれオブジェ指向がよくないって言わなかったけ? 使ってんじゃん」って思うひともいるかもしれないが, OOP自体が悪いではなく, 正確には「余計に使うことがよくない」

ここで出る罠は, Capabilityを定義してるときに余計に子クラス使うことである. (とはいえ, 単純にanti-patternだけで, 動き動作に影響はない, ちゃんと動く)

例えば, 前のクラフト可能で例を挙げるとする.

クラフト可能は, ここで クラフト可能Cap extends ItemCapabilityになり, 中身は普通に必要アイテムという辞書(Dictionary){item_id:数}を定義すればいい.
ここで, 更に追加して, 「作業台によってレシピが異なる」機能を追加する.

罠はここにある.
この機能の実装は, 大まかに3パターンがある.

パターン1(動くが, Anti-Pattern):

クラフト可能Capをabstractクラスにし, その子クラスを作る:
手作りクラフト可能Cap extends クラフト可能Cap (中身は空でいい, なぜなら, クラフト可能Capですでにアイテム辞書定義してる)
工作台クラフト可能Cap extends クラフト可能Cap


使うときは, 単純に

for item in items:
    for cap in item.CapabilityArray: 
        if cap is 手作りクラフト可能Cap:
            ...(このコードは作業台メニューUIで使われる)

若干計算量上げたに見えるが, +α(その1)で解説されたItemDatabaseを実装したことで, このコードはO(1)に軽量化される:
ItemDatabase.get_all_items_with_capability(手作りクラフト可能Cap)

こうやって, 事前にキャッシュされた辞書から検索すれば, O(1)ですべてのクラフト可能アイテムが出てくる.

パターン2: 動くが, ちょっと気に障る

クラフト可能Capにenum工作台種類を入れる
enum 工作台Type{手作り, 作業台}
@export 対象工作台: 工作台Type (public 工作台Type 対象工作台)
@export レシピ:Dictionary[item_id, 数]

こうやって, 指定する工作台によって, 複数個クラフト可能Cap乗せば, 動く.

気に障るといった点は, 沢山のクラフト可能Capが, インスペクターメニューで展開され, 少々見づらい (折り畳んでも最小限スペースかかるので. 本当はどうでもいいけど). 意味上は悪くない.

パターン3: 僕が思う最適解

レシピというScriptableObject/Resourceを定義し,
このResourceは,

  • Enum 工作台Type{手作り, 作業台…} (或いはStringName)
    Dictionary{素材ID, 数}
    Dictionary{結果ID, 数}
    工作台Type 対象工作台 (或いはStringName)
    float クラフト所要時間

こうやって定義し,

クラフト可能Capは, Array[レシピ]だけで収まるようにする.

これも一見パターン2と同じように, インスペクタースペースかかるに見えるが, 他のCapsと同じレベルではなく, クラフト可能Cap一個でに納めるレベルなので, クラフト可能Capを畳めるば, 最小限のスペースで納める.

+α(その2): Command Pattern

Command, つまり「指令」 Patternというのは, 「命令(コマンド)をオブジェクトにして渡す」っていうイメージに近い.

このパターンは使えるところは結構多い. 例えば, 「Replay」, 「Undo・Redo」機能や, ターン制ゲームなどなら, Command Patternならば比較的に気軽に作れる.
今回はそこまでいかない. イメージにしては, 「具体的な行動はどうであれ, 関数名に一致性を持てよう」っていうこと.

マインクラのモブで例えると, クリーパーの攻撃は, 「自爆」で , スケルトンの攻撃は, 矢を打つ, とはいえ, ここで creeper.explode(self)skeleton.shoot(player)みたいに, 同じ「攻撃」でも関数名やパラメーターを分けると, 管理しづらいと思うかもしれない.

じゃぁ, attack(source, target)を定義すれば,

for mob in mob_array:
  if mob.type is &"creeper": creeper.explode(self)
  if mob.type is &"skeleton": skeleton.shoot(player)
  ...


で書くより,
for mob in mob_array: mob.attack(self, player)
のほうが, 管理しやすい


アイテムも同然. 消費可能Capを持つアイテムは, 「消費効果」, 装備品エンチャントCap,例えば「攻撃命中時対象に雷属性ダメージ10を与える」など. 一見関係性ないようだが, 実行可能Capとしてまとめることができる.


ここで, 実行可能CapにCommand Pattern利用すると何ができる?

このようなアイテムを想像しよう:

パンドラのスクロール
効果:
ステータス全回復, 200単位範囲内のランダム場所にワープ, 全異常状態解除, 使用者に毒, 炎上, 麻痺効果を与える, 直ちに使用者のレベル+1 …などなど
の中, ランダムに3個選び, 発動する.

本記事で作ったアイテムシステムなら, このようなアイテムのランダム生成ですら, 実現できる, 更に, Command Patternで,

for 実行可能Cap in pandora_selected:
  実行可能Cap.execute(player)


(各アクション種類のexecute関数は勿論別途で定義する必要がある)

Unity マイナス百億万点

Odin買え, 俺らは自分のエディターを治すつもりはない!

この上で定義した機能(本当はこのアイテムシステム全体が使えないが, これは僕が多少解決してる) は, Polymorphismというオブジェ指向の概念 (Wikipedia: それぞれ異なる型に一元アクセスできる共通接点の提供,またはそれぞれ異なる型の多重定義を一括表現できる共通記号の提供を目的にした、型理論またはプログラミング言語理論の概念および実装である)を使って実装される.

要は, クラフト可能Cap 消耗品Cap 装備品Cap など, これらに共通してる部分は, どれもItemCapabilityから継承している. つまり, is ItemCapability でチェックすると, 全部Trueで戻るはず. これが, Polymorphism.


じゃ, Unityの何が問題になってるかというと, 自分でエディターツール作らないと, そもそもエディターで展開することができない.

これを自分なりにエディターツール作って, 解決したと思ったら, Listの中のListに, 同じことをしようとすると, 失敗した.

Polymorphismが使えるListの中, 更にPolymorphismを使おうとしたら, どうやらUnityでは簡単にできない. (Googleで検索したら, この問題は少なくても10年前から去年までも質問してる人がいた)

Unityトラブル詳細


もっと詳しく説明すると, Unityは, [SerializeReference]というのは, エディターに, 「ここのフィールドは値ではなく, リファレンスだ」という.

[SerializeReference] private List _capabilities = new List();

ここで, [SerializeReference]がないと, そもそもエディターではListが現れない.



[SerializeReference]を追加して, Listが現るが, 種類の定義はできない.


更に自作エディタースクリプトで, やっと最小限の動作ができるようになるが

ビデオで見えように, Listの中のListは, どうしても動かないのである.

本当はこれ, 解決策がない分けではないが, 僕の有限なUnity知識では, 「共通解」が作れなく, 「必要Capability一つにつき, 専用エディタースクリプトを定義」しなければならなかった. これは, 流石に本末転倒面倒くさいだと思い, このアプローチは, Unityとの相性が最悪だと結論つき, 諦めた.



「じゃぁピュアC#データではなく, Scriptable Objectとして定義すればいいのでは? 」と思う人もいるかもしれないが,

Unityは, Godotと違って, Nested Resource, 要は, Resourceの中にリソースを定義することができないのである.

この違いにもたらすワークフローの違いは, 言葉では中々伝わらない. Unityでは, まずある場所で, 使いたいすべてのパラメーターに対してScriptable Objectを定義し, その作ったSOを使いたい場所で選ぶ必要がある. その場では定義できないし, パラメーターがちょっとだけ違っても, 新しいファイルを作る必要がある.



因みに, この問題は, 「Odin Inspector and Serializer」というプラグインで解決できる(らしい).

じゃぁ, Odinはいくらかかるんだ…?

60ドルか, これって円にすると?


Okay, Unityって, アセットストア内の商品の売上げから, いくらもらうんだっけ…?

30%

この問題って何年前から質問してる人がいるんだっけ…?

10年以上前.

色々わかった気がする. まぁ冗談は程々にして, 結局のところは, ツールを上手く調整できない自分のコーディング力がまだまだってことからもたらした無力感がイライラしていた.
今の段階, ツールを自分の用途に合わせるようにできないなら, 自分のやり方をツールによって調整するしかない.

Unity用アプローチ

そもそもあまり使わないゲームエンジンに, 使いたい時間内に エディターに言うことをさせる能力は僕にはなかったので, アイテムシステムのアプローチを変えるしかない.

別に最初から, 単純に「気に障る」だけなので, このアプローチ自体は問題はないはず.
簡単にいうと, 「一つにまとめないなら, 複数個入れればいい」.

Godot用アプローチ

Godot 用アプローチは, 既に本編で解説した故, ここは略.

まとめ

アイテムは「本質」と「拡張」を分けるとスッキリする. 本体(Item)にはIDや表示名などの共通項だけ, 機能はCapability(Cap)として積む.

検索は人力ループではなくインデックス化したItemDatabaseでO(1)平均へ寄せる.

クラフトの分岐や拡張はRecipeリソースに押し込み, Capは配列で抱える. 実行系の効果はCommandパターンexecute()に統一する. これで, クラス爆発と“機能追加=コード改変”の強制連動を断ち切れる.

  • 拡張性:機能はCapの追加・差し替えで合成できる. 万能薬や“投げられて売買可の剣”も組み合わせで表現.
  • データ駆動:企画・デザイナーがScriptを書かずに増やせる設計に寄せやすい.
  • 可読性とテスト性:呼び出し側はget_items_with_cap()cap.execute()に収束. ユニットテストもしやすい.
  • 性能予測:起動時/ビルド時にインデックス化すれば, UI側の検索は定数時間で安定.

もちろん, アイテムが数個しかないミニマルなゲームなら重装備は不要だ. だが規模が育つほど, “継承で語るより、構成で積む”設計は効く. 次回(+α その2)では, ExecutableCapのexecute()設計と, レシピ資源の実装ノートを具体コードで示す.

コメントを残す

CAPTCHA