スコアとタイマーの追加
ニコニコ生放送には、ニコ生ゲームでのスコアを放送者・視聴者がそれぞれ競い合い、その順位を表示してくれるランキング機能があります。 そのスコアを加算する部分のロジックを追加しましょう。
スコア加算のルール
ランキングゲームはたくさんの人が同じルールで競い合い、より高いスコアを獲得することが目的です。 一方で、ここまで作成してきたブロックくずしは「パドルを操作してブロックを破壊する」という操作しかプレイヤー側に与えられていません。 例えば「ブロックを破壊するごとにスコアを 100
を加算する」というルールにすると、全てのブロックを破壊したプレイヤー全員が同率 1 位になってしまいます。 これではゲームを競い合うモチベーションが上がりません。
したがって、スコアはなるべくプレイによってバラけることが望ましいです。 理想はすべてのプレイヤーのスコアが異なることです。 そこで今回は ボールがパドルに衝突してから次にパドルに衝突するまでの間に、より多くのブロックを破壊したらその分のボーナスを加算する というルールでスコアを加算するようにしましょう。
スコア加算処理
ニコ生ゲームではコードの先頭の方で定義されている game.vars.gameState.score
がそのままゲームのスコアとなります。 この値をスコアを加算したいタイミングで適宜加算していきます。
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 用のビットマップフォントデータを生成するツールです。
次のコマンドを実行することでインストールできます。
npm i -g @akashic/bmpfont-generator
インストールが正常に行われたかを確認するため、以下のコマンドを実行します。
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 のディレクトリへ移動して以下のコマンドを実行します。
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
をつけて実行してみましょう。 大体のコマンドにおいて、サポートする引数や使い方などを表示してくれます。
bmpfont-generator --help
assets/fonts
ディレクトリに font-number.png
と font-number_glyphs.json
が出力されていることを確認します。 font-number_glyphs.json
には Akashic Engine で利用するためのグリフ情報が格納されています。
assets
└── fonts
├── font-number.png
└── font-number_glyphs.json
再び akashic scan
コマンドを実行します。
akashic scan asset
ビットマップフォントの利用
ビットマップフォントを使えるようにコードを修正します。 ビットマップフォントを読み込ませるため assetPaths
を修正します。
const scene = new g.Scene({
game,
assetPaths: [
"/assets/images/*",
"/assets/se/*",
"/assets/fonts/*" //[!code ++]
]
});
次に g.BitmapFont
を生成します。
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
を利用します。
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
には実際に表示する文字列を指定します。 anchorX
を 1.0
に指定することで、画面の右端へ寄せるように配置しています。
TIP
`${game.vars.gameState.score}`
はテンプレートリテラルと呼ばれる構文で、文字列をバッククオート記号 `
で定義することで利用できます。 テンプレートリテラル内では ${変数名}
のように ${}
で変数を囲むと、その変数が自動的に展開された状態で文字列を解決してくれます。 ここでは game.vars.gameState.score
の値を文字列に変換しています。
ゲームを実行してみましょう。 画面の右上にスコアが表示されていれば成功です。
スコアの加算
ブロックの破壊時にスコアを加算してみましょう。 まず、スコア加算時に scoreLabel
の表示を更新する関数 updateScoreLabel()
を定義しておきましょう。
// スコア表示エンティティの作成
const scoreLabel = new g.Label({
...
});
scene.append(scoreLabel);
// スコア表示を更新する
function updateScoreLabel() {
scoreLabel.text = `${game.vars.gameState.score}`;
scoreLabel.invalidate();
}
実は scoreLabel
の text
の内容を変更しただけでは、描画内容は更新されません。 描画内容を更新するには text
の変更後 scoreLabel.invalidate()
を呼びます。
TIP
invalidate()
はキャッシュされている描画内容を更新する関数です。 g.Label
など、描画内容をキャッシュするエンティティに存在します。 これには、不要なキャッシュ更新を避けることでパフォーマンス低下を防ぐという目的があります。 描画内容はそのままで、単に座標や角度を変更するだけの場合は modified()
を呼び出してください。
ボールとブロックの衝突処理の部分でスコアを加算します。
if (isCollided) {
// 破壊可能のボールと衝突していたら
if (block.tag.mapNumber !== 1) {
block.destroy(); // ブロックを破壊
seBlock.play();
game.vars.gameState.score += 100; // スコアを加算
updateScoreLabel(); // スコア表示の更新
break; // 一度のフレームで一つのブロックのみを削除
}
}
加算した後、忘れずに updateScoreLabel()
を実行してスコア表示を更新します。
ゲームを実行してみましょう。 ブロックを破壊するたびにスコアが加算されていれば成功です。
ボーナスの実装
さて、冒頭でも説明した通り
ボールがパドルに衝突してから次にパドルに衝突するまでの間に、より多くのブロックを破壊したらその分のボーナスを加算する
というルールでスコアにボーナスを加えてみることにします。 まず「パドルに衝突してから、ブロックを破壊した個数」として bonusCount
という変数を定義します。
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
をボーナス分として加算します。
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
をリセットします。
// ボールが画面の下端に到達したとき
if (ball.y > game.height + ball.height / 2) {
isStarted = false;
vx = speed * direction[0];
vy = speed * direction[1];
seMiss.play();
bonusCount = 0;
}
同様にパドルと衝突したら bonusCount
をリセットします。
// ボールとパドルが衝突したとき
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
のようなボーナススコアを表示する方法です。 このような演出により、ボーナス加算の条件をプレイ中に気づかせることが可能になるかもしれません。
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
から取得することができます。
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 を開き、以下のように修正します。
"environment": {
"sandbox-runtime": "3",
"nicolive": {
"supportedModes": [
"ranking"
]//[!code --]
],//[!code ++]
"preferredSessionParameters": {//[!code ++]
"totalTimeLimit": 65//[!code ++]
}//[!code ++]
}
},
これでゲームの実行時間が65秒へと変更されました。
WARNING
nicolive
というキーが niconico
になっている場合、それは古い仕様のままです。 nicolive
へと修正してください。
"environment": {
"sandbox-runtime": "3",
"niconico": {//[!code --]
"nicolive": {//[!code ++]
"supportedModes": [
"ranking"
]
}
},
以上を踏まえ、スコアエンティティ作成部分の直後に次のコードを追加します。
// スコア用のビットマップフォントの作成
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()
も作成します。
// スコア表示を更新する
function updateScoreLabel() {
...
}
// タイマー表示を更新する
function updateTimer() {
timerLabel.text = `${remainingTime}`;
timerLabel.invalidate();
}
残り時間を減らす処理も追加します。
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.onPointMoveCapture
で remainingTime
を判定し、0 であれば return
させます。
INFO
scene.onPointMoveCapture
などのハンドラ内の関数で true
を返すと、そのハンドラの登録が削除されます。
// スワイプでパドルが左右に動くようにする
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();
});
ゲームを実行してみます。 左上のタイマーがカウントダウンされていれば成功です。