Skip to content

スコアとタイマーの追加

ニコニコ生放送には、ニコ生ゲームでのスコアを放送者・視聴者がそれぞれ競い合い、その順位を表示してくれるランキング機能があります。 そのスコアを加算する部分のロジックを追加しましょう。

スコア加算のルール

ランキングゲームはたくさんの人が同じルールで競い合い、より高いスコアを獲得することが目的です。 一方で、ここまで作成してきたブロックくずしは「パドルを操作してブロックを破壊する」という操作しかプレイヤー側に与えられていません。 例えば「ブロックを破壊するごとにスコアを 100 を加算する」というルールにすると、全てのブロックを破壊したプレイヤー全員が同率 1 位になってしまいます。 これではゲームを競い合うモチベーションが上がりません。

したがって、スコアはなるべくプレイによってバラけることが望ましいです。 理想はすべてのプレイヤーのスコアが異なることです。 そこで今回は ボールがパドルに衝突してから次にパドルに衝突するまでの間に、より多くのブロックを破壊したらその分のボーナスを加算する というルールでスコアを加算するようにしましょう。

スコア加算処理

ニコ生ゲームではコードの先頭の方で定義されている game.vars.gameState.score がそのままゲームのスコアとなります。 この値をスコアを加算したいタイミングで適宜加算していきます。

javascript
exports.main = (param) => {
    const game = g.game; // よくアクセスするため変数に保持しておく
    const scene = new g.Scene({
        game,
        assetPaths: [
            "/assets/images/*",
            "/assets/se/*",
        ],
    });
    // ニコ生ゲームのランキングモードでは g.game.vars.gameState.score の値がスコアとして扱われる
    game.vars.gameState = { score: 0 };

    ...

スコアの表示

ビットマップフォントの作成

スコアは 0 から 9 までの数字しか使いません。 このように表示する値が事前に決まっているフォントの表示には ビットマップフォント が適しています。

ビットマップフォントとは、文字 (グリフ) として使いたい文字を敷き詰めて作った画像を指します。 あらかじめビットマップフォントを作成しておくことで、どのような端末でも同一のデザインのフォントが表示できる利点があります。

今回は、 TrueType 形式 (.ttf) のフォントデータからスコア表示用のビットマップフォントを作成してみます。 ビットマップフォントの作成には bmpfont-generator を利用します。 bmpfont-generator は、 .ttf 形式のフォントデータを画像化して Akashic Engine 用のビットマップフォントデータを生成するツールです。

次のコマンドを実行することでインストールできます。

sh
npm i -g @akashic/bmpfont-generator

インストールが正常に行われたかを確認するため、以下のコマンドを実行します。

sh
bmpfont-generator --version

正常にインストールされていれば、バージョン番号 (執筆時点では 4.0.5) が表示されます。 以降の説明ではバージョンが 4.0.5 であることを前提としているため、 4.0.5 よりも小さい場合は上記コマンドで再インストールしておきましょう。

スコア表示用のフォントとして、今回は 自家製 Rounded M+ にて配布されている rounded-mplus-2p-black を利用します。 以下からフォントデータをダウンロードして game.json と同一ディレクトリに保存してください。

WARNING

bmpfont-generator を使う際には、必ずフォントの配布元でライセンスを確認してください。 ライセンスによっては無償・有償にかかわらず商用での利用を禁止していたり、または画像化して配布することを禁止している場合があります。

ビットマップフォントのデータの保存先として、assets ディレクトリ内に fonts ディレクトリを作成しておきましょう。 作成後、game.json のディレクトリへ移動して以下のコマンドを実行します。

sh
bmpfont-generator rounded-mplus-2p-black.ttf -F "#fff" -S "#000" --stroke-width 3 -c "0123456789+-" -H 45 -w 30 assets/fonts/font-number.png

-H にはフォントの高さ (px) を指定します。 -w にはフォントの幅 (px) を指定します。 -F にはフォントの色を指定します。 -S にはフォントの枠色を指定します。 --stroke-width には枠の太さ (px) を指定します。 -c は画像化するフォントの文字列を指定します。 ここでは数字を利用するので "0123456789+-" を指定します。 最後の assets/fonts/font-number.png はビットマップフォントの出力先です。

TIP

コマンドで指定できる引数を調べたい場合は --help をつけて実行してみましょう。 大体のコマンドにおいて、サポートする引数や使い方などを表示してくれます。

sh
bmpfont-generator --help

assets/fonts ディレクトリに font-number.pngfont-number_glyphs.json が出力されていることを確認します。 font-number_glyphs.json には Akashic Engine で利用するためのグリフ情報が格納されています。

assets
└── fonts
    ├── font-number.png
    └── font-number_glyphs.json

再び akashic scan コマンドを実行します。

sh
akashic scan asset

ビットマップフォントの利用

ビットマップフォントを使えるようにコードを修正します。 ビットマップフォントを読み込ませるため assetPaths を修正します。

javascript
const scene = new g.Scene({
  game,
  assetPaths: [
    "/assets/images/*",
    "/assets/se/*",
    "/assets/fonts/*" //[!code ++]
  ]
});

次に g.BitmapFont を生成します。

javascript
        const blockContainer = new g.E({
            ....
        });
        scene.append(blockContainer);

        // スコア用のビットマップフォントの作成
        const font = new g.BitmapFont({
            scene,
            src: scene.asset.getImage("/assets/fonts/font-number.png"),
            glyphInfo: scene.asset.getJSONContent("/assets/fonts/font-number_glyphs.json"),
        });

        // paddle を作成
        const paddle = new g.Sprite({
            ...
        });
        scene.append(paddle); // paddle をシーンに追加

src には bmpfont-generator で生成した画像アセットを指定します。 glyphInfo には同時に生成された font-number_glyphs.json を指定します。 scene.asset.getJSONContent() は引数に指定したパスのテキストアセットを JSON の形式にパースした結果を返します。

以上で g.BitmapFont が生成できました。 次にこのビットマップフォントを実際に描画してみましょう。 ビットマップフォントの描画には g.Label を利用します。

javascript
        const blockContainer = new g.E({
            ....
        });
        scene.append(blockContainer);

        // スコア用のビットマップフォントの作成
        const font = new g.BitmapFont({
            ...
        });

        // スコア表示エンティティの作成
        const scoreLabel = new g.Label({
            scene,
            font,
            fontSize: font.size,
            text: `${game.vars.gameState.score}`,
            x: game.width - 10,
            y: 10,
            anchorX: 1.0,
            anchorY: 0,
        });
        scene.append(scoreLabel);

        // paddle を作成
        const paddle = new g.Sprite({
            ...
        });
        scene.append(paddle); // paddle をシーンに追加

font には先ほど生成した g.BitmapFont を指定します。 fontSize には font.size の値をそのまま指定します。 text には実際に表示する文字列を指定します。 anchorX1.0 に指定することで、画面の右端へ寄せるように配置しています。

TIP

`${game.vars.gameState.score}` はテンプレートリテラルと呼ばれる構文で、文字列をバッククオート記号 ` で定義することで利用できます。 テンプレートリテラル内では ${変数名} のように ${} で変数を囲むと、その変数が自動的に展開された状態で文字列を解決してくれます。 ここでは game.vars.gameState.score の値を文字列に変換しています。

ゲームを実行してみましょう。 画面の右上にスコアが表示されていれば成功です。

スコアの加算

ブロックの破壊時にスコアを加算してみましょう。 まず、スコア加算時に scoreLabel の表示を更新する関数 updateScoreLabel() を定義しておきましょう。

javascript
        // スコア表示エンティティの作成
        const scoreLabel = new g.Label({
            ...
        });
        scene.append(scoreLabel);

        // スコア表示を更新する
        function updateScoreLabel() {
            scoreLabel.text = `${game.vars.gameState.score}`;
            scoreLabel.invalidate();
        }

実は scoreLabeltext の内容を変更しただけでは、描画内容は更新されません。 描画内容を更新するには text の変更後 scoreLabel.invalidate() を呼びます。

TIP

invalidate() はキャッシュされている描画内容を更新する関数です。 g.Label など、描画内容をキャッシュするエンティティに存在します。 これには、不要なキャッシュ更新を避けることでパフォーマンス低下を防ぐという目的があります。 描画内容はそのままで、単に座標や角度を変更するだけの場合は modified() を呼び出してください。

ボールとブロックの衝突処理の部分でスコアを加算します。

javascript
                if (isCollided) {
                    // 破壊可能のボールと衝突していたら
                    if (block.tag.mapNumber !== 1) {
                        block.destroy(); // ブロックを破壊
                        seBlock.play();
                        game.vars.gameState.score += 100; // スコアを加算
                        updateScoreLabel(); // スコア表示の更新
                        break; // 一度のフレームで一つのブロックのみを削除
                    }
                }

加算した後、忘れずに updateScoreLabel() を実行してスコア表示を更新します。

ゲームを実行してみましょう。 ブロックを破壊するたびにスコアが加算されていれば成功です。

ボーナスの実装

さて、冒頭でも説明した通り

ボールがパドルに衝突してから次にパドルに衝突するまでの間に、より多くのブロックを破壊したらその分のボーナスを加算する

というルールでスコアにボーナスを加えてみることにします。 まず「パドルに衝突してから、ブロックを破壊した個数」として bonusCount という変数を定義します。

javascript
        let direction = normalize([1, 2]);
        let speed = 12;
        let vx = speed * direction[0];
        let vy = speed * direction[1];
        let bonusCount = 0;

        scene.onUpdate.add(() => {
            ...
        });

ブロックの破壊時にボーナス分のスコアを追加で加算します。 ここでは 連続で破壊した個数 * 50 をボーナス分として加算します。

javascript
                if (isCollided) {
                    // 破壊可能のボールと衝突していたら
                    if (block.tag.mapNumber !== 1) {
                        block.destroy(); // ブロックを破壊
                        seBlock.play();
                        game.vars.gameState.score += 100; // スコアを加算//[!code --]
                        const bonusScore = bonusCount * 50; //ボーナスとして加算するスコア//[!code ++]
                        game.vars.gameState.score += 100 + bonusScore; // スコアを加算//[!code ++]
                        updateScoreLabel(); // スコア表示の更新
                        bonusCount++;//[!code ++]
                        break; // 一度のフレームで一つのブロックのみを削除
                    }
                }

ボールが画面の下端に到達したら bonusCount をリセットします。

javascript
// ボールが画面の下端に到達したとき
if (ball.y > game.height + ball.height / 2) {
  isStarted = false;
  vx = speed * direction[0];
  vy = speed * direction[1];
  seMiss.play();
  bonusCount = 0; 
}

同様にパドルと衝突したら bonusCount をリセットします。

javascript
// ボールとパドルが衝突したとき
if (intersect(paddle, ball)) {
  const h = 30;
  const direction = normalize([ball.x - paddle.x, -(ball.y - (paddle.y + h))]);
  vx = speed * direction[0];
  vy = speed * direction[1];
  sePaddle.play();
  bonusCount = 0; 
}

再びゲームを実行してみましょう。 ブロックを連続で破壊するたびにボーナススコアが加算されていれば成功です。

TIP

スコアがどのように加算されているのかを、それとなくプレイヤーに仄めかすことも重要です。 以下は解決策の一例として、ボーナススコアが加算された際に破壊したブロック付近に一定時間 +50+100 のようなボーナススコアを表示する方法です。 このような演出により、ボーナス加算の条件をプレイ中に気づかせることが可能になるかもしれません。

javascript
                if (isCollided) {
                    // 破壊可能のボールと衝突していたら
                    if (block.tag.mapNumber !== 1) {
                        block.destroy(); // ブロックを破壊
                        seBlock.play();
                        const bonusScore = bonusCount * 50;
                        game.vars.gameState.score += 100 + bonusScore; // スコアを加算
                        updateScoreLabel(); // スコア表示の更新

                        // ボーナススコアの表示
                        if (bonusCount > 0) {
                            const bonusLabel = new g.Label({
                                scene,
                                font,
                                fontSize: font.size,
                                text: `+${bonusScore}`,
                                x: block.x,
                                y: block.y + block.height / 2 + 10,
                                anchorX: 0.5,
                                anchorY: 0.5,
                            });
                            // 500 ミリ秒後に削除
                            scene.setTimeout(() => {
                                bonusLabel.destroy();
                            }, 500);
                            scene.append(bonusLabel);
                        }

                        bonusCount++;
                        break; // 一度のフレームで一つのブロックのみを削除
                    }
                }

タイマーの実装

残り時間を示すタイマーも表示しましょう。

ランキングモードで起動されるニコ生ゲームには制限時間があります。

この制限時間は、テンプレート上部にある変数 param から取得することができます。

javascript
exports.main = (param) => {
    const game = g.game; // よくアクセスするため変数に保持しておく
    const scene = new g.Scene({
        game,
        assetPaths: [
            "/assets/images/*",
            "/assets/se/*",
            "/assets/fonts/*",
        ],
    });
    let time = 60; // 制限時間//[!code ++]
    if (param.sessionParameter.totalTimeLimit) {//[!code ++]
        time = param.sessionParameter.totalTimeLimit; // セッションパラメータで制限時間が指定されたらその値を使用//[!code ++]
    }//[!code ++]
    ...

TIP

セッションパラメータについての詳細は セッションパラメータとランキング対応ゲームテンプレート を参照してください。

変数 timeゲームが起動してから終了までの全ての時間 を意味しています。 したがって、この値からゲームリソースの読み込み時間・オープニング演出時間・エンディング演出時間を差し引いた部分が実質的なゲーム実行時間、すなわちタイマーとして表示すべき値にあたります。

全体の起動時間は game.json を直接編集することでその値を変更することができます。

TIP

詳細については テンプレートでわかるランキングゲーム#制限時間の申告 も併せて参照してください。

ゲームリソースの読み込み時間・オープニング演出時間・エンディング演出時間は制作するゲームによって様々です。 オープニング演出・エンディング演出の追加については後章で説明しますが、今の段階ではそれらの合計が 15秒 と仮定し、ゲームの時間が50秒となるように totalTimeLimit の値を65秒に変更しておきましょう。

game.json を開き、以下のように修正します。

javascript
	"environment": {
		"sandbox-runtime": "3",
		"nicolive": {
			"supportedModes": [
				"ranking"
			]//[!code --]
			],//[!code ++]
			"preferredSessionParameters": {//[!code ++]
				"totalTimeLimit": 65//[!code ++]
			}//[!code ++]
		}
	},

これでゲームの実行時間が65秒へと変更されました。

WARNING

nicolive というキーが niconico になっている場合、それは古い仕様のままです。 nicolive へと修正してください。

javascript
	"environment": {
		"sandbox-runtime": "3",
		"niconico": {//[!code --]
		"nicolive": {//[!code ++]
			"supportedModes": [
				"ranking"
			]
		}
	},

以上を踏まえ、スコアエンティティ作成部分の直後に次のコードを追加します。

javascript
        // スコア用のビットマップフォントの作成
        const font = new g.BitmapFont({
            ...
        });

        // スコア表示エンティティの作成
        const scoreLabel = new g.Label({
            ...
        });
        scene.append(scoreLabel);

        // 残り時間 (合計時間から15秒の猶予を持たせる)
        let remainingTime = time - 15;

        // タイマー表示エンティティの作成
        const timerLabel = new g.Label({
            scene,
            font,
            fontSize: font.size,
            text: `${remainingTime}`,
            x: 70,
            y: 10,
            width: 70,
            anchorX: 1.0,
            anchorY: 0,
        });
        scene.append(timerLabel);

remainingTime はゲームの残り時間を示す変数です。

残り時間を更新する関数 updateTimer() も作成します。

javascript
        // スコア表示を更新する
        function updateScoreLabel() {
            ...
        }

        // タイマー表示を更新する
        function updateTimer() {
            timerLabel.text = `${remainingTime}`;
            timerLabel.invalidate();
        }

残り時間を減らす処理も追加します。

javascript
let direction = normalize([1, 2]);
let speed = 12;
let vx = speed * direction[0];
let vy = speed * direction[1];
let bonusCount = 0;

// 残り時間の更新
const timer = scene.setInterval(() => {

  remainingTime--; 
  if (remainingTime === 0) {

    scene.clearInterval(timer); // タイマーの停止
    vx = 0; // ボールの停止
    vy = 0; // ボールの停止
    // TODO: 終了処理
  } 
  updateTimer(); 
}, 1000); 

scene.setInterval() はある処理を定期的に実行するメソッドです。 第1引数には処理を指定します。 第2引数の 1000 は実行間隔のミリ秒を指定します。 1ミリ秒は 1/1000 秒 のため、1000ミリ秒は1秒を示します。 したがって、ここでは1秒毎に remainingTime を1ずつ減らしています。

また、scene.setInterval() は戻り値にタイマーの ID を返します。 この値を scene.clearInterval() に渡すことで、定期処理を停止することができます。 remainingTime が 0 となったときの部分はまだ実装しないので // TODO: で始まるコメントのみを先に置いておきます。

TIP

このようにコメント内にメタデータを持たせることを アノテーションコメント といい、ゲーム開発に関わらずプログラミングにおいてはしばしば使われています。 IDE などで TODO: を検索することで、後にコードを追加する際に素早く該当箇所に移動することができます。

アノテーションコメントには TODO: 以外にも NOTE: (補足説明) や FIXME: (できるだけ早い段階で修正すべきコード) などがよく使われます。 ただしアノテーションコメントは仕様規格が定まっているわけではないため、人によって様々な使われ方がされています。

ついでにゲームの残り時間が0となったときにスワイプ操作を禁止しましょう。 scene.onPointMoveCaptureremainingTime を判定し、0 であれば return させます。

INFO

scene.onPointMoveCapture などのハンドラ内の関数で true を返すと、そのハンドラの登録が削除されます。

javascript
// スワイプでパドルが左右に動くようにする
const paddleMargin = 128 + paddle.width / 2;
scene.onPointMoveCapture.add((event) => {
  if (remainingTime === 0) return true; // 終了後は動かさない

  paddle.x += event.prevDelta.x;
  // パドルの移動範囲を制限
  if (paddle.x <= paddleMargin) {
    paddle.x = paddleMargin;
  } else if (paddle.x >= game.width - paddleMargin) {
    paddle.x = game.width - paddleMargin;
  }

  paddle.modified();
});

ゲームを実行してみます。 左上のタイマーがカウントダウンされていれば成功です。

実行例とソースコード

Playground で実行