【Cocos Creator】アタッチするだけ!InventoryGrid (インベントリ)の実装方法【TypeScript】

Godot 4ゲーム制作 実践ドリル 100本ノック

新品価格
¥1,250から
(2025/12/13 21:27時点)

Godot4& GDScriptではじめる 2Dゲーム開発レシピ

新品価格
¥590から
(2025/12/13 21:46時点)

Unity 6 C#スクリプト 100本ノック

新品価格
¥1,230から
(2025/12/29 10:28時点)

Cocos Creator100本ノックTypeScriptで書く!

新品価格
¥1,250から
(2025/12/29 10:32時点)

【Cocos Creator 3.8】InventoryGrid の実装:アタッチするだけで「インベントリのグリッド表示+ドラッグ&ドロップ入れ替え」を実現する汎用スクリプト

このガイドでは、任意のノードにアタッチするだけで、

  • アイテム配列をグリッド状に並べて表示
  • マウス / タッチでアイコンをドラッグ&ドロップしてスロット入れ替え

ができる 独立コンポーネント InventoryGrid を実装します。
外部の GameManager やシングルトンに依存せず、すべてインスペクタから設定可能な設計になっています。


コンポーネントの設計方針

機能要件の整理

  • グリッドレイアウト
    • 指定した行数・列数でスロットを並べる
    • スロット間の間隔(横・縦)を指定できる
    • 左上原点 or 中央原点など、親ノードのアンカーに依存しないシンプルな配置
  • アイテム表示
    • インスペクタからアイテム配列を設定
    • 各アイテムは「アイコン画像(SpriteFrame)」と「任意のID文字列」を持つ
    • 空スロットは何も表示しない or プレースホルダー色で表示
  • ドラッグ&ドロップ
    • スロット上のアイコンをドラッグ開始
    • ドラッグ中はアイコンをマウス / タッチ位置に追従させる
    • ドロップ位置のスロットとアイテムを入れ替え
    • ドロップ先が無効な場合は元の位置に戻す
  • 外部依存なし
    • 他のスクリプトからの参照は不要
    • 必要な設定はすべて @property 経由でインスペクタから行う
  • 防御的実装
    • 必要な標準コンポーネント(UITransform, Sprite, Widget など)はコード内で付与 or チェック
    • 足りない場合は console.error で明示的に警告

インスペクタで設定可能なプロパティ設計

InventoryGrid では、以下のようなプロパティを用意します。

  • グリッド設定
    • rows: number
      行数。1 以上の整数。例: 4
    • columns: number
      列数。1 以上の整数。例: 5
    • slotSize: Size
      各スロットの幅・高さ。例: (64, 64)
    • horizontalSpacing: number
      スロット間の横方向の間隔(ピクセル)。例: 8
    • verticalSpacing: number
      スロット間の縦方向の間隔(ピクセル)。例: 8
    • centerGrid: boolean
      true の場合、親ノードの中心にグリッド全体をセンタリングして配置。false の場合、親のローカル (0,0) を左上基準として配置。
  • スロット・アイコン表示設定
    • slotBackground: SpriteFrame | null
      各スロットの背景に使う SpriteFrame。null の場合は背景を描画しない。
    • emptySlotColor: Color
      アイテムが無いスロットの背景色。
    • occupiedSlotColor: Color
      アイテムがあるスロットの背景色。
    • iconScale: number
      アイコンのスロット内でのスケール。1 で等倍、0.8 で 80% 表示など。
  • ドラッグ&ドロップ設定
    • enableDragAndDrop: boolean
      D&D 機能を有効にするかどうか。
    • draggingOpacity: number
      ドラッグ中アイコンの透明度 (0–255)。例: 180
    • draggingScale: number
      ドラッグ中アイコンのスケール倍率。例: 1.1
    • swapOnlyOnValidSlot: boolean
      true: スロットの上でのみ入れ替えを行い、それ以外では元に戻す。
      false: 一番近いスロットにスナップして入れ替え。
  • アイテムデータ
    • itemIcons: SpriteFrame[]
      アイテムごとのアイコン画像。インスペクタから配列として設定。
    • itemIds: string[]
      アイテムごとの ID。itemIcons と同じインデックスで対応。
      (例えば itemIcons[0] の ID は itemIds[0])。

内部的には、スロット総数 = rows × columns とし、
itemIcons / itemIds の配列長がそれより短い場合は、残りスロットは空として扱います。


TypeScriptコードの実装

以下が完成した InventoryGrid コンポーネントの全コードです。


import {
    _decorator,
    Component,
    Node,
    UITransform,
    Sprite,
    SpriteFrame,
    Color,
    Vec3,
    Vec2,
    Size,
    EventTouch,
    EventMouse,
    input,
    Input,
    Camera,
    find,
    math,
    UIOpacity,
    Widget,
    Layers,
} from 'cc';
const { ccclass, property, type } = _decorator;

/**
 * InventoryGrid
 * 任意のノードにアタッチするだけで、
 * - グリッドレイアウトでスロットを自動生成
 * - アイテム配列を表示
 * - ドラッグ&ドロップでスロット間のアイテム入れ替え
 * を行う汎用コンポーネント
 */
@ccclass('InventoryGrid')
export class InventoryGrid extends Component {

    // =========================
    // グリッド設定
    // =========================

    @property({
        tooltip: 'グリッドの行数(1以上の整数)',
        min: 1,
    })
    public rows: number = 4;

    @property({
        tooltip: 'グリッドの列数(1以上の整数)',
        min: 1,
    })
    public columns: number = 5;

    @property({
        tooltip: '各スロットのサイズ(幅・高さ)',
    })
    public slotSize: Size = new Size(64, 64);

    @property({
        tooltip: 'スロット間の横方向の間隔(ピクセル)',
    })
    public horizontalSpacing: number = 8;

    @property({
        tooltip: 'スロット間の縦方向の間隔(ピクセル)',
    })
    public verticalSpacing: number = 8;

    @property({
        tooltip: 'true: 親ノードの中心にグリッド全体をセンタリングして配置\nfalse: 親ノードのローカル(0,0)を左上として配置',
    })
    public centerGrid: boolean = true;

    // =========================
    // スロット・アイコン表示設定
    // =========================

    @property({
        type: SpriteFrame,
        tooltip: '各スロットの背景に使用するSpriteFrame(任意)。未設定の場合は背景なし',
    })
    public slotBackground: SpriteFrame | null = null;

    @property({
        tooltip: 'アイテムが無いスロットの背景色',
    })
    public emptySlotColor: Color = new Color(60, 60, 60, 255);

    @property({
        tooltip: 'アイテムがあるスロットの背景色',
    })
    public occupiedSlotColor: Color = new Color(120, 120, 120, 255);

    @property({
        tooltip: 'アイコンのスロット内でのスケール(1で等倍)',
    })
    public iconScale: number = 0.9;

    // =========================
    // ドラッグ&ドロップ設定
    // =========================

    @property({
        tooltip: 'ドラッグ&ドロップでスロット間のアイテム入れ替えを有効にするか',
    })
    public enableDragAndDrop: boolean = true;

    @property({
        tooltip: 'ドラッグ中アイコンの透明度(0〜255)',
        min: 0,
        max: 255,
    })
    public draggingOpacity: number = 180;

    @property({
        tooltip: 'ドラッグ中アイコンのスケール倍率',
        min: 0.1,
    })
    public draggingScale: number = 1.1;

    @property({
        tooltip: 'true: 有効なスロット上でのみ入れ替え、false: 最も近いスロットにスナップして入れ替え',
    })
    public swapOnlyOnValidSlot: boolean = true;

    // =========================
    // アイテムデータ
    // =========================

    @property({
        type: [SpriteFrame],
        tooltip: 'インベントリに表示するアイテムアイコンの配列。rows×columns を超えた分は無視されます',
    })
    public itemIcons: SpriteFrame[] = [];

    @property({
        type: [String],
        tooltip: 'アイテムのID配列。itemIconsと同じインデックスで対応(長さが足りない場合は空文字になります)',
    })
    public itemIds: string[] = [];

    // =========================
    // 内部状態
    // =========================

    /** スロットを表すノード配列(行優先で index = row * columns + col) */
    private _slotNodes: Node[] = [];

    /** 各スロットに紐づくアイコンノード(スロットと同じインデックス) */
    private _iconNodes: Node[] = [];

    /** 現在のアイテムアイコン配列(内部コピー) */
    private _currentItemIcons: (SpriteFrame | null)[] = [];

    /** 現在のアイテムID配列(内部コピー) */
    private _currentItemIds: string[] = [];

    /** ドラッグ中のスロットインデックス(-1ならドラッグなし) */
    private _draggingSlotIndex: number = -1;

    /** ドラッグ中のアイコンノード */
    private _draggingIconNode: Node | null = null;

    /** ドラッグ開始時のアイコンローカル位置 */
    private _dragStartLocalPos: Vec3 = new Vec3();

    /** ドラッグ開始時のアイコンスケール */
    private _dragStartScale: Vec3 = new Vec3(1, 1, 1);

    /** ドラッグ開始時のアイコン不透明度 */
    private _dragStartOpacity: number = 255;

    /** UI用カメラ(画面座標→ワールド座標変換用)。未設定ならCanvas下のCameraを自動検出 */
    @property({
        type: Camera,
        tooltip: 'UI用のCamera。未設定の場合、シーン内のCanvas配下から自動で探します',
    })
    public uiCamera: Camera | null = null;

    // =========================
    // ライフサイクル
    // =========================

    onLoad() {
        // 親ノードに UITransform があることを保証
        let uiTransform = this.node.getComponent(UITransform);
        if (!uiTransform) {
            uiTransform = this.node.addComponent(UITransform);
            console.warn('[InventoryGrid] 親ノードにUITransformが無かったため自動追加しました:', this.node.name);
        }

        // レイヤーをUIに設定(任意だが推奨)
        this.node.layer = Layers.Enum.UI_2D;

        // UIカメラの自動検出
        if (!this.uiCamera) {
            const canvas = find('Canvas');
            if (canvas) {
                const cameraNode = canvas.getComponentInChildren(Camera);
                if (cameraNode) {
                    this.uiCamera = cameraNode;
                } else {
                    console.warn('[InventoryGrid] Canvas配下にCameraが見つかりませんでした。ドラッグ位置の計算が正しく行えない可能性があります。');
                }
            } else {
                console.warn('[InventoryGrid] Canvasノードがシーン内に見つかりません。UIカメラの自動検出に失敗しました。');
            }
        }

        // 内部データの初期化
        this._initItemData();
        this._createGrid();
        this._refreshAllSlots();

        // 入力イベント登録
        this._registerInputEvents();
    }

    onDestroy() {
        this._unregisterInputEvents();
    }

    // =========================
    // 内部初期化処理
    // =========================

    /**
     * itemIcons / itemIds から内部データを初期化
     */
    private _initItemData() {
        const slotCount = this.rows * this.columns;

        this._currentItemIcons = new Array(slotCount).fill(null);
        this._currentItemIds = new Array(slotCount).fill('');

        const len = Math.min(slotCount, this.itemIcons.length);
        for (let i = 0; i < len; i++) {
            this._currentItemIcons[i] = this.itemIcons[i] || null;
            this._currentItemIds[i] = this.itemIds[i] || '';
        }
    }

    /**
     * グリッド(スロットノードとアイコンノード)を生成
     */
    private _createGrid() {
        // 既存をクリア
        this._slotNodes.forEach(node => node.destroy());
        this._slotNodes = [];
        this._iconNodes = [];

        const totalWidth =
            this.columns * this.slotSize.width +
            (this.columns - 1) * this.horizontalSpacing;
        const totalHeight =
            this.rows * this.slotSize.height +
            (this.rows - 1) * this.verticalSpacing;

        // 左上基準の原点を計算
        let originX = 0;
        let originY = 0;

        if (this.centerGrid) {
            // 中心に合わせる場合、左上は (-totalWidth/2, totalHeight/2)
            originX = -totalWidth / 2;
            originY = totalHeight / 2;
        } else {
            // 親ノードのローカル(0,0)を左上とする場合
            // 親のUITransformサイズから左上を計算
            const parentTransform = this.node.getComponent(UITransform);
            if (parentTransform) {
                originX = -parentTransform.width / 2;
                originY = parentTransform.height / 2;
            } else {
                console.error('[InventoryGrid] 親ノードにUITransformがありません。centerGrid=false時の配置が正しく行えません。');
            }
        }

        for (let row = 0; row < this.rows; row++) {
            for (let col = 0; col < this.columns; col++) {
                const index = row * this.columns + col;

                // スロットノード作成
                const slotNode = new Node(`Slot_${row}_${col}`);
                this.node.addChild(slotNode);

                const slotTransform = slotNode.addComponent(UITransform);
                slotTransform.setContentSize(this.slotSize);

                const x =
                    originX +
                    col * (this.slotSize.width + this.horizontalSpacing) +
                    this.slotSize.width / 2;
                const y =
                    originY -
                    row * (this.slotSize.height + this.verticalSpacing) -
                    this.slotSize.height / 2;

                slotNode.setPosition(new Vec3(x, y, 0));

                // 背景スプライト
                const bgSprite = slotNode.addComponent(Sprite);
                if (this.slotBackground) {
                    bgSprite.spriteFrame = this.slotBackground;
                }
                bgSprite.color = this.emptySlotColor;

                // アイコンノード作成
                const iconNode = new Node(`Icon_${row}_${col}`);
                slotNode.addChild(iconNode);

                const iconTransform = iconNode.addComponent(UITransform);
                iconTransform.setContentSize(this.slotSize.width, this.slotSize.height);

                const iconSprite = iconNode.addComponent(Sprite);
                iconSprite.sizeMode = Sprite.SizeMode.CUSTOM;

                iconNode.setPosition(Vec3.ZERO);
                iconNode.setScale(new Vec3(this.iconScale, this.iconScale, 1));

                // UIOpacity を付けておく(ドラッグ時に使う)
                iconNode.addComponent(UIOpacity);

                // スロットをクリックしやすくするためにWidgetを追加(任意)
                const widget = slotNode.addComponent(Widget);
                widget.isAlignLeft = widget.isAlignRight = widget.isAlignTop = widget.isAlignBottom = false;

                this._slotNodes[index] = slotNode;
                this._iconNodes[index] = iconNode;
            }
        }
    }

    /**
     * 全スロットの見た目を現在のアイテムデータに合わせて更新
     */
    private _refreshAllSlots() {
        const slotCount = this.rows * this.columns;
        for (let i = 0; i < slotCount; i++) {
            this._refreshSlot(i);
        }
    }

    /**
     * 指定スロットの見た目を更新
     */
    private _refreshSlot(index: number) {
        const slotNode = this._slotNodes[index];
        const iconNode = this._iconNodes[index];
        if (!slotNode || !iconNode) {
            return;
        }

        const iconSprite = iconNode.getComponent(Sprite);
        const bgSprite = slotNode.getComponent(Sprite);
        const opacityComp = iconNode.getComponent(UIOpacity);

        const icon = this._currentItemIcons[index];

        if (iconSprite) {
            iconSprite.spriteFrame = icon;
        }

        if (bgSprite) {
            bgSprite.color = icon ? this.occupiedSlotColor : this.emptySlotColor;
        }

        if (opacityComp) {
            opacityComp.opacity = icon ? 255 : 0; // アイテムがない場合はアイコンを非表示
        }
    }

    // =========================
    // 入力イベント
    // =========================

    private _registerInputEvents() {
        if (!this.enableDragAndDrop) {
            return;
        }

        // タッチ入力
        this.node.on(Node.EventType.TOUCH_START, this._onTouchStart, this);
        this.node.on(Node.EventType.TOUCH_MOVE, this._onTouchMove, this);
        this.node.on(Node.EventType.TOUCH_END, this._onTouchEnd, this);
        this.node.on(Node.EventType.TOUCH_CANCEL, this._onTouchCancel, this);

        // マウス入力(エディタでのテスト用)
        this.node.on(Node.EventType.MOUSE_DOWN, this._onMouseDown, this);
        this.node.on(Node.EventType.MOUSE_MOVE, this._onMouseMove, this);
        this.node.on(Node.EventType.MOUSE_UP, this._onMouseUp, this);
    }

    private _unregisterInputEvents() {
        this.node.off(Node.EventType.TOUCH_START, this._onTouchStart, this);
        this.node.off(Node.EventType.TOUCH_MOVE, this._onTouchMove, this);
        this.node.off(Node.EventType.TOUCH_END, this._onTouchEnd, this);
        this.node.off(Node.EventType.TOUCH_CANCEL, this._onTouchCancel, this);

        this.node.off(Node.EventType.MOUSE_DOWN, this._onMouseDown, this);
        this.node.off(Node.EventType.MOUSE_MOVE, this._onMouseMove, this);
        this.node.off(Node.EventType.MOUSE_UP, this._onMouseUp, this);
    }

    // ----- タッチイベントハンドラ -----

    private _onTouchStart(event: EventTouch) {
        const pos = event.getUILocation();
        this._beginDragAtUIPos(pos);
    }

    private _onTouchMove(event: EventTouch) {
        if (this._draggingIconNode) {
            const pos = event.getUILocation();
            this._updateDragAtUIPos(pos);
        }
    }

    private _onTouchEnd(event: EventTouch) {
        if (this._draggingIconNode) {
            const pos = event.getUILocation();
            this._endDragAtUIPos(pos);
        }
    }

    private _onTouchCancel(event: EventTouch) {
        if (this._draggingIconNode) {
            // キャンセル時は元の位置に戻す
            this._cancelDrag();
        }
    }

    // ----- マウスイベントハンドラ(エディタ / PC向け) -----

    private _onMouseDown(event: EventMouse) {
        const pos = event.getUILocation();
        this._beginDragAtUIPos(pos);
    }

    private _onMouseMove(event: EventMouse) {
        if (this._draggingIconNode) {
            const pos = event.getUILocation();
            this._updateDragAtUIPos(pos);
        }
    }

    private _onMouseUp(event: EventMouse) {
        if (this._draggingIconNode) {
            const pos = event.getUILocation();
            this._endDragAtUIPos(pos);
        }
    }

    // =========================
    // ドラッグ処理本体
    // =========================

    /**
     * UI座標(スクリーン座標)からドラッグ開始
     */
    private _beginDragAtUIPos(uiPos: Vec2) {
        if (!this.enableDragAndDrop) {
            return;
        }

        const localPos = this._convertUIToLocal(uiPos);
        if (!localPos) {
            return;
        }

        const slotIndex = this._hitTestSlot(localPos);
        if (slotIndex < 0) {
            return;
        }

        // 対象スロットにアイテムが無ければドラッグしない
        if (!this._currentItemIcons[slotIndex]) {
            return;
        }

        const iconNode = this._iconNodes[slotIndex];
        if (!iconNode) {
            return;
        }

        this._draggingSlotIndex = slotIndex;
        this._draggingIconNode = iconNode;

        // 開始時の位置・スケール・不透明度を保存
        this._dragStartLocalPos = iconNode.position.clone();
        this._dragStartScale = iconNode.scale.clone();

        const opacityComp = iconNode.getComponent(UIOpacity);
        this._dragStartOpacity = opacityComp ? opacityComp.opacity : 255;

        // ドラッグ中の見た目に変更
        if (opacityComp) {
            opacityComp.opacity = this.draggingOpacity;
        }
        iconNode.setScale(new Vec3(
            this._dragStartScale.x * this.draggingScale,
            this._dragStartScale.y * this.draggingScale,
            this._dragStartScale.z,
        ));

        // 一番手前に表示するために親の最後の子に移動
        const parent = iconNode.parent;
        if (parent) {
            iconNode.removeFromParent();
            parent.addChild(iconNode);
        }

        // 開始位置に即座に追従
        iconNode.setWorldPosition(this._convertUIToWorld(uiPos) ?? iconNode.worldPosition);
    }

    /**
     * ドラッグ中の更新
     */
    private _updateDragAtUIPos(uiPos: Vec2) {
        if (!this._draggingIconNode) {
            return;
        }
        const worldPos = this._convertUIToWorld(uiPos);
        if (worldPos) {
            this._draggingIconNode.setWorldPosition(worldPos);
        }
    }

    /**
     * ドラッグ終了処理(ドロップ)
     */
    private _endDragAtUIPos(uiPos: Vec2) {
        if (this._draggingSlotIndex < 0 || !this._draggingIconNode) {
            return;
        }

        const fromIndex = this._draggingSlotIndex;
        const localPos = this._convertUIToLocal(uiPos);
        if (!localPos) {
            this._cancelDrag();
            return;
        }

        let toIndex = this._hitTestSlot(localPos);

        if (this.swapOnlyOnValidSlot && toIndex < 0) {
            // 有効なスロット上でなければ元に戻す
            this._cancelDrag();
            return;
        }

        if (!this.swapOnlyOnValidSlot) {
            // 一番近いスロットにスナップ
            toIndex = this._findNearestSlotIndex(localPos);
        }

        if (toIndex < 0) {
            this._cancelDrag();
            return;
        }

        // 同じスロットにドロップされた場合は位置だけ戻す
        if (toIndex === fromIndex) {
            this._resetDraggingIconVisual();
            this._draggingSlotIndex = -1;
            this._draggingIconNode = null;
            return;
        }

        // アイテムを入れ替え
        this._swapItems(fromIndex, toIndex);

        // 全スロットを更新
        this._refreshSlot(fromIndex);
        this._refreshSlot(toIndex);

        // ドラッグ中アイコンの見た目をリセット
        this._resetDraggingIconVisual();

        this._draggingSlotIndex = -1;
        this._draggingIconNode = null;
    }

    /**
     * ドラッグキャンセル時に元の位置と見た目に戻す
     */
    private _cancelDrag() {
        if (!this._draggingIconNode) {
            return;
        }
        this._resetDraggingIconVisual();
        this._draggingSlotIndex = -1;
        this._draggingIconNode = null;
    }

    /**
     * ドラッグ中アイコンの見た目を初期状態に戻す
     */
    private _resetDraggingIconVisual() {
        if (!this._draggingIconNode) {
            return;
        }
        const iconNode = this._draggingIconNode;
        iconNode.setPosition(this._dragStartLocalPos);
        iconNode.setScale(this._dragStartScale);

        const opacityComp = iconNode.getComponent(UIOpacity);
        if (opacityComp) {
            opacityComp.opacity = this._dragStartOpacity;
        }
    }

    /**
     * UI座標(画面座標)をこのノードのローカル座標に変換
     */
    private _convertUIToLocal(uiPos: Vec2): Vec3 | null {
        const worldPos = this._convertUIToWorld(uiPos);
        if (!worldPos) {
            return null;
        }
        const parent = this.node;
        const localPos = parent.getComponent(UITransform)?.convertToNodeSpaceAR(worldPos);
        return localPos ?? null;
    }

    /**
     * UI座標(画面座標)をワールド座標に変換
     */
    private _convertUIToWorld(uiPos: Vec2): Vec3 | null {
        if (!this.uiCamera) {
            console.error('[InventoryGrid] uiCameraが設定されていないため、ドラッグ位置の計算ができません。InspectorでUI Cameraを指定してください。');
            return null;
        }
        const out = new Vec3();
        this.uiCamera.screenToWorld(new Vec3(uiPos.x, uiPos.y, 0), out);
        return out;
    }

    /**
     * ローカル座標がどのスロット上かを判定
     * @returns スロットインデックス(見つからなければ -1)
     */
    private _hitTestSlot(localPos: Vec3): number {
        const slotCount = this.rows * this.columns;
        for (let i = 0; i < slotCount; i++) {
            const slotNode = this._slotNodes[i];
            const slotTransform = slotNode.getComponent(UITransform);
            if (!slotTransform) continue;

            const rect = slotTransform.getBoundingBox();
            if (rect.contains(new Vec2(localPos.x, localPos.y))) {
                return i;
            }
        }
        return -1;
    }

    /**
     * ローカル座標に最も近いスロットのインデックスを返す
     */
    private _findNearestSlotIndex(localPos: Vec3): number {
        let nearestIndex = -1;
        let nearestDistSq = Number.MAX_VALUE;

        const temp = new Vec3();
        const slotCount = this.rows * this.columns;

        for (let i = 0; i < slotCount; i++) {
            const slotNode = this._slotNodes[i];
            if (!slotNode) continue;

            slotNode.getPosition(temp);
            const dx = temp.x - localPos.x;
            const dy = temp.y - localPos.y;
            const distSq = dx * dx + dy * dy;
            if (distSq < nearestDistSq) {
                nearestDistSq = distSq;
                nearestIndex = i;
            }
        }

        return nearestIndex;
    }

    /**
     * 2つのスロットのアイテムを入れ替える(内部データのみ)
     */
    private _swapItems(indexA: number, indexB: number) {
        const iconA = this._currentItemIcons[indexA];
        const iconB = this._currentItemIcons[indexB];
        const idA = this._currentItemIds[indexA];
        const idB = this._currentItemIds[indexB];

        this._currentItemIcons[indexA] = iconB;
        this._currentItemIcons[indexB] = iconA;
        this._currentItemIds[indexA] = idB;
        this._currentItemIds[indexB] = idA;
    }

    // =========================
    // 公開API(任意で利用可能)
    // =========================

    /**
     * 現在のアイテムID配列を取得(rows*columns 長)
     */
    public getCurrentItemIds(): string[] {
        return this._currentItemIds.slice();
    }

    /**
     * 現在のアイテムアイコン配列を取得(rows*columns 長)
     */
    public getCurrentItemIcons(): (SpriteFrame | null)[] {
        return this._currentItemIcons.slice();
    }

    /**
     * 指定スロットにアイテムを設定し、表示を更新
     */
    public setItemAt(index: number, icon: SpriteFrame | null, id: string = '') {
        const slotCount = this.rows * this.columns;
        if (index < 0 || index >= slotCount) {
            console.error('[InventoryGrid] setItemAt: indexが範囲外です:', index);
            return;
        }
        this._currentItemIcons[index] = icon;
        this._currentItemIds[index] = id;
        this._refreshSlot(index);
    }

    /**
     * インベントリ全体を再構築(rows/columns/slotSizeなど変更後に呼ぶと安全)
     */
    public rebuildGrid() {
        this._initItemData();
        this._createGrid();
        this._refreshAllSlots();
    }
}

コードのポイント解説

  • onLoad()
    • 親ノードに UITransform が無ければ自動追加(防御的)
    • Canvas 配下から Camera を自動検出し、uiCamera にセット
    • _initItemData() で内部アイテム配列を初期化
    • _createGrid() でスロットとアイコンノードを生成
    • _refreshAllSlots() で見た目を更新
    • _registerInputEvents() でタッチ / マウスイベントを登録
  • _createGrid()
    • 行・列・スロットサイズ・間隔から、グリッド全体の幅・高さを算出
    • centerGrid に応じて、左上原点位置を決定
    • 各スロットに Sprite を付けて背景色を設定
    • スロット子としてアイコンノードを作成し、Sprite + UIOpacity を付与
  • ドラッグ処理
    • タッチ / マウスの getUILocation() から UI座標を取得
    • _convertUIToWorld() で UI座標 → ワールド座標(Camera使用)
    • _convertUIToLocal() で ワールド座標 → InventoryGridノードのローカル座標
    • _hitTestSlot() でどのスロット上かを判定
    • ドラッグ開始時にアイコンのスケール・透明度を変更し、ドラッグ中は setWorldPosition で追従
    • ドロップ時に _swapItems() で内部データを入れ替え、見た目を更新
  • 公開API
    • getCurrentItemIds() / getCurrentItemIcons() で現在の配置を取得可能
    • setItemAt() で任意スロットの中身を差し替え可能
    • rebuildGrid() で rows / columns / slotSize 変更後に再構築

使用手順と動作確認

1. スクリプトファイルの作成

  1. Assets パネルで右クリック → Create → TypeScript を選択します。
  2. ファイル名を InventoryGrid.ts にします。
  3. 自動生成されたコードをすべて削除し、本記事の InventoryGrid コードを貼り付けて保存します。

2. テスト用シーンノードの準備

  1. Hierarchy パネルで右クリック → Create → UI → Canvas を作成(既にある場合はスキップ)。
  2. Canvas の子として UI → Camera が存在することを確認します。
    • 存在しない場合は Canvas を右クリック → Create → 3D Object → Camera などで UI 用カメラを作成し、Canvas 配下に移動します。
  3. Canvas の子として、インベントリ用のルートノードを作成します。
    • Hierarchy で Canvas を右クリック → Create → UI → Empty Node
    • 名前を InventoryRoot などに変更。
    • Inspector の Add Component ボタンから UITransform が付いていることを確認(無ければ追加)。
    • UITransform の Content Size600 x 400 など、グリッド全体が収まるサイズに設定します。

3. InventoryGrid コンポーネントのアタッチ

  1. Hierarchy で InventoryRoot ノードを選択します。
  2. Inspector 右下の Add ComponentCustomInventoryGrid を選択して追加します。

4. プロパティの設定

Inspector の InventoryGrid セクションで、以下のように設定してみます(例):

  • グリッド設定
    • Rows: 3
    • Columns: 4
    • Slot Size: 64 x 64
    • Horizontal Spacing: 8
    • Vertical Spacing: 8
    • Center Grid: ON
  • スロット・アイコン表示
    • Slot Background: 任意のパネル用 SpriteFrame(UIスプライトアトラスなどからドラッグ&ドロップ)
    • Empty Slot Color: (60, 60, 60, 255)(デフォルトのままでOK)
    • Occupied Slot Color: (120, 120, 120, 255)
    • Icon Scale: 0.9
  • ドラッグ&ドロップ
    • Enable Drag And Drop: ON
    • Dragging Opacity: 180
    • Dragging Scale: 1.1
    • Swap Only On Valid Slot: ON
  • アイテムデータ
    • Item Icons:
      • サイズを 4 などに設定し、各要素に任意の SpriteFrame(アイテム画像)を割り当てます。
    • Item Ids:
      • サイズを 4 にして、"sword", "shield", "potion", "key" など好きな文字列を入力。
  • UICamera
    • Canvas 配下の Camera を Hierarchy からドラッグ&ドロップして uiCamera プロパティに設定します。

5. プレビューと動作確認

  1. メインメニューから Project → Preview をクリックして、ブラウザ or ネイティブプレビューを起動します。
  2. シーンが表示されたら、InventoryRoot の位置に 3×4 のスロットと、設定したアイコンが並んでいることを確認します。
  3. マウス(PC)またはタッチ(スマホ)で、アイコンの上を押し続けてドラッグしてみます。
    • ドラッグ開始時に、アイコンが少し大きくなり、半透明になる
    • ドラッグ中はポインタに追従する
    • 別のスロット上で離すと、アイテムが入れ替わる
    • スロット外で離すと、元の位置に戻る(Swap Only On Valid Slot = ON の場合)

6. よくあるつまずきポイント

  • ドラッグしてもアイコンが動かない
    • InventoryRoot の uiCamera プロパティに、Canvas 配下の Camera が設定されているか確認
    • Canvas の RenderMode が Camera に設定されている場合、その Camera を指定する
  • グリッドが見切れる / 小さすぎる
    • InventoryRoot の UITransform → Content Size が、グリッド全体を収める大きさになっているか確認
    • Rows / Columns / Slot Size / Spacing を調整して、全体サイズを計算し直す
  • アイテムが一部しか表示されない
    • スロット総数(rows×columns)より itemIcons の長さが短い場合、残りスロットは空として扱われます。
    • アイテムを増やしたい場合は itemIcons の配列サイズを増やすか、rows / columns を増やします。

まとめ

この InventoryGrid コンポーネントは、
「任意のノードにアタッチして、インスペクタでアイテム配列を設定するだけ」で、

  • インベントリ UI のグリッド表示
  • ドラッグ&ドロップによるアイテム入れ替え

を完結させることができます。

外部の GameManager やシングルトンに依存せず、スクリプト単体で完結しているため、

  • 別プロジェクトへの持ち回り
  • UI テンプレートとしての再利用
  • プロトタイピング時の素早いインベントリ実装

に非常に向いています。

応用としては、

  • getCurrentItemIds() を使って、セーブデータへの書き出し
  • setItemAt() を使って、ドロップ報酬をインベントリに追加
  • スロットごとに右クリック / 長押しメニューを追加して「装備」「捨てる」などの操作を実装

などが考えられます。

まずは本記事のコードをそのまま動かしてみて、
自分のゲーム用にスロットの見た目やドラッグ挙動をカスタマイズしていくと、
インベントリ UI の実装がぐっと楽になります。

Godot 4ゲーム制作 実践ドリル 100本ノック

新品価格
¥1,250から
(2025/12/13 21:27時点)

Godot4& GDScriptではじめる 2Dゲーム開発レシピ

新品価格
¥590から
(2025/12/13 21:46時点)

Unity 6 C#スクリプト 100本ノック

新品価格
¥1,230から
(2025/12/29 10:28時点)

Cocos Creator100本ノックTypeScriptで書く!

新品価格
¥1,250から
(2025/12/29 10:32時点)

URLをコピーしました!