Skip to content

簡単なゲームを作ってローカル処理に触れよう

ローカルとグローバル

前回の記事にて、マルチプレイゲームとはなんなのかを定義しました。 おさらいしておきますと

  • 他人が存在する
  • 共有物や他人と相互に干渉し合う

という特徴を持つものがマルチプレイゲームでした。

しかし、他人と何でも共有すればいいというものではありません。 例えば、自分がアイテムを使おうとしてカバンの中を開いた時、それが他の人に伝わると困ります。 カバンの画像が勝手に出てきては邪魔です。

マルチプレイゲームを作る際には共有する部分自分だけが使える専有部分を常に考える必要があります。 先の例ではカバンを開いているという状態は専有部分にあたり、他人と共有しません。

AkashicEngine の世界においては専有部分を特にローカルと呼びます。また、共有部分をグローバルと呼ぶことがあります。 何も考えずに作ったものは原則グローバルなものになります。つまり参加者全員の環境で同期されます。 同期されたくない時に意図的にローカルな物を作る。それ以外は全部同期される、という原則を覚えましょう。

簡単なゲームを作ってみる

ローカルとグローバルを考えるため、AkashicEngine 入門の記事を参考にみんなでボタンを押すゲームを考えてみましょう。 https://akashic-games.github.io/tutorial/v3/multiplay/introduction.html

この記事では自分の PC 上でマルチプレイを動作チェックする方法なども書かれているため、まずはコピペで試してみましょう。 すでにシングルプレイやランキング対応ゲームの作成環境が整っている前提の記事ですから、まだの場合には以下の入門ページを挟むことをお勧めします。 https://akashic-games.github.io/tutorial/v3/introduction.html

早押しゲーム

入門文書のマルチプレイゲームサンプルを参考に作りました。ボタンを押すと押した人にスコアが入ります。ゲームっぽくするために以下の機能が追加で入っています。

  • ボタンを押すとランダムに数秒間ボタンが押せなくなる(早押しした人だけに点が入る)
  • ボタンが押せるようになってから早く押せば押すほどスコアが高く入る
  • 参加者全員のうち、獲得得点の上位5名を表示

終わりはありません。飽きたらブラウザを閉じて終わりです。 ソースコードは短いですが掲載するにはちょっと長いかもしれません。ラベル内で改行するために拡張機能の一つとして akashic-label を使っています。

js
const Label = require("@akashic-extension/akashic-label");

function main(param) {
  const MAXIMUM_BUTTON_TIMER_COUNT = 150;
  let timer = 0;
  let buttonDisabled = false;
  let button;
  const scoreTable = {};

  const scene = new g.Scene({
    game: g.game,
    assetIds: ["button", "se"]
  });

  const font = new g.DynamicFont({
    game: g.game,
    fontFamily: g.FontFamily.SansSerif,
    size: 20
  });
  const rankingLabel = new Label({
    scene: scene,
    font: font,
    text: "ランキング",
    fontSize: 20,
    textColor: "white",
    width: 100
  });

  scene.loaded.add(() => {
    // 背景とスコアボード用背景の初期化
    const scoreBoard = new g.FilledRect({
      scene: scene,
      cssColor: "black",
      height: g.game.height,
      width: g.game.width / 5
    });

    const background = new g.FilledRect({
      scene: scene,
      cssColor: "rgba(0,0,0,0.2)",
      height: g.game.height,
      width: g.game.width - scoreBoard.width
    });

    scoreBoard.x = g.game.width - scoreBoard.width;
    scene.append(background);
    scene.append(scoreBoard);

    // ボタンの初期化
    button = new g.Sprite({
      scene: scene,
      src: scene.assets["button"],
      touchable: true
    });
    button.y = g.game.height / 2 - button.height / 2;
    button.x = background.width / 2 - button.width / 2;
    scene.append(button);

    // ランキング用テキストの配置
    rankingLabel.x = scoreBoard.x;
    rankingLabel.width = scoreBoard.width;
    rankingLabel.modified();
    scene.append(rankingLabel);

    // ボタンタイマーを初期化
    timer = g.game.random.get(30, MAXIMUM_BUTTON_TIMER_COUNT);

    // ボタンを押された時の処理
    button.pointDown.add(event => {
      if (buttonDisabled) {
        return;
      }

      // ボタンが押せるようになってからの経過時間が少ないほどスコアが高い
      const score = Math.floor(1000 / -timer);
      if (scoreTable[event.player.id] == null) {
        scoreTable[event.player.id] = 0;
      }
      scoreTable[event.player.id] += score;
      timer = g.game.random.get(30, MAXIMUM_BUTTON_TIMER_COUNT);

      // スコアボードの処理 スコアボードを元に点数の配列を作り、上位5名を出す
      const topFive = Object.keys(scoreTable)
        .map(id => {
          return { score: scoreTable[id], id: id };
        })
        .sort((a, b) => {
          return b.score - a.score;
        })
        .slice(0, 5);

      // その5名を表示するテキストを作る
      let rankingText = "ランキング\n";
      topFive.forEach((score, rank) => {
        rankingText += `${rank + 1}位: ${score.id}さん(${score.score}pt)\n`;
      });
      rankingLabel.text = rankingText;
      rankingLabel.invalidate();
    });
  });

  // メインループ
  scene.update.add(() => {
    timer--;
    // タイマーが1以上でボタンが有効の時はボタンを無効にする
    if (timer > 0 && !buttonDisabled) {
      button.opacity = 0.2;
      button.modified();
      buttonDisabled = true;
    } else if (timer === 0 && buttonDisabled) {
      button.opacity = 1;
      button.modified();
      buttonDisabled = false;
    }
  });

  g.game.pushScene(scene);
}

module.exports = main;
const Label = require("@akashic-extension/akashic-label");

function main(param) {
  const MAXIMUM_BUTTON_TIMER_COUNT = 150;
  let timer = 0;
  let buttonDisabled = false;
  let button;
  const scoreTable = {};

  const scene = new g.Scene({
    game: g.game,
    assetIds: ["button", "se"]
  });

  const font = new g.DynamicFont({
    game: g.game,
    fontFamily: g.FontFamily.SansSerif,
    size: 20
  });
  const rankingLabel = new Label({
    scene: scene,
    font: font,
    text: "ランキング",
    fontSize: 20,
    textColor: "white",
    width: 100
  });

  scene.loaded.add(() => {
    // 背景とスコアボード用背景の初期化
    const scoreBoard = new g.FilledRect({
      scene: scene,
      cssColor: "black",
      height: g.game.height,
      width: g.game.width / 5
    });

    const background = new g.FilledRect({
      scene: scene,
      cssColor: "rgba(0,0,0,0.2)",
      height: g.game.height,
      width: g.game.width - scoreBoard.width
    });

    scoreBoard.x = g.game.width - scoreBoard.width;
    scene.append(background);
    scene.append(scoreBoard);

    // ボタンの初期化
    button = new g.Sprite({
      scene: scene,
      src: scene.assets["button"],
      touchable: true
    });
    button.y = g.game.height / 2 - button.height / 2;
    button.x = background.width / 2 - button.width / 2;
    scene.append(button);

    // ランキング用テキストの配置
    rankingLabel.x = scoreBoard.x;
    rankingLabel.width = scoreBoard.width;
    rankingLabel.modified();
    scene.append(rankingLabel);

    // ボタンタイマーを初期化
    timer = g.game.random.get(30, MAXIMUM_BUTTON_TIMER_COUNT);

    // ボタンを押された時の処理
    button.pointDown.add(event => {
      if (buttonDisabled) {
        return;
      }

      // ボタンが押せるようになってからの経過時間が少ないほどスコアが高い
      const score = Math.floor(1000 / -timer);
      if (scoreTable[event.player.id] == null) {
        scoreTable[event.player.id] = 0;
      }
      scoreTable[event.player.id] += score;
      timer = g.game.random.get(30, MAXIMUM_BUTTON_TIMER_COUNT);

      // スコアボードの処理 スコアボードを元に点数の配列を作り、上位5名を出す
      const topFive = Object.keys(scoreTable)
        .map(id => {
          return { score: scoreTable[id], id: id };
        })
        .sort((a, b) => {
          return b.score - a.score;
        })
        .slice(0, 5);

      // その5名を表示するテキストを作る
      let rankingText = "ランキング\n";
      topFive.forEach((score, rank) => {
        rankingText += `${rank + 1}位: ${score.id}さん(${score.score}pt)\n`;
      });
      rankingLabel.text = rankingText;
      rankingLabel.invalidate();
    });
  });

  // メインループ
  scene.update.add(() => {
    timer--;
    // タイマーが1以上でボタンが有効の時はボタンを無効にする
    if (timer > 0 && !buttonDisabled) {
      button.opacity = 0.2;
      button.modified();
      buttonDisabled = true;
    } else if (timer === 0 && buttonDisabled) {
      button.opacity = 1;
      button.modified();
      buttonDisabled = false;
    }
  });

  g.game.pushScene(scene);
}

module.exports = main;

ローカルとグローバルのちょっと詳しい話

ローカルとグローバルの境界線

今回の早押しゲームでは、グローバルな情報のみを取り扱いました。 画面に出てくるボタンはみんなが触れるためグローバルなオブジェクトでした。これは直感的ですね。

ではスコアはどうでしょう。これもグローバルな情報です。ランキングの状態を全員で揃えるために自分のスコアはみんなで共有する必要があります。 言い換えれば、グローバルなスコア一覧表をゲームがもっていて、ボタンを押した結果がそこに格納されていくわけです。

ではローカルとは何なのか。ここでいうローカルとはブラウザや js で実行中のゲームを意味し、他人同士のゲーム上で違うもの、が該当します。

例えば、3人のプレイヤー A、B、C が早押しゲームをプレイしたとしましょう。 この時、誰のブラウザ上においてもボタンの位置は同じですし、全員のスコアも一致しているはずです。 B のブラウザでは A の得点は 5 点だが、A のブラウザでは 500 点になっている、というようなことはあっては破綻します。一致していないと困ります。 オンラインゲームでは所持金、手持ちパーティ、クエストの進行状況など、全ての環境で一致している必要があるデータが大量にあります。

逆を言えばみんなで一致させたくないもの、一致していないもの、がローカルになります。ローカルとグローバルの境界線はここにあります。同様に、ローカルなものを操作する処理がローカル処理です。 例えば、プレイヤーは誰なのか。言い換えれば、このブラウザで早押しゲームをプレイしているのは A、B、C のうち誰なのか、という情報はブラウザでそれぞれ違うためローカルな情報です。 このローカルな情報を扱うローカル処理を早押しゲームに追加してみます。具体的には、自分のスコアの表示です。

ソースコードは割愛しました。以下のリポジトリからダウンロードできるので確認してみてください。 また、以下のリポジトリでのソースコードは、JavaScript を拡張した TypeScript を使って書かれています。 基本的には細部が異なるだけで JavaScript 同様に読めると思いますが、もし気になる場合には JavaScript に変換した結果を読むと助けになるかもしれません。

sh
npm run build
npm run build

上記コマンドで TypeScript が JavaScript に変換され、結果は script/main.js として出力されます。

本講座では基本的に記事上は JavaScript を扱っていく予定ですが、今後本格的なゲームを作っていくにあたりいまのうちに TypeScript に慣れていくとバグに悩まされることが少なくなるかもしれません。

完成版のソースコード一式はこちらです。 https://github.com/akashic-contents/button-multi

この例では、スコア一覧から自分のスコアを抜き出し画面に表示させています。またこの時自分のスコアが1位かそうでないかで表示色を変えています。 スコアの色は人によって異なり、スコアの中身も人によって異なります。そのためこの自分のスコア表示はローカルな処理と言えます。

ローカル処理と制約

ローカルとグローバルの境界線がわかったところで、マルチプレイ制作上の注意点を一つお伝えします。 ローカルな情報を扱う場合、自分にしか発生しない処理というものがよく現れます。今回の例では1位の時は赤くする、がそれです。

この、ローカルな処理内において、やってはいけないことがいくつかあります。その中でも特にありがちなのがグローバルな物への操作です。 参照だけなら問題ありません。 今回の例でも、グローバルなスコア一覧表を参照して自分のスコアを取得しています。 ローカルな処理は発生する人としない人がいるため、そこで直接グローバルを操作しても他人へは反映されません。 例えば1位の表示中は毎秒スコアが増加する、みたいな処理を考えてみます。この時分岐した処理の中にスコア一覧への加算処理を書いてしまうとそれは1位になった人にしか発生しないため、他の人とスコア一覧表の内容にズレが発生しゲームが破綻します。

ローカルな情報を使って条件分岐を書いた場合は必ずローカルな処理です。 ローカルな処理の中でグローバルなオブジェクトの生成、更新、削除を行なってはいけません。今回は詳細は省きますが、同じ理由によりローカルな処理内で g.game.random を使ってもいけません。

まとめますと

  • ローカルな情報を使って条件分岐を書くと、ローカルな処理が書ける
  • ローカルな情報は人によって違うため、人によって処理が行われる時と行われない時がある
  • ローカルな処理内でグローバルな物を操作すると同期が行われずゲームが壊れる

となります。

実際には今回の例では、スコア表示用ラベルがグローバルでもさしたる問題はおきません。破壊的な変更、イベントの発生、情報の参照などがないからです。 しかしローカルなボタンを作ったりエンティティの情報を参照したりするなど、大規模なゲームを作っていく際にはローカルとグローバルを意識することが必要になります。 いまのうちからこのような考え方の癖をつけておくと後々楽になるかもしれません。

以上が、ローカルな処理を書く上での注意点のまとめです。

次は

ローカル処理が境界線を乗り越えて同期を壊してしまったバグは、TypeScript のコンパイルなどでは誤りを検出できないため人間が気をつけてやる必要があります。 ローカルとグローバルの話は非常に重要なため、次回も少し触れるつもりです。

また、早押しボタンだけでは一瞬で飽きてしまうため本格的なゲームを小分けに作っていきます。