【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 を加算してもレベルアップしません(オーバーフローを防止)。
0やstartLevel未満の場合は防御的に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: ログは出力しない。
- ON: EXP 獲得やレベルアップのたびに
4. 外部への通知(カスタムイベント)
UI や他のコンポーネントがこのコンポーネントの状態変化を知るために、以下のカスタムイベントを this.node から emit します。
-
"exp-changed"
EXP が変化したときに発火。ペイロード:currentExp: numberrequiredExp: number | null(次レベルに必要な EXP。最大レベルならnull)currentLevel: number
-
"level-up"
レベルアップしたときに発火。ペイロード:newLevel: numberoldLevel: numbercurrentExp: 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が空の場合はエラーログを出し、購読を行わないようにします。
- インスペクタで設定された
-
startlistenNodeが設定されていればそれを、なければthis.nodeをリッスン対象にします。targetNode.on(gainEventName, this._onGainExpEvent, this)でカスタムイベントを購読。
-
_onGainExpEvent- 敵撃破側から emit されたイベントのペイロード(
gainedExp)を受け取る。 - 型チェックを行い、数値でなければエラーログを出して無視。
_gainExpInternalに処理を委譲。
- 敵撃破側から emit されたイベントのペイロード(
-
_gainExpInternal- 0 以下の EXP や最大レベル到達後の加算を防御的に拒否。
- 現在 EXP に加算し、
_checkLevelUp()でレベルアップ判定。 - 最後に
_emitExpChanged()で状態変化を通知。
-
_checkLevelUprequiredExpForNextLevelから次レベルに必要な EXP を取得。- EXP が足りていればレベルアップし、余剰 EXP を持ち越す。
allowMultiLevelUpが true なら while ループで連続レベルアップを許可。- レベルアップ毎に
this.node.emit('level-up', ...)で通知。
-
_emitExpChanged- EXP が変化するたび、
'exp-changed'イベントで UI 等に現在値を通知。
- EXP が変化するたび、
使用手順と動作確認
1. スクリプトファイルの作成
- Assets パネルで右クリックします。
Create → TypeScriptを選択します。- ファイル名を
ExperienceGainer.tsとして作成します。 - 生成された
ExperienceGainer.tsをダブルクリックしてエディタで開き、
既存のテンプレートコードをすべて削除し、上記の TypeScript コードを貼り付けて保存します。
2. テスト用ノードの作成
ここでは簡単に、1 つのノードに ExperienceGainer を付けて、ボタンから疑似的に敵撃破イベントを送ってテストします。
- Hierarchy パネルで右クリック →
Create → UI → Canvasを選択し、キャンバスを作成します(既にある場合はスキップ)。 - Canvas の子として右クリック →
Create → 2D Object → Spriteなど、適当なノードを 1 つ作ります。
ここでは名前をPlayerとします。 Playerノードを選択し、Inspector のAdd Componentボタンをクリックします。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 を加算する流れを確認します。
- Hierarchy で Canvas を右クリック →
Create → UI → Buttonを選択し、ボタンを作成します。 - ボタンの名前を
TestKillButtonなどに変更します。 TestKillButtonを選択し、Inspector のButtonコンポーネントのClick Eventsにテスト用のスクリプトを設定します。- まず、ボタンにアタッチする簡単なテストスクリプトを作成します。
- Assets パネルで右クリック →
Create → TypeScript→ 名前をTestExpEmitter.tsとします。 - 以下のような簡易スクリプトを貼り付けます(このスクリプトはあくまでテスト用で、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}`);
}
}
続きの設定:
TestKillButtonノードを選択し、Add Component → Custom → TestExpEmitterを追加します。TestExpEmitterの Inspector で:- Target Node:
Playerノードをドラッグ&ドロップ。 - Exp Amount: 例として
120に設定(1 回で Lv1→2 に必要な 100 を超えるように)。 - Event Name:
enemy-killed(ExperienceGainer 側と一致させる)。
- Target Node:
TestKillButtonのButtonコンポーネントのClick EventsにTestExpEmitter.emitExpEventを登録します。Click Eventsの+ボタンを押す。- 追加された要素の
Node欄にTestKillButtonをドラッグ&ドロップ。 ComponentのドロップダウンからTestExpEmitterを選択。HandlerのドロップダウンからemitExpEventを選択。
5. 実行して動作確認
- エディタ右上の
Playボタンでプレビューを開始します。 - ゲーム画面に表示された
TestKillButtonをクリックします。 - Console パネル(
Developer Tools → Consoleなど)を開き、ログを確認します。
期待される挙動:
- 1 回目のクリックで
+120EXP が加算され、Lv1→2 にレベルアップします(残り EXP = 20)。 Log To Consoleが ON のため、以下のようなログが出力されます(一例):
[ExperienceGainer] EXP 獲得: +120 -> 現在EXP=120, 現在レベル=1
[ExperienceGainer] レベルアップ! Lv1 -> Lv2 (残りEXP=20)
[ExperienceGainer] レベルアップ条件未達: 現在EXP=20/200 (Lv2)
TestExpEmitter の Exp Amount を大きく(例: 500)して Allow Multi Level Up を ON/OFF して試すと、連続レベルアップの挙動も確認できます。
まとめ
この ExperienceGainer コンポーネントは、以下の点で汎用的かつ再利用しやすい設計になっています。
- 完全に独立: 外部の GameManager やシングルトンに依存せず、このスクリプト単体で完結。
- イベント駆動: 敵撃破などの「経験値獲得トリガー」は、任意のノードから任意のイベント名で emit するだけ。
- 柔軟なレベルアップテーブル: インスペクタから
levelUpRequirementsを編集するだけで、ゲームごとに異なる成長カーブを簡単に設定可能。 - UI 連携が容易:
'exp-changed'や'level-up'イベントを通じて、HPバー風の EXP ゲージやレベル表示テキストとシンプルに連携できる。 - 防御的実装: 不正な設定値(負の EXP、テーブル不足、イベント名未設定など)に対してログを出しつつ安全側に倒す設計。
実際のプロジェクトでは、このコンポーネントをプレイヤーだけでなく、スキルの熟練度システムや武器の強化レベルなどにも流用できます。
「経験値を蓄積してレベルアップする」という共通のロジックを 1 箇所に閉じ込めることで、ゲーム全体の実装をシンプルに保ちつつ、インスペクタから数値を調整するだけでバランス調整ができるようになります。
このままプロジェクトに組み込んで、UI との連携やセーブ/ロード処理などを追加していけば、より実戦的な経験値システムの土台として活用できます。




