はじめに
はじめまして。
完全未経験から2年目に突入したソフトウェアエンジニアです。
本記事は Cocos Creator(TypeScript) を学び始めてから約半年のなかで気になったことや、困ったポイントを紹介します。具体的な内容は実際に携わった2DのWEBゲーム開発をもとにします。
主な読者として Cococs Creator ・TypeScript(JavaScript) のビギナーを想定します。
使用言語は Cocos Creator が推奨する TypeScriptです。
JavaScriptの標準仕様(ECMAScript)については、公式が保証する ES2015(ES6) を前提とします。
Cocos Creator とは
主に2Dゲーム・カジュアルゲームに特化した軽量な開発環境です。偶然にも基本設計は Unity に類似しています。イメージとしては Unity をコンパクトにした開発環境です。
3D開発も可能です。
本記事では Cocos Creator バージョン2系を前提としますが、これは2DでカジュアルなWebゲーム開発に最適だからです。実際のWEBゲーム開発にあたって3系と2系でロード時間が大きく異なる点で2系を採用した背景があります。
3系と比較すると2系のロード時間は一瞬です。
ちなみに無料です。
気になったこと・困ったこと
1. GC(ガベージコレクタ・ガベージコレクション)
動的に確保されたヒープ領域にある死んだオブジェクトのメモリを自動管理する機能を指します。GC を組み込んだ言語に Java・Ruby・Python・C#・Swift(ARC方式)・Lispなどが挙げられます。
いずれにせよ、メモリリークや不要なメモリ消費を防ぐことができます。
Delphi(Object Pascal) では一部の参照カウンティングを除いてクラスインスタンスなどは手動でメモリ管理する必要があります。
「どのタイミングで開放されるのだろう...」と気になったり、デバッグ時に残したいメモリを解放されたりと、当初は困惑しました。インタプリタ言語などのプログラマは何気なくお世話になっているかもしれませんが、厳格なプログラム管理を主軸とするObject Pascalプログラマなどは慣れを要します。
Cocos Creator では、シーン間で参照を維持したい場合、たとえばゲームスコアなどは staticキーワードを付けてクラスの静的プロパティとして定義する方法が有効です。
↓↓↓ ミニゲーム制作ではシングルトンのインスタンスとして使用しました ↓↓↓
import gameScore from "./GameScore"; // 相対パス or 絶対パス(プロジェクトディレクトリ以下)
const score: GameScore = gameScore.getInstance();
export default class GameScore {
private static _instance: GameScore;
public static getInstance (): GameScore {
if (!this._instance) {
// 一度だけインスタンスを生成します
this._instance = new GameScore();
}
return this._instance;
}
ちなみに addPersistRootNode という専用のメソッドもありますのでご自由にお選びください。実際の呼び方は cc.game.addPersistRootNode() になります。
2. アンカーポイント
親ノードに対する子ノードの 「座標・回転・拡大縮小など」に関する原点のことです。 0 ~ 1 の値をとります。
デフォルトは X: 0.5, Y: 0.5です。これはノード中央をアンカーポイントにします。
たとえば親ノードのアンカーポイントが X: 0.5, Y: 0.5 で子ノードが X: 0, Y: 0 の場合は ↓ のようになります。
これは子ノードの座標が(0, 0)のとき、子ノードのアンカーポイントが親ノードのアンカーポイントの位置と一致することを示します。このようにアンカーポイント同士が一致する状態をイメージしてから座標を考えると分かりやすいです。
ちなみにオブジェクトの座標系はアンカーポイントを問わず X座標は右方向へ、Y座標は上方向に増加します。
当初は困惑しました。Delphi ではデフォルトのアンカーポイントが親オブジェクトの左上を基準にするからです。加えて Delphi ではY座標は下方向に増加します。
このアンカーポイントの違いで苦心しました。
ただ、慣れると Cocos Creator のアンカーポイントは便利です。特に親ノードのX, Y軸に対称的にノードを設置する操作を簡単にします。
実際のゲーム開発において、親ノードの中心に子ノードを設置することが多々あります。両者のアンカーポイントがともに X: 0.5, Y: 0.5 の場合、↓画像のように子ノードの座標を (0, 0)にするだけです。これも両者のアンカーポイントが一致する座標がそれぞれの中心点と考えると分かりやすいです。
個人的にアンカーポイントの値をデフォルト以外にすると全体管理が複雑化するため、デフォルト値を維持したまま各種演算を行いました。
Align に関してはアンカーポイントではなく、 cc.Widgetコンポーネント で制御したほうが分かりやすいです。
3. 座標系
ノード間のローカル座標は親子のアンカーポイントに準じます。cc.Graphics を用いた直線の描画なども同様です。
それに対してタッチ(マウス)イベントはワールド座標(グローバル座標)に相当します。キャンバスの左下を (0, 0) とします。
そのため、タッチイベントの引数から取得した座標をローカル座標に変換するケースが一般的です。もちろん逆方向の変換も可能です。
// 当該コンポーネントをアタッチしたノードのプロパティとして
// myNode(cc.Node) が宣言されていると仮定します
onTouch (event: cc.Event.EventTouch): void {
const worldPos: cc.Vec2 = event.getLocation();
// worldPos を myNode のローカル座標に変換します
const localPos: cc.Vec2 = this.myNode.convertToNodeSpaceAR(pos);
}
ちなみにタッチイベントはモバイルだけでなく、デスクトップのマウス操作でも発火します。
4. 配列の操作
JavaScript は配列操作が豊富だと感じました。TypeScript は その JavaScript に静的な型付けを付加・強化した言語です。文字通り [型]のJavaScript です。これにより、静的な型チェックなどの安全が保障されます。
これら配列操作で「あれっ?」となった初歩的な うっかりポイントを紹介します。
■その1
Array(Length) の後に pushメソッド を呼んだことがあります。pushメソッドは単純に配列の末尾に要素を追加するだけなので、下記の場合は [undefined, undefined, 1, 2] の配列になります。
let numbers2: number[] = Array(2); // undefined が入ります。長さは 2 です
numbers2.push(1); // numbers[0] に 1を代入したい
numbers2.push(2); // numbers[1] に 2を代入したい
■その2
クラス・コンポーネントのインスタンス配列を初期化するとき、new MyClass() のようにインスタンス生成してからプロパティなどを参照しないとエラーが発生し、呼び出し以下の処理がスキップされる可能性があります。下記の例では Array(2) で長さが 2 の MyData の配列を生成したつもりですが、undefined で初期化されます。結果的に参照エラーが発生しました。
class MyData {
public isActive: boolean = false;
}
export default class MyDataList {
private _dataList: MyData[] = [];
init(): void {
// 誤った方法
this._dataList = Array(2); // undefined で初期化されます。
this._dataList.forEach(item => item.isActive = true); // 参照エラーが発生します。
// 正しい方法
this._dataList = Array.from({length: 2}, () => new MyData());
this._dataList.forEach(item => item.isActive = true);
}
}
配列の初期化方法は複数あるので、プログラム要件に合わせて柔軟にご選択ください。
詳しくはこちら
5. マスク機能
子ノードの描画範囲を制限します。
実際のゲーム開発のなかで、ズーム用のウィンドウを実装する必要がありました。
↓ のようなイメージです。
最初は cc.Cameraコンポーネント の rectプロパティで制御しようとしました。
この rectプロパティは基本的にキャンバス要素の表示領域(ビューポート)に対するサイズ・座標を 0 ~ 1 の割合でとり、描画対象の配置を定義します。ちなみに座標系は左下基準です。
ここで問題が発生します。
rect.width と rect.height のアスペクト比が異なると、テクスチャがそれに合わせて伸縮します。
そこで最初に試したのがマスク機能です。ズーム用のレンダーテクスチャを乗せたスプライトノードの親に cc.Mask をアタッチすることで描画領域をクリッピングする試みです。
分かりにくいかもしれませんが ↓画像のイメージです。
結果から述べると失敗しました。
なぜかレンダーテクスチャが真っ黒に塗りつぶされたような見た目になったのです。
同じような不具合が公式の Cocos Forum に報告されていましたが、現時点で未解決のようです。
解決策として、レンダーテクスチャ自体をクリッピングすることでズームウィンドウを実装できました。
要するにレンダーテクスチャを乗せたノードではなく、レンダーテクスチャ自体をクリッピングしました。
イメージとしては ↓画像のようにカメラが映すレンダーテクスチャの特定領域をクリッピングします。
そのときのライブラリ(カスタムコンポーネント)の一部をご紹介します。
/*
* 当該カスタムコンポーネントのプロパティとして
* ズーム用のカメラコンポーネント("ZC")が宣言されていると仮定します
*/
this.node.setContentSize(width, height); // ズームウィンドウのサイズを設定します
this.node.addComponent(cc.Sprite); // 最終的にレンダーテクスチャをセットします
const viewportSize = cc.view.getVisibleSize(); // ビューポートのサイズ(仮想解像度)
// 独立してキャプチャ保存するためのレンダーテクスチャを生成します
const renderTexture = new cc.RenderTexture();
renderTexture.initWithSize(viewportSize.width, viewportSize.height);
// 生成したテクスチャをズーム用のカメラコンポーネントにセットします
this.ZC.targetTexture = renderTexture;
// 生成したスプライトフレームにレンダーテクスチャを対応させます
const spriteFrame = new cc.SpriteFrame();
spriteFrame.setTexture(this.ZC.targetTexture);
// レンダーテクスチャをクリッピングします。
// 今回はビューポートの中央かつズームウィンドウと同サイズをとります
const rect: cc.Rect = cc.rect(
(viewportSize.width - this.node.width) / 2,
(viewportSize.height - this.node.height) / 2,
this.node.width,
this.node.height
);
spriteFrame.setRect(rect);
// クリッピングされたレンダーテクスチャを渡します
this.node.getComponent(cc.Sprite).spriteFrame = spriteFrame;
// WebGLのテクスチャの座標系は左下であり、これは Cocos Creator の画像処理の座標系と上下逆と思われるため、
// Y方向に反転させて座標系を一致させます
this.node.setScale(1, -1);
あとはズーム用のカメラコンポーネントにある Positionプロパティを調整することで、
レンダーテクスチャのズーム箇所を指定できます。
6. アトラステクスチャ
特にゲーム開発では不可避です。複数のテキスチャを大きなテクスチャにまとめます。
これにより、GPU へのドローコール(描画命令)をコンパクトにすることでレンダリング効率を向上させて描画コストを軽減させられます。
たとえば テクスチャパッカーを使用して ↓ のような複数のテクスチャをまとめたアトラステクスチャを生成します。
これだけでは不足です。実際に対象のテクスチャを参照するためにはパッキング内容の情報が必要です。
簡潔に説明すると なにが・どこに・どのようにパッキングされているかという内容です。
このリストはテクスチャパッカーでアトラステクスチャと同時に生成されます。
Cocos Creator は .plistファイル で管理します。
↓ 画像はリストのサンプルです。ファイル名やアトラステクスチャ内での座標などが記載されています。
また、原則としてテクスチャサイズが 2のべき乗のときに GPU のパフォーマンスが最適化されます。
例) 2048 * 2048, 4096*4096
もう1つ注意点があります。
テクスチャファイルと .plistファイルは同階層に設置してください。
さて、ここで困ったことが発生しました。
実際に Cocosアプリを実行して描画するとき、 ↓画像のようにオブジェクトと透明ピクセルとの境界あたりに黒い線がにじむ? ことがありました。
この場合、無料のテクスチャパッカーでは対応が難しい可能性があります。テクスチャ間の Margin や Extrude オプションの値を大きくすることで回避する方法もありますが、それでも解消されない場合は reduce border artifacts というオプションがあるテクスチャパッカーを利用することを推奨します。
7.ビット演算
ビットレベルの演算処理のことです。
ここでいうビットは文字通りコンピューターの情報表現の最小単位を指します。
ビット演算を活用することで優れたメモリ効率と高速処理を実現できます。
なにより普段からビット演算に意識的かつ頻繁に触れてきたわけではないので、当初は苦労しました。
メモリ効率
たとえば JavaScriptでは Number型は32 ~ 64ビットで表現され、Boolean型も8ビット以上あるといわれています。
そのため、大量の変数を保持するとメモリ消費も肥大します。
Cocos Creator で実際にお絵かきアプリを開発したときも、メモリ消費量を抑えることに悩みました。
まず、同じセルを複数回にわたって塗らないために一度塗りつぶしたセルの情報を保持する必要があります。
最初に考えたのが二次元配列(行列)で bool値を保持するというものでした。
具体的には一度塗りつぶしたセル座標に相当する配列の要素(bool値)を true にします。
仮に 8 * 8 の小さな描画領域があったして中央あたりを描画したときは ↓ のようになります。
0 0 0 0 0 0 0 0
0 0 0 0 0 0 0 0
0 0 0 0 0 0 0 0
0 0 1 1 1 1 0 0
0 0 1 1 1 1 0 0
0 0 0 0 0 0 0 0
0 0 0 0 0 0 0 0
0 0 0 0 0 0 0 0
ただし、単純にBoolean型の変数を保持するだけではメモリ消費が過大です。
そこでビット演算です。
1バイトの符号無し整数値を扱う Uint8Array という配列に対してビット演算をすることで1バイトに8個分のセル情報を保持させることができました。
通常の Boolean型の二次元配列と比較して 1/8以下にメモリ消費を圧縮できるのです。
このようなデータ保持に必要なビット演算を紹介します。
大事なのはビットを立たせる処理(0 → 1)と、対象のビットを参照する処理です。
↓ の例は row行, column列 にある ビット配列に対して 右から3番目のビットを立てます。
/*
操作対象のビット配列を 1 0 0 0 0 0 0 0 と仮定します。
1 << 2 で 1を表すビット配列(0 0 0 0 0 0 0 1) を左へ2つビットシフトします。
0 0 0 0 0 1 0 0 になります。
操作対象のビット配列(1 0 0 0 0 0 0 0) と ビットシフトしたビット配列(0 0 0 0 0 1 0 0 ) 間において
|= 演算子で論理和(OR)を取って値を代入します。
また、ビット配列の特定のビットを操作するためのビットパターンを "ビットマスク" といいます。
1 0 0 0 0 0 0 0 ← 操作対象のビット配列
0 0 0 0 0 1 0 0 ← ビットマスク
---------------
1 0 0 0 0 1 0 0 ← 論理和
元のビット配列に対して対象(3番目)のビットを立てられました。
*/
values[row][column][0] |= 1 << 2;
↓ の例ではrow行, column列 にある ビット配列に対して右から3番目のビットを参照します
/*
参照対象のビット配列を 1 0 0 0 0 1 0 0 と仮定します。
>> 2 で対象のビット配列を右へ2つビットシフトします。
0 0 1 0 0 0 0 1 になります。
このビット配列と 1を表すビット配列(0 0 0 0 0 0 0 1)間において、&演算子で論理積(AND)を取ります。
0 0 1 0 0 0 0 1 ← 参照対象のビット配列
0 0 0 0 0 0 0 1 ← ビットマスク
---------------
0 0 0 0 0 0 0 1 ← 論理積
元のビット配列に対して対象(3番目)のビットを参照できました。
変数targetBitが保持します。
*/
targetBit = (values[row][column][0] >> 2) & 1;
高速処理
特に除算(/演算子)の代替としてビット演算を使用することで高速処理を実現します。
ただし注意点があります。ビットシフトで除算しますが、シフト演算の性質上、
割る数は2のべき乗でなければなりません。また、結果は小数点以下を切り捨てた整数になります。
たとえば 2で割る場合、↓ のようになります。
/*
例) 4を2で割る場合、右へ1つビットシフトすると実際に 2 になります
0 0 0 0 0 1 0 0 ← 4 の二進数表記
0 0 0 0 0 0 1 0 ← 2 の二進数表記
*/
num = num >> 1; // 右へ1つビットシフトします
具体的なビットシフト回数は割る数(2^n) の指数部に相当します。4で割る場合は2回シフト、8で割る場合は3回シフトします。
実際に処理速度をテストしたところ、通常の除算よりも数倍以上のスコアをマークしました。
実行環境や具体的な計算対象に依存するため、あくまでも目安です。
8. エンディアン
2バイト以上で表現される値をメモリやファイルに読み書きするときのバイトの並び順(オーダー)を指します。
ビッグエンディアンとリトルエンディアンがあります。
両者を簡単に説明します。なお、分かりやすいようにバイト単位を()で囲みます。
/*
2バイトの数値(値: 3)を想定します。
ビッグエンディアンは一般的なイメージに則していると思われます。
上位バイトから下位バイトへ向かいます
*/
(0 0 0 0 0 0 0 0) (0 0 0 0 0 0 1 1)
/*
2バイトの数値(値: 3)を想定します。
リトルエンディアンは一般的なイメージの逆に見える可能性があります。
下位バイトから上位バイトへ向かいます
*/
(0 0 0 0 0 0 1 1) (0 0 0 0 0 0 0 0)
JavaScriptではこうしたバイナリデータをエンディアンを指定して読み書きするために DataView というインターフェースが用意されています。
流れとして、まず ArrayBuffer で固定長のバイナリデータのバッファを生成します。この ArrayBuffer だけではバイナリデータを直接操作できません。
そのため、DataView を介して操作します。↓ が一例です。
// 1バイト分のバイナリデータを操作してみる
const buffer = new ArrayBuffer(1); // 1バイトの固定長のバイナリデータのバッファを生成します
const viewer: DataView = new DataView(buffer);
viewer.setUint8(0, 1); // オフセット0(1バイト目)に 1 を書き込みます
viewer.getUint8(0) // オフセット0(1バイト目)を読み込みます。値は 1 です。
2バイト以上で表現される値の場合は上記のセッター・ゲッターの引数に エンディアンのタイプを指定するための bool値を渡します。
これらの延長線上として、リソースファイルからバイナリデータを読み込む方法で悩みました。
なぜなら Cocos Creator 2系の標準ライブラリにある cc.BufferAsset では _buffer というメンバ変数を直接参照する必要があると思われるからです。
解決策として、ほかのネイティブプラグイン・API も有効かもしれません。
cc.resources.load(fileName, (err: Error, binaryData: cc.BufferAsset) => {
// (中略)
const arrayBuffer: ArrayBuffer = binaryData._buffer;
9. オプショナルチェイニング
null・undefined のオブジェクトの参照・管理を簡単かつ安全にします。通常、オブジェクトが null・undefined の場合は nullチェックや try catch文などでエラー管理する必要があります。しかし、オプショナルチェイニングを使用することで null・undefined を暗黙的に確認し、実際に null・undefined だった場合は undefined を即座に返すことで評価を短絡させます。
↓ は簡単なサンプルです
const animal = {
rabbit: {
name: 'peter',
},
cat: {
name: 'tom',
},
};
// dog は存在しないため、undefined を返して評価を終了します
const dogName = animal.dog?.name;
console.log(dogName); // undefined が出力されます
特にコンポーネントのプロパティなど、null・undefined になる可能性があるオブジェクトに対してもオプショナルチェイニングが有効です。
↓ のように宣言・初期化すると警告される可能性があります。
これは null で初期化する以上、オブジェクトが null である可能性を明示する必要があるからです。
// 警告される可能性があります
@property(cc.Node) myNode: cc.Node = null;
// ↓↓↓ これをこうします ↓↓↓
@property(cc.Node) myNode: cc.Node | null = null; // cc.Node または null を取りうることを明示します
| null によって nullを許容する null許容型(Nullable) として Cocos Creator のプロパティを定義できます。
そのほか 1つ注意点があります。原則としてオプショナルチェイニングはセッターの左辺に使用できません。
これはセッターの左辺が null・undefined だったときに処理が実質的にスキップされるため、以降の該当オブジェクトの管理に曖昧さが生じて意図しないバグの可能性をはらむからだと思われます。
object?.property = 1; // これは駄目です
ほかにも ↓ のようなパターンもあります。
obj.myProperty = nullObject?.property || myValue;
これはセッターの右辺でオプショナルチェイニングを使用するときは undefined だったときのフォールバック値(予備)が必要だからです。厳密にはJavaScript の論理和の短絡評価を応用したものです。初項が undefined のときに次項が返されます。
こうしないと警告される可能性があります。nullチェックでも問題ないですが、コードが冗長になるのでこちらがおすすめです。
Cocos Creator(TypeScript) を触りはじめたときは 型 'null' を型 '〇〇' に割り当てることはできません と大量のエラーが出て困っていましたが、これも上述したオプショナルチェイニングまわりの警告と関連することで、 TypeScript のコンパイラオプション である strict を有効にしていたからです。この strictオプションは型チェックを厳格にして型の安全性を強化します。したがって有効化が推奨されます。せっかくの TypeScript ですので...
なお、Cocos Creator ではコンパイラオプションを tsconfig.json ファイルで管理します。
おわりに
TypeScript は JavaScript と異なり、型やオブジェクトの概念が付加・強化されているため、言語学習に大きな摩擦はなかったです。ただ、Cocos Creator の使い方に悩まされました。おそらくUnity の開発経験があればスムーズに移行できたと思います。
自身が Cocos Creator(TypeScript) のビギナーであるため、内容に誤りがあったり、最適化の面で粗が目立ったりする可能性があります。その場合はご指摘を頂けると嬉しいです。