現場レベルのゲーム制作が、すべてここで学べます。
この講座ではUnityを使って「箱庭経営シミュレーション&農場ゲーム」を作成していきます。
今回はその第4回目です。
前回はプレイヤーがアイテムを拾ったり落としたりできるシステムを作成しました。
前回の記事:

第4回では、前回までの実装をさらに応用してアイテムをまとめて持てる機能を実装します。
3D空間の農場で一つ一つ手でニンジンを運ぶのはとても大変です。一気に複数のニンジンをバケツに入れて運べたらとても嬉しい。
ですが、3Dモデルを別の3Dモデルに入れて運ぶのはやろうとしても意外とやり方がわからない・・・
今回はそんな状態から3Dモデルのアイテムを別の3Dモデルに入れて運ぶ方法や、3Dゲームで複数個のアイテムをまとめて扱えるスキルを習得していきましょう。
アイテムを複数入れるための箱を作成する
まずは、操作対象となる「箱」のオブジェクトをシーンに配置します。
Projectウィンドウから、次のパスを開いてください。
AssetHuntsl > GameDev Starter Kit – Farming > Assets > Prop > Props_Bucket_01
表示された Props_Bucket_01 を、
Hierarchy ウィンドウへドラッグ&ドロップしてください。
シーン上にバケツの3Dモデルが配置されます。

配置した箱のオブジェクトを選択し、
Inspectorに表示されている Transform を以下のように設定してください。

今回はこのバケツを複数アイテムを持てる箱アイテムにしていきます

複数アイテムを持てるようにするスクリプトを作成しよう。
今回はあくまで前回の応用編です。
前回作成した「ItemBase」と「ItemInteractor」をベースにして、新しいスクリプトを作っていきます。
ItemBaseを使って「BoxItem」スクリプトを作成しよう
まずは、ボックス本体が収納しているアイテムを管理するスクリプトを作成します。
Projectウィンドウで Assets を開き、Scripts フォルダを開きます。
フォルダ内で右クリックして Create > MonoBehaviour Script を選択し、名前を「BoxItem」にしましょう。

作成できたら、そのスクリプトをダブルクリックで開き、以下の内容を書き込んでください。
BoxItem.cs
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 |
using System.Collections.Generic; using UnityEngine; using DG.Tweening; public class BoxItem : ItemBase { [Header("箱設定")] [SerializeField] private int maxItems = 10; // 箱内部のスロット [SerializeField] private Transform[] ItemSlots; // スロットが足りないときの親 [SerializeField] private Transform contentRoot; private readonly List<ItemBase> storedItems = new List<ItemBase>(); // 元スケール保存 private readonly Dictionary<ItemBase, Vector3> originalScales = new Dictionary<ItemBase, Vector3>(); [Header("収納演出")] [SerializeField] private float storeDuration = 0.2f; [SerializeField] private float takeOutDuration = 0.15f; [SerializeField] private Ease storeEase = Ease.InQuad; [SerializeField] private Ease takeOutEase = Ease.OutQuad; // UI / 判定用 public int MaxItems => Mathf.Max(1, maxItems); public int StoredCount => storedItems.Count; public bool HasStoredItems => storedItems.Count > 0; // 「空きがある」= UIが “収納” を出す条件に使える public bool HasFreeSpace => storedItems.Count < MaxItems; // 「満杯」= UIが “満杯” 表示に使える public bool IsFull => storedItems.Count >= MaxItems; // Store public bool TryStoreItem(ItemBase Item) { if (Item == null) return false; if (storedItems.Contains(Item)) return false; // 満杯なら収納不可 if (!HasFreeSpace) return false; Transform targetParent = GetFreeSlotTransform(); if (targetParent == null) targetParent = contentRoot != null ? contentRoot : transform; // 元スケール保存 if (!originalScales.ContainsKey(Item)) originalScales[Item] = Item.transform.localScale; // 物理停止(Rigidbodyが子に付いている場合もあるので Children を探す方が安全) Rigidbody targetRb = Item.GetComponentInChildren<Rigidbody>(); if (targetRb != null) { #if UNITY_6000_0_OR_NEWER targetRb.linearVelocity = Vector3.zero; #else targetRb.velocity = Vector3.zero; #endif targetRb.angularVelocity = Vector3.zero; targetRb.isKinematic = true; targetRb.useGravity = false; } // Collider OFF(箱内部での衝突不要) Collider[] cols = Item.GetComponentsInChildren<Collider>(true); foreach (var c in cols) c.enabled = false; // まず親だけ設定(ワールド座標そのまま) Item.transform.SetParent(transform, worldPositionStays: true); // DOTween で箱スロットへ吸い込まれる演出 Sequence seq = DOTween.Sequence(); seq.Join(Item.transform.DOMove(targetParent.position, storeDuration).SetEase(storeEase)); seq.Join(Item.transform.DORotateQuaternion(targetParent.rotation, storeDuration).SetEase(storeEase)); seq.Join(Item.transform.DOScale(originalScales[Item] * 0.5f, storeDuration).SetEase(storeEase)); seq.OnComplete(() => { // 最終的な親をスロットにする Item.transform.SetParent(targetParent, worldPositionStays: false); Item.transform.localPosition = Vector3.zero; Item.transform.localRotation = Quaternion.identity; }); // Box 自体が Hand レイヤーなら中身もあわせる int handLayer = LayerMask.NameToLayer("Hand"); if (gameObject.layer == handLayer) SetLayerRecursiveInternal(Item.transform, handLayer); else SetLayerRecursiveInternal(Item.transform, gameObject.layer); storedItems.Add(Item); return true; } private Transform GetFreeSlotTransform() { if (ItemSlots == null || ItemSlots.Length == 0) return null; foreach (var slot in ItemSlots) { if (slot != null && slot.childCount == 0) return slot; } return null; } // Take out public ItemBase PopLastItem() { if (storedItems.Count == 0) return null; int lastIndex = storedItems.Count - 1; ItemBase Item = storedItems[lastIndex]; storedItems.RemoveAt(lastIndex); if (Item == null) return null; // 元スケール取得 Vector3 originalScale = Vector3.one; if (originalScales.TryGetValue(Item, out var s)) originalScale = s; // 親から外す(位置/回転は一旦維持) Item.transform.SetParent(null, worldPositionStays: true); // レイヤーを元に戻す SetLayerRecursiveInternal(Item.transform, originalLayer); // 物理復帰 Rigidbody r = Item.GetComponentInChildren<Rigidbody>(); if (r != null) { r.isKinematic = false; r.useGravity = true; #if UNITY_6000_0_OR_NEWER r.linearVelocity = Vector3.zero; #else r.velocity = Vector3.zero; #endif r.angularVelocity = Vector3.zero; r.WakeUp(); } // コライダーON Collider[] cols = Item.GetComponentsInChildren<Collider>(true); foreach (var c in cols) c.enabled = true; // 箱からポンっと元サイズに戻る演出 Item.transform.DOScale(originalScale, takeOutDuration).SetEase(takeOutEase); return Item; } public override void OnDropped(Vector3 forward) { base.OnDropped(forward); } } |
スクリプトの解説
これは「箱=アイテム」そのものが、中身(他のアイテム)を保持できるコンテナとして振る舞うための ItemBase 派生クラスになっています。
主な機能は3つ
- 収納管理(データ)
- 何が何個入っているかを storedItems で管理
- 最大収納数 maxItems による制限もここで判定
- 見た目(箱に吸い込まれる/出てくる演出)
- DOTweenで「スロットに吸い込まれる」演出
- 取り出し時に「元のサイズに戻る」演出
- 物理・当たり判定の安全化
- 収納中は Rigidbody を止めて kinematic化&重力OFF
- Collider をOFFにして箱の中で暴れないようにする
- 取り出し時に全部元に戻す
クラス全体の役割
|
1 |
public class BoxItem : ItemBase |
ItemBase は箱そのものがアイテムとして持てる機能を提供する抽象クラスでした。そのItemBaseを継承し、さらに中に別のアイテムを収納/取り出しできるクラスがBoxItemです。
フィールド(変数)ブロックの役割
|
1 2 3 4 5 6 7 8 |
[Header("箱設定")] [SerializeField] private int maxItems = 10; // 箱内部のスロット [SerializeField] private Transform[] ItemSlots; // スロットが足りないときの親 [SerializeField] private Transform contentRoot; |
- maxItems:アイテムの最大収納数
- itemSlots:箱の中の“置き場所”(空スロットに入れる)
- contentRoot:スロットが足りないときにまとめて入れる親
例えば…

| 段ボール | 紙袋 | |
| maxItems | 6 | 6 |
| itemSlots |
右下、右真ん中、右上 左下、左真ん中、左上 計6箇所 |
紙袋上部、右と左 計2箇所 |
| contentRoot | 設定しなくてもいい |
袋の真ん中(見えない部分) |
この時それぞれ6個のにんじんを収納したとき以下のようににんじんが収納されます。
段ボール
- itemSlots … 6個
- contentRoot … 0個
紙袋
- itemSlots … 2個
- contentRoot … 4個
このようにすることで視覚的表現が必要ない箱などでも対応することができます。
例えば100個収納できるリュックがあったときに視覚的表現は必要ないので
- itemSlots … 0個
- contentRoot … 100個
このようになります。これをInspectorで設定したとき、もしitemSlotsしかなかったらスロットを100個用意しないといけません。しかしcontentRootを用意することで設定一つで対応することができます。
今回サンプルゲームで作るバケツの箱の場合はスロット3、maxitems3で設定するので収納可能数と表示アイテム数が一致するパターンですが、数値を調整すると視覚的に見えないところに収納することも可能です。
例えばバケツの箱にレベルシステムを導入し、レベルアップしたら収納数が増える!なんていうシステムも今回の作り方なら対応できます。
|
1 2 3 4 |
private readonly List<ItemBase> storedItems = new List<ItemBase>(); // 元スケール保存 private readonly Dictionary<ItemBase, Vector3> originalScales = new Dictionary<ItemBase, Vector3>(); |
- storedItems:中に入っている ItemBase を順番付きで保持(リスト)
- originalScales:縮小演出に使うので、元のスケールを保存する辞書
|
1 2 3 4 5 |
[Header("収納演出")] [SerializeField] private float storeDuration = 0.2f; [SerializeField] private float takeOutDuration = 0.15f; [SerializeField] private Ease storeEase = Ease.InQuad; [SerializeField] private Ease takeOutEase = Ease.OutQuad; |
- storeDuration / takeOutDuration:収納/取り出しの演出時間
- storeEase / takeOutEase:動き方(イージング)
プロパティ(状態を返すだけの“小関数”)
|
1 |
public int MaxItems => Mathf.Max(1, maxItems); |
maxItems が 0 やマイナスでも最低 1 になるように安全化。
|
1 |
public int StoredCount => storedItems.Count; |
今箱に収納されてるアイテムの数。
|
1 |
public bool HasStoredItems => storedItems.Count > 0; |
“中身があるか”の判定。UIで「取り出す」ボタン表示に使える。
|
1 2 |
// 「空きがある」= UIが “収納” を出す条件に使える public bool HasFreeSpace => storedItems.Count < MaxItems; |
“空きがあるか”の判定。UIで「収納」ボタン表示に使える。
|
1 2 |
// 「満杯」= UIが “満杯” 表示に使える public bool IsFull => storedItems.Count >= MaxItems; |
“満杯か”の判定。「満杯」表示などに使える。
収納機能の実装:TryStoreItem(ItemBase Item)
役割:引数の Item を箱に収納する(できたら true、無理なら falseを戻り値で返す)
① 収納できるかチェック
|
1 2 3 4 5 |
if (Item == null) return false; if (storedItems.Contains(item)) return false; // 満杯なら収納不可 if (!HasFreeSpace) return false; |
- null防止
- 二重収納防止
- 満杯なら収納不可
② 入れる場所(親Transform)を決める
|
1 2 3 |
Transform targetParent = GetFreeSlotTransform(); if (targetParent == null) targetParent = contentRoot != null ? contentRoot : transform; |
- まず空いてるスロットを探す
- なければ contentRoot

- それもなければ箱自身を親にする(最悪のケース)
③ 元スケールを保存(取り出しで戻すため)
アイテムを収納しているときと取り出されてるときでオブジェクトのサイズが異なるための対応処理です。
|
1 2 3 |
// 元スケール保存 if (!originalScales.ContainsKey(Item)) originalScales[Item] = Item.transform.localScale; |

箱を使用する際にはこのように入れるアイテムを小さくして収納しています。
例えば、にんじんのサイズはそのままで箱を大きくした場合、箱がでかすぎてしまいゲーム全体で見た時箱のサイズに違和感が生まれます。逆もまた同じでニンジンを小さくしてしまうと、小さすぎて扱いづらく、プレイヤーのストレスになってしまいます。
このような問題を解決するためににんじんと箱のサイズを同じくらいにしておいて、収納するときはにんじんを小さくして収納しています。
④ 物理を止める(箱の中で暴れないように)
|
1 2 3 4 5 6 7 8 9 10 11 12 13 |
// 物理停止(Rigidbodyが子に付いている場合もあるので Children を探す方が安全) Rigidbody targetRb = Item.GetComponentInChildren<Rigidbody>(); if (targetRb != null) { #if UNITY_6000_0_OR_NEWER targetRb.linearVelocity = Vector3.zero; #else targetRb.velocity = Vector3.zero; #endif targetRb.angularVelocity = Vector3.zero; targetRb.isKinematic = true; targetRb.useGravity = false; |
こちらで物理を止めておかないと以下のようになってしまいます↓↓↓

↑実行例です。現段階ではまだこのようには動きません。
⑤ ColliderをOFF(箱内部で衝突不要)
|
1 2 3 |
// Collider OFF(箱内部での衝突不要) Collider[] cols = Item.GetComponentsInChildren<Collider>(true); foreach (var c in cols) c.enabled = false; |
⑥ 一旦、箱の子にする(ワールド座標維持)
|
1 2 |
// まず親だけ設定(ワールド座標そのまま) Item.transform.SetParent(transform, worldPositionStays: true); |
演出中に親が無い/別親だと位置計算が崩れやすいので、まず箱の子へ。
⑦ DOTweenで収納演出 → 最後にスロットへ確定
|
1 2 3 4 5 6 7 8 9 10 11 12 |
// DOTween で箱スロットへ吸い込まれる演出 Sequence seq = DOTween.Sequence(); seq.Join(Item.transform.DOMove(targetParent.position, storeDuration).SetEase(storeEase)); seq.Join(Item.transform.DORotateQuaternion(targetParent.rotation, storeDuration).SetEase(storeEase)); seq.Join(Item.transform.DOScale(originalScales[Item] * 0.5f, storeDuration).SetEase(storeEase)); seq.OnComplete(() => { // 最終的な親をスロットにする Item.transform.SetParent(targetParent, worldPositionStays: false); Item.transform.localPosition = Vector3.zero; Item.transform.localRotation = Quaternion.identity; }); |
- Move / Rotate / Scale を同時に実行(Join)
- 完了したら最終的な親を targetParent にして、ローカル位置・回転をきれいに揃える
↓このようにDOTweenでスムーズな演出ができます。

↑実行例です。現段階ではまだこのようには動きません。
⑧ レイヤーを箱に合わせる
|
1 2 3 4 5 6 |
// Box 自体が Hand レイヤーなら中身もあわせる int handLayer = LayerMask.NameToLayer("Hand"); if (gameObject.layer == handLayer) SetLayerRecursiveInternal(Item.transform, handLayer); else SetLayerRecursiveInternal(Item.transform, gameObject.layer); |
- 箱が Hand レイヤーなら中身も Hand へ
- そうでなければ箱と同じレイヤーへ
もしこれをしなかったら…

↑実行例です。現段階ではまだこのようには動きません。
このようにバケツのみが”Hand”レイヤー(最前面に表示されるレイヤー)になってしまいます。
⑨ リストに登録して完了
|
1 2 |
storedItems.Add(Item); return true; |
空スロット検索:GetFreeSlotTransform()
役割: itemSlots の中から“空いてるスロット”を探して返す。なければ null
|
1 2 3 4 5 6 7 8 9 10 11 12 |
private Transform GetFreeSlotTransform() { if (ItemSlots == null || ItemSlots.Length == 0) return null; foreach (var slot in ItemSlots) { if (slot != null && slot.childCount == 0) return slot; } return null; } |
- childCount == 0 → そのスロットに何も入ってない
- 最初に見つけた空スロットを返す(先着順)
取り出し:PopLastItem()
役割:最後に収納したアイテムを1つ取り出して返す。空なら null
① 中身チェック → 最後の要素を取り出す
|
1 2 3 4 5 |
if (storedItems.Count == 0) return null; int lastIndex = storedItems.Count - 1; ItemBase Item = storedItems[lastIndex]; storedItems.RemoveAt(lastIndex); |
② null保険
|
1 |
if (Item == null) return null; |
③ 元スケール取得(戻す用)
|
1 2 3 4 |
// 元スケール取得 Vector3 originalScale = Vector3.one; if (originalScales.TryGetValue(Item, out var s)) originalScale = s; |

④ 親から外す
|
1 2 |
// 親から外す(位置/回転は一旦維持) Item.transform.SetParent(null, worldPositionStays: true); |
⑤ レイヤーを元に戻す
|
1 2 |
// レイヤーを元に戻す SetLayerRecursiveInternal(Item.transform, originalLayer); |
もしこれをしなかったら…

↑実行例です。現段階ではまだこのようには動きません。
このようにアイテムが取り出し後も“Hand”レイヤー(最前面に表示されるレイヤー)になってしまいます。
⑥ 物理を戻す
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 |
// 物理復帰 Rigidbody r = Item.GetComponentInChildren<Rigidbody>(); if (r != null) { r.isKinematic = false; r.useGravity = true; #if UNITY_6000_0_OR_NEWER r.linearVelocity = Vector3.zero; #else r.velocity = Vector3.zero; #endif r.angularVelocity = Vector3.zero; r.WakeUp(); } |
収納中に止めたものを復帰
⑦ ColliderをONに戻す
|
1 2 3 |
// コライダーON Collider[] cols = Item.GetComponentsInChildren<Collider>(true); foreach (var c in cols) c.enabled = true; |
⑧ 元サイズへ戻す演出
|
1 2 |
// 箱からポンっと元サイズに戻る演出 Item.transform.DOScale(originalScale, takeOutDuration).SetEase(takeOutEase); |

↑実行例です。現段階ではまだこのようには動きません。
⑨ 取り出したアイテムを返す
|
1 |
return Item; |
ドロップ時:OnDropped(Vector3 forward)
|
1 2 3 4 |
public override void OnDropped(Vector3 forward) { base.OnDropped(forward); } |
「箱を落とした時」の挙動を ItemBase の処理に任せる
ここまででBoxItemのスクリプト解説は終了です。
ItemInteractorにBoxの処理を追加しよう
次に ItemInteractor にBox 関係の処理を追加して、プレイヤーが箱に干渉できるようにします。
Projectウィンドウから Scripts フォルダを開き、ItemInteractor を見つけたらダブルクリックして開きましょう。

開けたら、以下のマーカー部分を書き込んでください。
まとめ

今回は、前回作成したプレイヤーがアイテムを持てる基本システムを応用し、バケツで農作物のにんじんを複数持てるようにしました。
次の講座では、箱庭シミュレーションとして重要な農作物が畑から生えてくるシステム、畑システムを作成しましょう。
次の記事:
現場レベルのゲーム制作が、すべてここで学べます。







コメント