【スポンサーリンク】

[phina.js] シーン管理の基本

[phina.js] シーン管理の基本

phina.jsのシーン遷移については、「<シーン編>|phina.js Tips集 下巻」が詳しいです。特徴的な部分をまとめます。

[phina.js] シーン管理の基本

phina.jsでは暗黙のうちにデフォルトのシーンが用意されているので、ちょっと戸惑いました。

\記事が役に立ったらシェアしてね/
【スポンサーリンク】

1. phina.jsの Hello Worldコード

久しぶりに phina.js で遊んでみることにしました。JavaScriptだけでブラウザ上でゲームが動くのはやっぱり手軽です。

しかし、プログラムの処理の流れをすっかり忘れています。

まずは、シーンの概要として、phina.jsの Hello Worldコードを見てみましょう1

phina.jsの Hello Worldコード

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」のメインシーン部分を見てみます。
画面上をマウスでクリックするとキャラクターが近づくように移動します。

シーンの中身を定義する(initとupdate)
/** メインシーン */
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はその一つです。

シーンクラス名ラベル
SplashScenesplash
TitleScenetitle
MainScenemain
ResultSceneresult

splash → title → main → result
の順番に遷移するよう設定されています。

開始シーンは、GameAppstartLabelで指定します。
初期値は ‘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で実行しています。

はじめはシーンリストを意識しなくても、デフォルトのシーンリストがあります。
しかし、複数の画面を遷移するには自分でシーンリストを定義する必要があります。

(補足)

  1. 参考 – https://runstant.com/phi/projects/phinajs_template
  2. デフォルトでは30fpsになっています – ゲームのfpsを変える|phina.js Tips集 下巻
  3. Scene遷移で値を渡す|phina.js Tips集 下巻
  4. Sceneをプッシュしてポーズ画面を作成する|phina.js Tips集 下巻
  5. デフォルトで用意されてるSceneについて知る|phina.js Tips集 下巻
  6. 独自のSceneを作って遷移させる|phina.js Tips集 下巻
QRコードを読み込むと、関連記事を確認できます。

[phina.js] シーン管理の基本
【スポンサーリンク】
タイトルとURLをコピーしました