【Cocos Creator】アタッチするだけ!ExperienceGainer (経験値管理)の実装方法【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】ExperienceGainer の実装:アタッチするだけで「経験値加算&レベルアップ判定」を行える汎用スクリプト

この記事では、敵を倒したときの経験値(EXP)加算とレベルアップ判定を、1つのコンポーネントをノードにアタッチするだけで実現できる「ExperienceGainer」を実装します。
外部の GameManager やシングルトンに依存せず、インスペクタで初期レベルやレベルアップに必要な経験値テーブルを設定するだけで動作するように設計します。


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

1. 要件整理

  • 敵を倒したときに「経験値を獲得した」というイベントを受け取り、内部の EXP を加算する。
  • EXP が一定値に達したらレベルアップし、余剰 EXP は次のレベルに持ち越す。
  • レベルアップに必要な EXP は、インスペクタからレベルごとに設定可能にする。
  • 外部スクリプトに依存しないため、「敵撃破シグナル」は Cocos Creator のカスタムイベントとして受け取る。
  • UI などに通知したい場合は、同じノードからカスタムイベントを発火して他コンポーネントが購読できるようにする。

2. 敵撃破シグナルの受け取り方

外部依存を避けるため、このコンポーネント自身が「敵撃破イベント名」を文字列で受け取り、そのイベントを this.node(または任意の targetNode)に対して Node.EventType.CUSTOM としてリッスンする設計にします。

敵を倒した側のコード(別コンポーネント)は、例えば以下のようにしてイベントを送信します(参考例・本コンポーネントには含めません):

// 例: 敵が倒れたときに送る側
this.node.emit('enemy-killed', 50); // 50 EXP を獲得

ExperienceGainer はこの 'enemy-killed' というイベント名をインスペクタで設定された文字列から購読し、ペイロードとして渡された数値を獲得 EXP として加算します。

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

  • listenNode : Node | null
    経験値獲得イベント(敵撃破シグナル)をリッスンする対象ノード。
    • 未指定(null)の場合は this.node を使用。
    • 敵側のノードや上位の管理ノードから emit する場合は、そのノードをドラッグ&ドロップで指定。
  • gainEventName : string
    敵撃破シグナルのイベント名。
    例: "enemy-killed""gain-exp" など。
    このイベント名で listenNode から emit されたときに経験値を加算します。
  • startLevel : number
    開始時のレベル。通常は 1
    0 やマイナスが設定されていた場合は、防御的に 1 に補正します。
  • startExp : number
    開始時の現在 EXP。通常は 0
    マイナスが設定されていた場合は 0 に補正します。
  • maxLevel : number
    到達可能な最大レベル。
    このレベルに達した後は、EXP を加算してもレベルアップしません(オーバーフローを防止)。
    0startLevel 未満の場合は防御的に startLevel に補正します。
  • levelUpRequirements : number[]
    各レベルから次のレベルへ上がるために必要な EXP のテーブル。
    例: [100, 150, 200] の場合
    • Lv1 → Lv2 に 100 EXP 必要
    • Lv2 → Lv3 に 150 EXP 必要
    • Lv3 → Lv4 に 200 EXP 必要

    配列のインデックスは「現在レベル – 1」 に対応します。
    最大レベルに達した後は、このテーブルは参照されません。

  • allowMultiLevelUp : boolean
    一度に大量の EXP を獲得したとき、複数レベルアップを許可するか
    • true: 余剰 EXP を使って、条件を満たすだけ連続でレベルアップします。
    • false: 1 回の獲得につき、最大 1 レベルアップまで。
  • logToConsole : boolean
    デバッグ用。
    • ON: EXP 獲得やレベルアップのたびに console.log で状況を出力。
    • OFF: ログは出力しない。

4. 外部への通知(カスタムイベント)

UI や他のコンポーネントがこのコンポーネントの状態変化を知るために、以下のカスタムイベントを this.node から emit します。

  • "exp-changed"
    EXP が変化したときに発火。ペイロード:
    • currentExp: number
    • requiredExp: number | null(次レベルに必要な EXP。最大レベルなら null
    • currentLevel: number
  • "level-up"
    レベルアップしたときに発火。ペイロード:
    • newLevel: number
    • oldLevel: number
    • currentExp: number(レベルアップ後に残った EXP)

これにより、UI 側は例えば:

this.node.on('level-up', (newLevel: number, oldLevel: number, currentExp: number) => {
    // レベルアップ演出やテキスト更新など
}, this);

のように購読できます(この購読処理は UI 側で実装します)。


TypeScriptコードの実装


import { _decorator, Component, Node, EventTarget, log, error } from 'cc';
const { ccclass, property } = _decorator;

/**
 * ExperienceGainer
 * 
 * 敵撃破などのカスタムイベントを受け取り、経験値を加算してレベルアップ判定を行う汎用コンポーネント。
 * 
 * - インスペクタでイベント名・レベルアップテーブルなどを設定可能。
 * - 外部スクリプトに依存せず、このコンポーネント単体で完結。
 * - EXP 変化・レベルアップ時には、このノードからカスタムイベントを emit して通知する。
 * 
 * 使用するカスタムイベント:
 * - 入力:
 *   - (listenNode or this.node).emit(gainEventName, gainedExp: number)
 * - 出力:
 *   - this.node.emit('exp-changed', currentExp: number, requiredExp: number | null, currentLevel: number)
 *   - this.node.emit('level-up', newLevel: number, oldLevel: number, currentExp: number)
 */
@ccclass('ExperienceGainer')
export class ExperienceGainer extends Component {

    @property({
        type: Node,
        tooltip: '経験値獲得イベントをリッスンするノード。\n未指定の場合はこのコンポーネントが付いているノード(this.node)を使用します。'
    })
    public listenNode: Node | null = null;

    @property({
        tooltip: '経験値獲得イベントの名前。\n例えば「enemy-killed」「gain-exp」など。\nlistenNode(または this.node) からこの名前で emit されたとき、ペイロードの数値を獲得EXPとして加算します。'
    })
    public gainEventName: string = 'enemy-killed';

    @property({
        tooltip: '開始時のレベル。\n0 以下が指定された場合は防御的に 1 に補正されます。'
    })
    public startLevel: number = 1;

    @property({
        tooltip: '開始時の現在EXP。\n負の値が指定された場合は 0 に補正されます。'
    })
    public startExp: number = 0;

    @property({
        tooltip: '到達可能な最大レベル。\nstartLevel 未満や 0 以下の場合は防御的に startLevel に補正されます。'
    })
    public maxLevel: number = 99;

    @property({
        type: [Number],
        tooltip: '各レベルから次レベルへ上がるために必要なEXPテーブル。\nindex = 現在レベル - 1。\n例: [100, 150, 200] -> Lv1→2:100, Lv2→3:150, Lv3→4:200。'
    })
    public levelUpRequirements: number[] = [100, 150, 200];

    @property({
        tooltip: '一度に大量のEXPを獲得したとき、複数レベルアップを許可するかどうか。\nON: 条件を満たすだけ連続レベルアップ。\nOFF: 1回の獲得につき最大1レベルアップ。'
    })
    public allowMultiLevelUp: boolean = true;

    @property({
        tooltip: 'ONにすると、EXP獲得やレベルアップのたびに console.log にログを出力します。'
    })
    public logToConsole: boolean = true;

    // 内部状態
    private _currentLevel: number = 1;
    private _currentExp: number = 0;

    // イベント受信に使用する EventTarget ラッパー(型安全のために用意)
    private _eventTarget: EventTarget = new EventTarget();

    onLoad() {
        // 初期値の防御的補正
        if (this.startLevel <= 0) {
            if (this.logToConsole) {
                log('[ExperienceGainer] startLevel が 1 以下のため、1 に補正します。指定値:', this.startLevel);
            }
            this.startLevel = 1;
        }

        if (this.startExp < 0) {
            if (this.logToConsole) {
                log('[ExperienceGainer] startExp が負のため、0 に補正します。指定値:', this.startExp);
            }
            this.startExp = 0;
        }

        if (this.maxLevel < this.startLevel || this.maxLevel <= 0) {
            if (this.logToConsole) {
                log('[ExperienceGainer] maxLevel が startLevel 未満または 0 以下のため、startLevel に補正します。指定値:', this.maxLevel);
            }
            this.maxLevel = this.startLevel;
        }

        if (!this.gainEventName || this.gainEventName.trim().length === 0) {
            error('[ExperienceGainer] gainEventName が空です。経験値獲得イベントを受信できません。インスペクタでイベント名を設定してください。');
        }

        this._currentLevel = this.startLevel;
        this._currentExp = this.startExp;

        // 初期状態を通知
        this._emitExpChanged();
    }

    start() {
        // リッスン対象ノードを決定
        const targetNode = this.listenNode ?? this.node;

        if (!targetNode) {
            // 通常 this.node は存在するが、念のため防御的チェック
            error('[ExperienceGainer] listenNode が null で、this.node も利用できません。コンポーネントのアタッチ状態を確認してください。');
            return;
        }

        // カスタムイベント購読
        if (!this.gainEventName || this.gainEventName.trim().length === 0) {
            // onLoad で既にエラーを出しているので、ここでは購読しないだけにする
            return;
        }

        // Cocos Creator の Node は EventTarget を継承しているため、直接 on で購読できる
        targetNode.on(this.gainEventName, this._onGainExpEvent, this);

        if (this.logToConsole) {
            log(`[ExperienceGainer] イベント購読開始: node="${targetNode.name}", event="${this.gainEventName}"`);
        }
    }

    onDestroy() {
        const targetNode = this.listenNode ?? this.node;
        if (targetNode && this.gainEventName && this.gainEventName.trim().length > 0) {
            targetNode.off(this.gainEventName, this._onGainExpEvent, this);
        }
    }

    /**
     * 外部から現在レベルを取得するためのゲッター。
     */
    public get currentLevel(): number {
        return this._currentLevel;
    }

    /**
     * 外部から現在EXPを取得するためのゲッター。
     */
    public get currentExp(): number {
        return this._currentExp;
    }

    /**
     * 次のレベルに必要な EXP を取得する。
     * 最大レベルに達している場合は null を返す。
     */
    public get requiredExpForNextLevel(): number | null {
        if (this._currentLevel >= this.maxLevel) {
            return null;
        }
        const index = this._currentLevel - 1;
        if (index < 0 || index >= this.levelUpRequirements.length) {
            // 設定ミス: テーブル不足
            return null;
        }
        return this.levelUpRequirements[index];
    }

    /**
     * 外部から手動で EXP を加算したい場合に呼び出せるメソッド。
     * (イベントを使わず、直接メソッド呼び出しで管理したいケース用)
     */
    public addExp(amount: number): void {
        this._gainExpInternal(amount);
    }

    /**
     * 経験値獲得イベントを受け取ったときのハンドラ。
     * 敵撃破側から emit される想定。
     * 
     * 例:
     *   targetNode.emit(gainEventName, 50);
     */
    private _onGainExpEvent(gainedExp: number): void {
        if (typeof gainedExp !== 'number') {
            error('[ExperienceGainer] 受信した経験値が number 型ではありません。送信側の emit の引数を確認してください。受信値:', gainedExp);
            return;
        }

        this._gainExpInternal(gainedExp);
    }

    /**
     * 実際の EXP 加算処理とレベルアップ判定を行う内部メソッド。
     */
    private _gainExpInternal(gainedExp: number): void {
        if (gainedExp <= 0) {
            // 0以下は無視(防御的)
            if (this.logToConsole) {
                log('[ExperienceGainer] 0 以下の経験値が渡されました。処理をスキップします。値:', gainedExp);
            }
            return;
        }

        if (this._currentLevel >= this.maxLevel) {
            // 既に最大レベル
            if (this.logToConsole) {
                log('[ExperienceGainer] 既に最大レベルのため、EXP を加算しません。');
            }
            return;
        }

        this._currentExp += gainedExp;

        if (this.logToConsole) {
            log(`[ExperienceGainer] EXP 獲得: +${gainedExp} -> 現在EXP=${this._currentExp}, 現在レベル=${this._currentLevel}`);
        }

        // レベルアップ判定
        this._checkLevelUp();

        // 最終的な状態を通知
        this._emitExpChanged();
    }

    /**
     * 現在の EXP とレベルから、レベルアップ可能かを判定し、必要ならレベルアップを行う。
     */
    private _checkLevelUp(): void {
        if (this._currentLevel >= this.maxLevel) {
            return;
        }

        let leveledUp = false;

        while (true) {
            const required = this.requiredExpForNextLevel;
            if (required === null) {
                // テーブル不足 or 最大レベル
                if (this.logToConsole) {
                    log('[ExperienceGainer] レベルアップテーブルが不足しているか、最大レベルに達しています。これ以上レベルアップできません。');
                }
                break;
            }

            if (this._currentExp < required) {
                // まだ足りない
                break;
            }

            // レベルアップ処理
            const oldLevel = this._currentLevel;
            this._currentExp -= required;
            this._currentLevel++;

            leveledUp = true;

            if (this.logToConsole) {
                log(`[ExperienceGainer] レベルアップ! Lv${oldLevel} -> Lv${this._currentLevel} (残りEXP=${this._currentExp})`);
            }

            // レベルアップイベント通知
            this.node.emit('level-up', this._currentLevel, oldLevel, this._currentExp);

            if (this._currentLevel >= this.maxLevel) {
                // 最大レベルに達したら、残りEXPは保持するがレベルアップはしない
                if (this.logToConsole) {
                    log('[ExperienceGainer] 最大レベルに到達しました。これ以上レベルアップしません。');
                }
                break;
            }

            if (!this.allowMultiLevelUp) {
                // 1回の獲得につき1レベルアップまで
                break;
            }

            // allowMultiLevelUp が true の場合は while を継続して連続レベルアップを許可
        }

        if (!leveledUp && this.logToConsole) {
            const required = this.requiredExpForNextLevel;
            if (required !== null) {
                log(`[ExperienceGainer] レベルアップ条件未達: 現在EXP=${this._currentExp}/${required} (Lv${this._currentLevel})`);
            }
        }
    }

    /**
     * EXP が変化したことを通知するカスタムイベントを発火する。
     */
    private _emitExpChanged(): void {
        const required = this.requiredExpForNextLevel;
        this.node.emit('exp-changed', this._currentExp, required, this._currentLevel);
    }
}

コードの要点解説

  • onLoad
    • インスペクタで設定された startLevel, startExp, maxLevel を防御的に補正。
    • _currentLevel, _currentExp を初期化し、_emitExpChanged() で初期状態を通知。
    • gainEventName が空の場合はエラーログを出し、購読を行わないようにします。
  • start
    • listenNode が設定されていればそれを、なければ this.node をリッスン対象にします。
    • targetNode.on(gainEventName, this._onGainExpEvent, this) でカスタムイベントを購読。
  • _onGainExpEvent
    • 敵撃破側から emit されたイベントのペイロード(gainedExp)を受け取る。
    • 型チェックを行い、数値でなければエラーログを出して無視。
    • _gainExpInternal に処理を委譲。
  • _gainExpInternal
    • 0 以下の EXP や最大レベル到達後の加算を防御的に拒否。
    • 現在 EXP に加算し、_checkLevelUp() でレベルアップ判定。
    • 最後に _emitExpChanged() で状態変化を通知。
  • _checkLevelUp
    • requiredExpForNextLevel から次レベルに必要な EXP を取得。
    • EXP が足りていればレベルアップし、余剰 EXP を持ち越す。
    • allowMultiLevelUp が true なら while ループで連続レベルアップを許可。
    • レベルアップ毎に this.node.emit('level-up', ...) で通知。
  • _emitExpChanged
    • EXP が変化するたび、'exp-changed' イベントで UI 等に現在値を通知。

使用手順と動作確認

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

  1. Assets パネルで右クリックします。
  2. Create → TypeScript を選択します。
  3. ファイル名を ExperienceGainer.ts として作成します。
  4. 生成された ExperienceGainer.ts をダブルクリックしてエディタで開き、
    既存のテンプレートコードをすべて削除し、上記の TypeScript コードを貼り付けて保存します。

2. テスト用ノードの作成

ここでは簡単に、1 つのノードに ExperienceGainer を付けて、ボタンから疑似的に敵撃破イベントを送ってテストします。

  1. Hierarchy パネルで右クリック → Create → UI → Canvas を選択し、キャンバスを作成します(既にある場合はスキップ)。
  2. Canvas の子として右クリック → Create → 2D Object → Sprite など、適当なノードを 1 つ作ります。
    ここでは名前を Player とします。
  3. Player ノードを選択し、Inspector の Add Component ボタンをクリックします。
  4. Custom → ExperienceGainer を選択してアタッチします。

3. インスペクタでプロパティを設定

Player ノードにアタッチされた ExperienceGainer コンポーネントを選択し、Inspector で以下のように設定します(例):

  • Listen Node: (空のまま)→ 自身のノード Player を自動的に使用します。
  • Gain Event Name: enemy-killed
  • Start Level: 1
  • Start Exp: 0
  • Max Level: 5
  • Level Up Requirements: [100, 200, 300, 400]
    • Lv1 → 2: 100 EXP
    • Lv2 → 3: 200 EXP
    • Lv3 → 4: 300 EXP
    • Lv4 → 5: 400 EXP
  • Allow Multi Level Up: チェック ON(大量 EXP で連続レベルアップを確認したい場合)
  • Log To Console: チェック ON(挙動を Console で確認)

4. 疑似的な「敵撃破」イベントを送るためのテストボタンを作成

ここでは、ボタンを押すと enemy-killed イベントを送信し、ExperienceGainer が EXP を加算する流れを確認します。

  1. Hierarchy で Canvas を右クリック → Create → UI → Button を選択し、ボタンを作成します。
  2. ボタンの名前を TestKillButton などに変更します。
  3. TestKillButton を選択し、Inspector の Button コンポーネントの Click Events にテスト用のスクリプトを設定します。
    1. まず、ボタンにアタッチする簡単なテストスクリプトを作成します。
    2. Assets パネルで右クリック → Create → TypeScript → 名前を TestExpEmitter.ts とします。
    3. 以下のような簡易スクリプトを貼り付けます(このスクリプトはあくまでテスト用で、ExperienceGainer には依存しません):

import { _decorator, Component, Node } from 'cc';
const { ccclass, property } = _decorator;

/**
 * テスト用: ボタンをクリックしたときに enemy-killed イベントを emit するだけのスクリプト。
 * ExperienceGainer の動作確認のための補助です。
 */
@ccclass('TestExpEmitter')
export class TestExpEmitter extends Component {

    @property({
        type: Node,
        tooltip: '経験値獲得イベントを送信する対象ノード。\n通常は ExperienceGainer が付いているノード(Playerなど)を指定します。'
    })
    public targetNode: Node | null = null;

    @property({
        tooltip: '送信する経験値量。'
    })
    public expAmount: number = 50;

    @property({
        tooltip: '送信するイベント名。ExperienceGainer 側の Gain Event Name と一致させてください。'
    })
    public eventName: string = 'enemy-killed';

    onLoad() {
        if (!this.targetNode) {
            // 防御的に警告
            console.warn('[TestExpEmitter] targetNode が設定されていません。Inspector で設定してください。');
        }
    }

    /**
     * Button の Click Event から呼び出す用のメソッド。
     */
    public emitExpEvent() {
        if (!this.targetNode) {
            console.warn('[TestExpEmitter] targetNode が null のため、イベントを送信できません。');
            return;
        }

        this.targetNode.emit(this.eventName, this.expAmount);
        console.log(`[TestExpEmitter] イベント送信: node="${this.targetNode.name}", event="${this.eventName}", exp=${this.expAmount}`);
    }
}

続きの設定:

  1. TestKillButton ノードを選択し、Add Component → Custom → TestExpEmitter を追加します。
  2. TestExpEmitter の Inspector で:
    • Target Node: Player ノードをドラッグ&ドロップ。
    • Exp Amount: 例として 120 に設定(1 回で Lv1→2 に必要な 100 を超えるように)。
    • Event Name: enemy-killed(ExperienceGainer 側と一致させる)。
  3. TestKillButtonButton コンポーネントの Click EventsTestExpEmitter.emitExpEvent を登録します。
    1. Click Events+ ボタンを押す。
    2. 追加された要素の Node 欄に TestKillButton をドラッグ&ドロップ。
    3. Component のドロップダウンから TestExpEmitter を選択。
    4. Handler のドロップダウンから emitExpEvent を選択。

5. 実行して動作確認

  1. エディタ右上の Play ボタンでプレビューを開始します。
  2. ゲーム画面に表示された TestKillButton をクリックします。
  3. Console パネル(Developer Tools → Console など)を開き、ログを確認します。

期待される挙動:

  • 1 回目のクリックで +120 EXP が加算され、Lv1→2 にレベルアップします(残り EXP = 20)。
  • Log To Console が ON のため、以下のようなログが出力されます(一例):

[ExperienceGainer] EXP 獲得: +120 -> 現在EXP=120, 現在レベル=1
[ExperienceGainer] レベルアップ! Lv1 -> Lv2 (残りEXP=20)
[ExperienceGainer] レベルアップ条件未達: 現在EXP=20/200 (Lv2)

TestExpEmitterExp Amount を大きく(例: 500)して Allow Multi Level Up を ON/OFF して試すと、連続レベルアップの挙動も確認できます。


まとめ

この ExperienceGainer コンポーネントは、以下の点で汎用的かつ再利用しやすい設計になっています。

  • 完全に独立: 外部の GameManager やシングルトンに依存せず、このスクリプト単体で完結。
  • イベント駆動: 敵撃破などの「経験値獲得トリガー」は、任意のノードから任意のイベント名で emit するだけ。
  • 柔軟なレベルアップテーブル: インスペクタから levelUpRequirements を編集するだけで、ゲームごとに異なる成長カーブを簡単に設定可能。
  • UI 連携が容易: 'exp-changed''level-up' イベントを通じて、HPバー風の EXP ゲージやレベル表示テキストとシンプルに連携できる。
  • 防御的実装: 不正な設定値(負の EXP、テーブル不足、イベント名未設定など)に対してログを出しつつ安全側に倒す設計。

実際のプロジェクトでは、このコンポーネントをプレイヤーだけでなく、スキルの熟練度システム武器の強化レベルなどにも流用できます。
「経験値を蓄積してレベルアップする」という共通のロジックを 1 箇所に閉じ込めることで、ゲーム全体の実装をシンプルに保ちつつ、インスペクタから数値を調整するだけでバランス調整ができるようになります。

このままプロジェクトに組み込んで、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をコピーしました!