▽この記事について

この記事はニコニコ新市場へコンテンツを投稿している、またはこれから投稿しようとしている方に対して、マルチプレイ対応コンテンツの作成に必要な知識をお伝えしていく連載の2回目です。 
今回は簡単なゲームを作り、それに絡めてマルチプレイゲームに重要な、ローカルとグローバルの境界線の話をしたいと思います。

▽対象読者

この記事はニコニコ新市場でゲームを開発をこれから始める、もしくは何らかのゲームを作ったことがある方が対象です。記事中にJavaScriptの記載があるためJavaScriptを何かしらの形で書いたことがあると理解がスムーズです。

また、サンプルとして提供したコードはTypeScriptを用いて書かれています。


▽ローカルとグローバルの話

ローカルとグローバル

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

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

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

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

AkashicEngineの世界においては専有部分を特にローカルと呼びます。また、共有部分をグローバルと呼ぶことがあります。


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

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

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



早押しゲーム

edb9904b24035831d84376818076182ad1a792e9

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

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

終わりはありません。飽きたらブラウザを閉じて終わりです。

ソースコードは短いですがブロマガに乗せるにはちょっと長いかもしれません。ラベル内で改行するために拡張機能の一つとしてakashic-labelを使っています。

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のうち誰なのか、という情報はブラウザでそれぞれ違うためローカルな情報です。
このローカルな情報を扱うローカル処理を早押しゲームに追加してみます。具体的には、自分のスコアの表示です。

4f0c3bade99de53aea125957308ced780f7b5e31

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

npm run build

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

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

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


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


ローカル処理と制約

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

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

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

まとめますと

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

となります。

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

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



▽最後に

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

また、早押しボタンだけでは一瞬で飽きてしまうため本格的なゲームを小分けに作っていくつもりなのですが、どのようなゲームを作っていくか少し悩み中です。マルチプレイゲームはニコ生上で遊べるため、せっかくならその機能を生かしたいところです。

- 全員で対戦
- 視聴者vs放送者
- みんなで協力

など、いくつかジャンルの候補はあります。実現できるかはわかりませんが今後連載を進めてほしいジャンルが何かありましたらおしらせください。
これからも新市場投稿者向けの情報や新しいコンテンツ情報などを配信していくので、引き続きよろしくお願いいたします。