【Cocos Creator 3.8】LoadingSpinner の実装:アタッチするだけで「画面右下に表示されるロード中の回転アイコン」を実現する汎用スクリプト
このコンポーネントは、非同期ロード中などに「右下でクルクル回るローディングアイコン」を簡単に表示するためのものです。任意のノードに LoadingSpinner をアタッチするだけで、画面サイズに合わせて自動的に右下に配置され、指定したスプライトを回転させる UI を作れます。外部の GameManager やシングルトンに依存せず、このスクリプト単体で完結します。
コンポーネントの設計方針
今回の要件を整理すると、以下のようになります。
- 非同期ロードなど「ロード中」の間に、右下で回転するアイコンを表示したい。
- どのシーンでも再利用できるように、他のカスタムスクリプトには依存しない。
- UI の Canvas / Camera 構成が多少違っても、基本的に「画面右下」に来てほしい。
- 回転速度や位置オフセット、表示・非表示の制御をインスペクタから簡単に調整したい。
外部依存をなくすために、以下のような設計を採用します。
- 回転アニメーションは、このコンポーネント自身の
updateでnode.angleを更新するだけで完結。 - 見た目は「このノードにアタッチされた Sprite(または任意の UI ノード)」をそのまま回転させる。
- 画面右下への配置は、
UITransformと親 Canvas のサイズから算出し、アンカーを右下に固定することで実現。 - Canvas や Camera は外部スクリプトに頼らず、親階層を辿って自動的に Canvas を探す。
- ロード中かどうかのフラグは、インスペクタからオン・オフできる
isSpinningと、公開メソッドshow()/hide()で制御。
インスペクタで設定可能なプロパティ
LoadingSpinner で用意する @property は次の通りです。
- isSpinning: boolean
- デフォルト:
true - このフラグが
trueのときだけスピナーが回転します。 - エディタ上でチェックを外すと停止した状態で表示されます。
- デフォルト:
- autoHideWhenStopped: boolean
- デフォルト:
true isSpinning = falseのときにノード自体をactive = falseにして非表示にします。- 「停止してもアイコンは残しておきたい」場合はオフにします。
- デフォルト:
- rotationSpeed: number
- デフォルト:
180(度/秒) - スピナーの回転速度。正の値で時計回り、負の値で反時計回りに回転します。
- 例: 360 にすると 1 秒で 1 回転、90 なら 4 秒で 1 回転。
- デフォルト:
- useCanvasBottomRight: boolean
- デフォルト:
true - オンの場合:親 Canvas の右下を基準に自動配置します(UI 用)。
- オフの場合:現在のローカル座標をそのまま使い、位置制御は行いません(自由配置)。
- デフォルト:
- marginRight: number
- デフォルト:
40(ピクセル) - 画面の右端からどれだけ内側に寄せるか。
useCanvasBottomRightがtrueのときのみ有効。
- デフォルト:
- marginBottom: number
- デフォルト:
40(ピクセル) - 画面の下端からどれだけ上に寄せるか。
useCanvasBottomRightがtrueのときのみ有効。
- デフォルト:
- lockToUIScale: boolean
- デフォルト:
true - 親 Canvas の
UITransformのスケールを無視し、スピナー自身のスケールを (1,1,1) に保とうとします。 - UI の解像度設定によってスケールが変わる環境で、表示サイズを安定させたい場合に有効です。
- デフォルト:
- initialVisible: boolean
- デフォルト:
true - シーン開始時にスピナーを表示しておくかどうか。
- 非同期ロードが終わってから明示的に
show()したい場合はfalseにします。
- デフォルト:
見た目となる Sprite 自体は、このコンポーネントがアタッチされたノードに自由に設定して構いません。Sprite コンポーネントが存在しない場合でも、単に「空のノードが回転する」だけで動作しますが、ログで警告を出しておきます。
TypeScriptコードの実装
import { _decorator, Component, Node, UITransform, Canvas, Sprite, warn, log, Vec3 } from 'cc';
const { ccclass, property } = _decorator;
/**
* LoadingSpinner
* 任意の UI ノードにアタッチするだけで、
* 画面右下に配置される回転アイコン(ローディングスピナー)を実現するコンポーネント。
*
* 外部の GameManager などには依存せず、このスクリプト単体で完結します。
*/
@ccclass('LoadingSpinner')
export class LoadingSpinner extends Component {
@property({
tooltip: 'スピナーを回転させるかどうか。\ntrue のときだけ毎フレーム回転します。',
})
public isSpinning: boolean = true;
@property({
tooltip: '回転が停止しているときに、このノード自体を非表示(active=false)にします。',
})
public autoHideWhenStopped: boolean = true;
@property({
tooltip: 'スピナーの回転速度(度/秒)。\n正の値で時計回り、負の値で反時計回りに回転します。',
})
public rotationSpeed: number = 180;
@property({
tooltip: 'true の場合、親 Canvas の右下を基準に自動配置します。\nfalse の場合、ノードの現在位置を維持します。',
})
public useCanvasBottomRight: boolean = true;
@property({
tooltip: '画面右端からの余白(ピクセル)。useCanvasBottomRight が true のときのみ有効です。',
visible() {
return this.useCanvasBottomRight;
}
})
public marginRight: number = 40;
@property({
tooltip: '画面下端からの余白(ピクセル)。useCanvasBottomRight が true のときのみ有効です。',
visible() {
return this.useCanvasBottomRight;
}
})
public marginBottom: number = 40;
@property({
tooltip: '親 Canvas のスケールの影響を打ち消し、スピナー自身のスケールを (1,1,1) に保ちます。',
})
public lockToUIScale: boolean = true;
@property({
tooltip: 'シーン開始時にスピナーを表示するかどうか。false の場合、start 時に自動で非表示になります。',
})
public initialVisible: boolean = true;
/** キャッシュ用: 親 Canvas ノード */
private _canvasNode: Node | null = null;
/** キャッシュ用: このノードの UITransform */
private _uiTransform: UITransform | null = null;
/** キャッシュ用: スピナー画像用 Sprite(存在しない場合もある) */
private _sprite: Sprite | null = null;
onLoad() {
// 必要なコンポーネントの取得とチェック
this._uiTransform = this.node.getComponent(UITransform);
if (!this._uiTransform) {
// UITransform がないと UI の座標計算が難しいので警告
warn('[LoadingSpinner] このノードに UITransform コンポーネントがありません。UI ノードとして使用する場合は追加してください。');
}
this._sprite = this.node.getComponent(Sprite);
if (!this._sprite) {
// Sprite がなくても動作はするが、見た目がないので警告
warn('[LoadingSpinner] Sprite コンポーネントが見つかりません。スピナーとして表示するには Sprite を追加し、スプライトフレームを設定してください。');
}
// 親階層から Canvas を探索
this._canvasNode = this._findParentCanvas();
if (!this._canvasNode && this.useCanvasBottomRight) {
warn('[LoadingSpinner] 親階層に Canvas が見つかりません。useCanvasBottomRight を true にしている場合、期待通りの位置にならない可能性があります。');
}
}
start() {
// 初期表示状態の制御
this.node.active = this.initialVisible;
// 初期位置の調整(Canvas がある場合)
if (this.useCanvasBottomRight) {
this._applyBottomRightPosition();
}
// スケールの調整
if (this.lockToUIScale) {
this._applyScaleLock();
}
// デバッグログ(必要に応じてコメントアウト可)
log('[LoadingSpinner] Initialized. isSpinning =', this.isSpinning, ', initialVisible =', this.initialVisible);
}
update(dt: number) {
// スケールロックが有効なら、毎フレームスケールを補正
if (this.lockToUIScale) {
this._applyScaleLock();
}
// スピナーが停止状態の場合の処理
if (!this.isSpinning) {
if (this.autoHideWhenStopped) {
this.node.active = false;
}
return;
}
// スピナーが回転中の場合、必ず表示しておく
if (!this.node.active) {
this.node.active = true;
}
// 回転アニメーション
const deltaAngle = this.rotationSpeed * dt;
this.node.angle -= deltaAngle; // Cocos の angle は時計回りが負方向
}
/**
* スピナーを表示し、回転を開始します。
* (isSpinning = true, node.active = true)
*/
public show(): void {
this.isSpinning = true;
this.node.active = true;
}
/**
* スピナーを停止し、autoHideWhenStopped に応じて非表示にします。
* (isSpinning = false)
*/
public hide(): void {
this.isSpinning = false;
if (this.autoHideWhenStopped) {
this.node.active = false;
}
}
/**
* 親階層から Canvas ノードを探して返します。
*/
private _findParentCanvas(): Node | null {
let current: Node | null = this.node;
while (current) {
const canvas = current.getComponent(Canvas);
if (canvas) {
return current;
}
current = current.parent;
}
return null;
}
/**
* Canvas の右下を基準に、marginRight / marginBottom を反映した位置にノードを移動します。
*/
private _applyBottomRightPosition(): void {
if (!this._canvasNode) {
return;
}
const canvasTransform = this._canvasNode.getComponent(UITransform);
if (!canvasTransform) {
warn('[LoadingSpinner] 親 Canvas に UITransform がありません。位置の自動調整が行えません。');
return;
}
const canvasWidth = canvasTransform.width;
const canvasHeight = canvasTransform.height;
// アンカーを右下に固定(0,0 が左下、1,1 が右上)
if (this._uiTransform) {
this._uiTransform.anchorX = 1;
this._uiTransform.anchorY = 0;
}
// Canvas のローカル座標系で右下から margin を引いた位置に配置
const x = canvasWidth / 2 - this.marginRight;
const y = -canvasHeight / 2 + this.marginBottom;
this.node.setPosition(x, y, 0);
}
/**
* 親 Canvas のスケールを打ち消し、スピナー自身のスケールを (1,1,1) に保ちます。
*/
private _applyScaleLock(): void {
if (!this._canvasNode) {
return;
}
const parentScale = this._canvasNode.getScale();
const sx = parentScale.x === 0 ? 1 : 1 / parentScale.x;
const sy = parentScale.y === 0 ? 1 : 1 / parentScale.y;
const sz = parentScale.z === 0 ? 1 : 1 / parentScale.z;
this.node.setScale(new Vec3(sx, sy, sz));
}
}
コードのポイント解説
- onLoad
UITransformとSpriteをgetComponentで取得し、存在しない場合はwarnを出しています。- 親階層から
Canvasを探索し、右下配置やスケール補正に使うためにキャッシュしています。
- start
initialVisibleに応じてnode.activeを初期化します。useCanvasBottomRightがtrueの場合に_applyBottomRightPosition()で右下に自動配置します。lockToUIScaleがtrueの場合に_applyScaleLock()でスケールを補正します。
- update
lockToUIScaleが有効なら、毎フレームスケール補正を行い、UI 解像度の変化にも追従します。isSpinningがfalseのとき:autoHideWhenStoppedがtrueならnode.active = falseにして非表示。- 処理を抜けて回転は行いません。
isSpinningがtrueのとき:- ノードが非表示なら
active = trueに戻します。 rotationSpeed * dtだけangleを変化させて回転させます。
- ノードが非表示なら
- show / hide メソッド
- 外部から簡単に
spinner.show()/spinner.hide()で制御できるようにしています。 - 他のカスタムスクリプトに依存せず、「ボタンのクリックイベント」や「非同期ロード完了コールバック」から直接呼び出せます。
- 外部から簡単に
- _applyBottomRightPosition
- 親 Canvas の幅・高さから、右下基準で
marginRight,marginBottomを引いた位置を算出します。 - アンカーを (1,0)(右下)に設定することで、スピナーの中心ではなく「右下の角」を基準に位置調整できるようにしています。
- 親 Canvas の幅・高さから、右下基準で
- _applyScaleLock
- 親 Canvas のスケールを取得し、その逆数をこのノードに設定することで、結果的に見た目のスケールが 1 倍になるようにしています。
- Canvas のスケールが (1,1,1) であれば、そのまま (1,1,1) になります。
使用手順と動作確認
ここからは、実際に Cocos Creator 3.8.7 のエディタ上でこのコンポーネントを使う手順を説明します。
1. スクリプトファイルの作成
- Assets パネルで右クリックします。
Create > TypeScriptを選択します。- ファイル名を
LoadingSpinner.tsにします。 - 作成された
LoadingSpinner.tsをダブルクリックして開き、先ほどの TypeScript コード全体を貼り付けて保存します。
2. テスト用の UI ノードを作成
- Hierarchy パネルで、まだ Canvas がない場合は
- 右クリック →
Create > UI > Canvasを選択して Canvas を作成します。
- 右クリック →
- Canvas の子として、スピナー用のノードを作成します。
- Canvas を右クリック →
Create > UI > Spriteを選択します。 - 作成された Sprite ノードの名前を
LoadingSpinnerNodeなど分かりやすい名前に変更します。
- Canvas を右クリック →
- スピナーの見た目となる画像を設定します。
- Assets に用意した「ぐるぐるアイコン」の画像をインポートします(PNG など)。
LoadingSpinnerNodeを選択し、Inspector の Sprite コンポーネントのSprite Frameにその画像をドラッグ&ドロップします。- スピナーにしたい円形の画像などを使うとわかりやすいです。
3. LoadingSpinner コンポーネントをアタッチ
- Hierarchy で
LoadingSpinnerNodeを選択します。 - Inspector で
Add Componentボタンをクリックします。 CustomカテゴリからLoadingSpinnerを選択して追加します。- もし一覧に見つからない場合は、スクリプトの
@ccclass('LoadingSpinner')名とファイル名が一致しているか確認し、一度エディタを保存/再読み込みしてみてください。
- もし一覧に見つからない場合は、スクリプトの
4. プロパティの設定例
LoadingSpinner コンポーネントを選択した状態で、Inspector から以下のように設定してみます。
- isSpinning:
true - autoHideWhenStopped:
true - rotationSpeed:
240- 少し速めに回したい場合の例です。
- useCanvasBottomRight:
true - marginRight:
40 - marginBottom:
40 - lockToUIScale:
true - initialVisible:
true
この設定で、ゲームを再生すると、画面右下にスピナーが表示され、クルクル回転します。
5. シーン再生での動作確認
- エディタ上部の「▶」ボタンを押して、シーンを再生します。
- Game ビューで、画面右下にスピナーが見えることを確認します。
- 回転速度を確認し、必要であれば
rotationSpeedを調整します。- 例: 360 にすると 1 秒で 1 回転、720 にすると 0.5 秒で 1 回転とかなり速くなります。
- 再生中に Inspector から
isSpinningのチェックを外してみます。autoHideWhenStopped = trueの場合 → スピナーが非表示になります。autoHideWhenStopped = falseの場合 → 回転は止まりますが、アイコンは表示されたままになります。
6. 実際のロード処理と連動させる例
外部スクリプトに依存せず、単純に「他のスクリプトからこのコンポーネントを呼び出す」だけで連動できます。例えば、同じノードに別のスクリプトを付けて、ボタン操作で制御する場合:
import { _decorator, Component, Node } from 'cc';
import { LoadingSpinner } from './LoadingSpinner';
const { ccclass, property } = _decorator;
@ccclass('SpinnerTestController')
export class SpinnerTestController extends Component {
@property(LoadingSpinner)
spinner: LoadingSpinner | null = null;
// ボタンのクリックイベントなどから呼ぶ
public onStartLoading() {
if (this.spinner) {
this.spinner.show();
}
}
public onFinishLoading() {
if (this.spinner) {
this.spinner.hide();
}
}
}
この SpinnerTestController はあくまで例であり、LoadingSpinner 自体はこのスクリプトがなくても完全に動作します。必要な場合だけ、他のスクリプトから show() / hide() を呼ぶ、という使い方ができます。
まとめ
今回作成した LoadingSpinner コンポーネントは、
- Canvas 上の任意の UI ノードにアタッチするだけで「画面右下で回転するローディングアイコン」を実現できる。
- 外部の GameManager やシングルトンに一切依存せず、このスクリプト単体で完結している。
- 回転速度・表示/非表示・右下への自動配置・余白・スケール補正などをすべてインスペクタから調整できる。
show()/hide()メソッドにより、任意のロード処理と簡単に連動できる。
このような「アタッチするだけで UI の振る舞いが完結する汎用コンポーネント」を用意しておくと、
- 各シーンごとにローディング UI を作り直す手間が省ける。
- デザイナーやレベルデザイナーが、コードを書かずに Inspector から挙動を調整できる。
- スピナーのデザインを差し替えるだけで、全シーンのロード演出を一括で変更できる。
といったメリットが得られ、Cocos Creator での UI 演出を効率よく統一できます。
このパターンを応用すれば、「左上にフェードインする通知」「中央にポップアップするチュートリアル」なども同様の設計で汎用コンポーネント化できます。まずはこの LoadingSpinner をベースに、プロジェクトごとの共通 UI ライブラリを育ててみてください。




