phina.jsのシーン遷移については、「<シーン編>|phina.js Tips集 下巻」が詳しいです。特徴的な部分をまとめます。
phina.jsでは暗黙のうちにデフォルトのシーンが用意されているので、ちょっと戸惑いました。
1. phina.jsの Hello Worldコード
久しぶりに phina.js で遊んでみることにしました。JavaScriptだけでブラウザ上でゲームが動くのはやっぱり手軽です。
しかし、プログラムの処理の流れをすっかり忘れています。
まずは、シーンの概要として、phina.jsの Hello Worldコードを見てみましょう1。
1-1. 画面の枠組み(index.html)
phina.jsのコードは、HTML(たとえば index.html)から呼び出して利用します。
<!doctype html>
<html>
<head>
<meta charset='utf-8' />
<meta name="viewport" content="width=device-width, user-scalable=no" />
<meta name="apple-mobile-web-app-capable" content="yes" />
<title>Hello World</title>
<script src='https://cdn.jsdelivr.net/gh/phi-jp/phina.js@v0.2.0/build/phina.js'></script>
<!-- メイン処理 -->
<script src='hello.js'></script>
</head>
<body>
</body>
</html>
「phina.js」のライブラリは、CDN(https://cdn.jsdelivr.net/gh/phi-jp/phina.js@v0.2.0/build/phina.js)を参照するか、ダウンロードして使います。
ライブラリを読み込んだ後に、自分のスクリプトを読み込めばよいわけです。
1-2. エントリーポイント(main関数と GameApp)
スクリプトの大まかな構造として、
まず、phina.globalize()で、phinaのAPIを利用できるようにしています。
その上でエントリーポイントのmain関数とMainSceneクラスを定義しています。
実行は定義後なので、定義順はどちらが先でも大丈夫です。
main関数 では GameApp を生成して、runしています。
// hello.js
phina.globalize();
/* メイン処理 */
phina.main(function() {
var app = GameApp({
startLabel: 'main',
});
app.run();
});
/* メインシーン */
phina.define("MainScene", {
superClass: 'DisplayScene',
init: function() {
this.superInit();
this.backgroundColor = '#444';
var label = Label('Hello, phina.js!').addChildTo(this);
label.x = this.gridX.center();
label.y = this.gridY.center();
label.fill = '#eee';
},
});
startLabel に’main’を指定すると、MainSceneが生成され呼び出されています。
このシーンでは、背景色を設定してから、テキストラベル(Label)を配置しています。
/*
* Runstant
* 思いたったらすぐ開発. プログラミングに革命を...
*/
// グローバルに展開
phina.globalize();
/*
* メインシーン
*/
phina.define("MainScene", {
// 継承
superClass: 'DisplayScene',
// 初期化
init: function() {
// super init
this.superInit();
// 背景色
this.backgroundColor = '#444';
// ラベルを生成
var label = Label('Hello, phina.js!').addChildTo(this);
label.x = this.gridX.center(); // x 軸
label.y = this.gridY.center(); // y 軸
label.fill = '#eee'; // 塗りつぶし色
},
});
/*
* メイン処理
*/
phina.main(function() {
// アプリケーションを生成
var app = GameApp({
startLabel: 'main', // MainScene から開始
});
app.enableStats();
// 実行
app.run();
});
後述しますが、’main’とMainSceneが関連付けられているのは、デフォルトのシーンリストに理由があります。
2. シーンの中身を定義する(initとupdate)
シーンの動作は、phina.define(クラス名, 関数)で定義します。
初期化(init)と更新処理(update)で構成されています。
たとえば、「Walk Tomapiko – phina.js」のメインシーン部分を見てみます。
画面上をマウスでクリックするとキャラクターが近づくように移動します。
/** メインシーン */
phina.define("MainScene", {
superClass: 'DisplayScene',
// 初期化
init: function(options) {
this.superInit(options);
/* ... */
},
// 更新
update: function(app) {
var p = app.pointer;
/* ... */
}
});
superClass: では、シーンの元になるスーパークラス(DisplayScene)を宣言しています。
2-1. シーン内の要素を配置する(init)
init: では、初期化処理を定義します。
これはシーン開始時に一回処理されます。
this.superInit()でスーパークラスの初期化処理を継承しています。
optionパラメータで別のシーンからオプションオブジェクトを受け取ることもできます。
// 初期化
init: function(options) {
this.superInit(options);
this.bg = Sprite("bg").addChildTo(this);
this.bg.origin.set(0, 0); // 左上基準に変更
this.player = Sprite('tomapiko', 64, 64).addChildTo(this);
this.player.setPosition(400, 400);
this.player.frameIndex = 0;
},
ここでは、bg(背景)とplayer(プレイヤー)のスプライトを用意しています。
2-2. シーン内の時間の流れ(update)
update: では、更新処理を定義します。
これはメインループで、フレームごと(30fps)に実行されます2。
appパラメータで、ゲームアプリの情報を受け取れます。
// 更新
update: function(app) {
var p = app.pointer;
if (p.getPointing()) {
var diff = this.player.x - p.x;
if (Math.abs(diff) > SPEED) {
if (diff < 0) {
this.player.x += SPEED;
this.player.scaleX = -1;
} else {
this.player.x -= SPEED;
this.player.scaleX = 1;
}
if (app.frame % 4 === 0) {
this.player.frameIndex = (this.player.frameIndex === 12) ? 13:12;
}
}
} else {
this.player.frameIndex = 0;
}
}
app.pointerでマウス情報を取得しています。
playerスプライトを移動したり、アニメーションさせています。
/*
* Runstant
* 思いたったらすぐ開発. プログラミングに革命を...
*/
// グローバルに展開
phina.globalize();
// 定数
var ASSETS = {
image: {
bg: "http://jsrun.it/assets/a/G/5/Y/aG5YD.png",
tomapiko: 'http://cdn.rawgit.com/phi-jp/phina.js/v0.2.0/assets/images/tomapiko_ss.png',
},
};
var SCREEN_WIDTH = 465; // スクリーン幅
var SCREEN_HEIGHT = 465; // スクリーン高さ
var SPEED = 4;
/*
* メインシーン
*/
phina.define("MainScene", {
// 継承
superClass: 'DisplayScene',
// 初期化
init: function(options) {
// super init
this.superInit(options);
// 背景
this.bg = Sprite("bg").addChildTo(this);
this.bg.origin.set(0, 0); // 左上基準に変更
// プレイヤー
this.player = Sprite('tomapiko', 64, 64).addChildTo(this);
this.player.setPosition(400, 400);
this.player.frameIndex = 0;
},
// 更新
update: function(app) {
var p = app.pointer;
if (p.getPointing()) {
var diff = this.player.x - p.x;
if (Math.abs(diff) > SPEED) {
// 右に移動
if (diff < 0) {
this.player.x += SPEED;
this.player.scaleX = -1;
}
// 左に移動
else {
this.player.x -= SPEED;
this.player.scaleX = 1;
}
// フレームアニメーション
if (app.frame % 4 === 0) {
this.player.frameIndex = (this.player.frameIndex === 12) ? 13:12;
}
}
}
else {
// 待機
this.player.frameIndex = 0;
}
}
});
/*
* メイン処理
*/
phina.main(function() {
// アプリケーションを生成
var app = GameApp({
startLabel: 'main', // MainScene から開始
width: SCREEN_WIDTH, // 画面幅
height: SCREEN_HEIGHT,// 画面高さ
assets: ASSETS, // アセット読み込み
});
app.enableStats();
// 実行
app.run();
});
3. 複数のシーンを組み合わせる
ここまでのサンプルでは 単一のシーンで動作していましたが、phina.jsでは複数のシーンを組み合せてゲームを構成します。
シーンを分割することで、内部処理を管理しやすくしています。
「Touch Game – phina.js」では、タイトルを表示後にメインのゲームがあり、最後に結果画面を表示しています。
しかし、スクリプト全体を見ても MainSceneしか定義されていません。
これは、タイトル画面と結果画面はデフォルトのシーンをそのまま利用しているからです。
phina.globalize();
/* ... */
phina.define("MainScene", {
superClass: 'DisplayScene',
init: function() {
/* ... */
},
onenter: function() {
/* ... */
},
update: function(app) {
/* ... */
},
check: function(piece) {
/* ... */
}
});
phina.define('Piece', {
superClass: 'Button',
/* ... */
});
phina.main(function() {
var app = GameApp({
width: SCREEN_WIDTH,
height: SCREEN_HEIGHT,
startLabel: 'title',
});
app.run();
});
3-1. デフォルトのタイトルシーン(title)
startLabel が「title」です。
TitleSceneを定義していないので、デフォルトのタイトルシーンが表示されます。
デフォルトのタイトルシーンは、クリックすると「main」シーンに遷移するようになっています。
3-2. シーンの初期化(init, onpointstart)
MainSceneでは、initで はじめに数字のPiecesを配置しています。
また、onpointstartでクリック時の動作を登録しています。
init: function() {
this.superInit({
width: SCREEN_WIDTH,
height: SCREEN_HEIGHT,
});
/* ... */
var numbers = Array.range(1, MAX_NUM+1).shuffle();
numbers.each(function(index, i) {
/* ... */
var p = Piece(index).addChildTo(self.group);
/* ... */
p.onpointstart = function() {
self.check(this);
};
p.appear();
});
this.onpointstart = function(e) {
var p = e.pointer;
var wave = Wave().addChildTo(this);
wave.x = p.x;
wave.y = p.y;
};
},
Piecesは、Buttonを継承して作ったオブジェクトです。
phina.define('Piece', {
superClass: 'Button',
/* ... */
});
3-3. クリア判定とスコアを受け渡す(exit)
updateではタイマー進行の処理だけです。
数字ブロックを順番にクリックしていくと、onpointstartのイベントが発生します。
check関数が呼び出されクリア条件を判定しています。
check: function(piece) {
if (this.currentIndex === piece.index) {
piece.alpha = 0.5;
if (this.currentIndex >= MAX_NUM) {
this.exit({
score: 100,
});
}
else {
this.currentIndex += 1;
}
}
}
this.exit(DisplayScene::exit)でシーンは終了し、次のシーンに遷移します。
MainSceneの次のラベルは既定では「result」になっています。
しかし、ResultSceneも定義していないので、 デフォルトのResultSceneに遷移します。
exitの引数は、次のシーンのinitに渡すことができます3。
デフォルトのResultSceneは、オブジェクト{score: 100, }をパラメータoptionsとして受け取り、結果として「100」を表示しています。
(ゲーム性を考えると、経過時間からスコアを決めてもよいですね)
3-4. 準備画面を挿入する(onenter, pushScene)
ただし、ゲーム開始前に「Ready」という準備画面が挟み込んであります。
これは、onenter: で定義されています。
onenter: function() {
var scene = CountScene({
backgroundColor: 'rgba(100, 100, 100, 1)',
count: ['Ready'],
fontSize: 100,
});
this.app.pushScene(scene);
},
用意されている CountScene を GameApp::pushSceneで入れています4。
挿入したシーンは、exit で元にシーンに復帰します。
/*
* Runstant
* 思いたったらすぐ開発. プログラミングに革命を...
*/
phina.globalize();
var SCREEN_WIDTH = 640;
var SCREEN_HEIGHT = 960;
var MAX_PER_LINE = 5; // ピースの横に並ぶ最大数
var PIECE_SIZE = 100;
var BOARD_PADDING = 40;
var MAX_NUM = MAX_PER_LINE*MAX_PER_LINE; // ピース全体の数
var BOARD_SIZE = SCREEN_WIDTH - BOARD_PADDING*2;
var BOARD_OFFSET_X = BOARD_PADDING+PIECE_SIZE/2;
var PIECE_APPEAR_ANIMATION = {
// loop: true,
tweens: [
['to', {rotation:360}, 500],
['set', {rotation:0}],
]
};
phina.define("MainScene", {
superClass: 'DisplayScene',
init: function() {
this.superInit({
width: SCREEN_WIDTH,
height: SCREEN_HEIGHT,
});
this.currentIndex = 1;
this.group = DisplayElement().addChildTo(this);
var gridX = Grid(BOARD_SIZE, 5);
var gridY = Grid(BOARD_SIZE, 5);
var self = this;
var numbers = Array.range(1, MAX_NUM+1).shuffle();
numbers.each(function(index, i) {
// グリッド上でのインデックス
var xIndex = i%MAX_PER_LINE;
var yIndex = Math.floor(i/MAX_PER_LINE);
var p = Piece(index).addChildTo(self.group);
p.x = gridX.span(xIndex)+BOARD_OFFSET_X;
p.y = gridY.span(yIndex+1)+150;
p.onpointstart = function() {
self.check(this);
};
p.appear();
});
// タイマーラベルを生成
var timerLabel = Label('0').addChildTo(this);
timerLabel.origin.x = 1;
timerLabel.x = 580;
timerLabel.y = 130;
timerLabel.fill = '#444';
timerLabel.fontSize = 100;
// timerLabel.align = 'right';
timerLabel.baseline = 'bottom';
this.timerLabel = timerLabel;
this.time = 0;
this.onpointstart = function(e) {
var p = e.pointer;
var wave = Wave().addChildTo(this);
wave.x = p.x;
wave.y = p.y;
};
},
onenter: function() {
var scene = CountScene({
backgroundColor: 'rgba(100, 100, 100, 1)',
count: ['Ready'],
fontSize: 100,
});
this.app.pushScene(scene);
},
update: function(app) {
// タイマーを更新
this.time += app.ticker.deltaTime;
var sec = this.time/1000; // 秒数に変換
this.timerLabel.text = sec.toFixed(3);
},
check: function(piece) {
if (this.currentIndex === piece.index) {
piece.alpha = 0.5;
if (this.currentIndex >= MAX_NUM) {
this.exit({
score: 100,
});
}
else {
this.currentIndex += 1;
}
}
}
});
phina.define('Piece', {
superClass: 'Button',
init: function(index) {
this.superInit({
width: PIECE_SIZE,
height: PIECE_SIZE,
text: index+'',
});
this.index = index;
},
appear: function() {
this.tweener
.clear()
.fromJSON(PIECE_APPEAR_ANIMATION);
},
});
phina.main(function() {
var app = GameApp({
startLabel: location.search.substr(1).toObject().scene || 'title',
width: SCREEN_WIDTH,
height: SCREEN_HEIGHT,
startLabel: 'title',
});
app.enableStats();
app.run();
});
4. デフォルトのシーンリスト
MainScene以外のTitleScene, ResultSceneはデフォルトで用意されているものを使っていました。実は phina.jsには 4つのシーンがデフォルトで用意されています5。
先ほど使った MainSceneはその一つです。
シーンクラス名 | ラベル |
---|---|
SplashScene | splash |
TitleScene | title |
MainScene | main |
ResultScene | result |
splash → title → main → result
の順番に遷移するよう設定されています。
開始シーンは、GameApp
の startLabel
で指定します。
初期値は ‘title’ になっています。
(つまり、そのままでは splashシーンは使われません。)
/** メイン処理 */
phina.main(function() {
var app = GameApp({
startLabel: 'splash',
});
app.run();
});
自分でシーンを作成しなくても、基本的なシーンが利用できるんだね。
4-1. シーンリストの構造(GameApp.scenes)
シーン終了時の「次のシーン」は、GameAppに設定されたシーンリスト(scenes
変数)で管理されています。
シーンリストは、className, label, nextLabel のオブジェクトの配列になっています。ラベル(文字列)とクラス名を結びつけると同時に、遷移先のシーンも指定しています。
もし、独自のシーンを追加・設定したいなら、GameAppのコンストラクタのパラメータでscenesを渡します6。
phina.main(function() {
var app = GameApp({
startLabel: 'scene01',
scenes: [
{
className: 'Scene01',
label: 'scene01',
nextLabel: 'scene02',
},
{
className: 'Scene02',
label: 'scene02',
nextLabel: 'scene03',
},
{
className: 'Scene03',
label: 'scene03',
nextLabel: 'scene01',
},
]
});
app.run();
});
逆にいうと、GameApp::scenesに何も渡さなかったので、デフォルトのシーンリストになっていたわけです。
scenes: [
{
className: 'SplashScene',
label: 'splash',
nextLabel: 'title',
},
{
className: 'TitleScene',
label: 'title',
nextLabel: 'main',
},
{
className: 'MainScene',
label: 'main',
nextLabel: 'result',
},
{
className: 'ResultScene',
label: 'result',
nextLabel: '',
},
]
まとめると、phina.jsでは、
必要なシーンクラスを定義し、
それらをシーンリストとしてまとめて GameAppで実行しています。
はじめはシーンリストを意識しなくても、デフォルトのシーンリストがあります。
しかし、複数の画面を遷移するには自分でシーンリストを定義する必要があります。
(補足)
- 参考 – https://runstant.com/phi/projects/phinajs_template
- デフォルトでは30fpsになっています – ゲームのfpsを変える|phina.js Tips集 下巻
- Scene遷移で値を渡す|phina.js Tips集 下巻
- Sceneをプッシュしてポーズ画面を作成する|phina.js Tips集 下巻
- デフォルトで用意されてるSceneについて知る|phina.js Tips集 下巻
- 独自のSceneを作って遷移させる|phina.js Tips集 下巻