ゲームを作ってみよう


この文書は 2018 年当時のものです。

最新のニコ生ゲーム作成については https://akashic-games.github.io/shin-ichiba/ をご参照ください。


本ページの内容

本ページは、2018年10月25日~2018年12月14日(結果発表は2019年01月26日)までかけて行われる(行われた)、ニコニコ自作ゲームフェス新人賞 「実験放送ゲーム部門」に合わせて制作された特集記事の第3回目です。

はじめに

ドワンゴの、主に実験放送のコンテンツ制作に携わっているエンジニアのツゲハラと申します。

実験放送でニコニコ新市場を通して利用できるコンテンツ群(以後ニコニコ新市場対応コンテンツ)が、2018年10月25日より自分達で作れるようになりました。

皆様がスムーズに制作に挑める助力になればと、制作ガイドとなりそうな記事を公開させていただいております。この記事は、この一連の記事の第3回になります。第2回同様、記事の対象はプログラミング経験者向きとなります。

ニコニコ新市場対応コンテンツについてのご不明点などがあれば、お気軽にお問合せください。

準備

第1回第2回を参考に、Akashicの環境の用意と新規プロジェクトの作成を進めてください。 解像度やFPSは前回と同様、640x360の30fpsで作成してください。

ここから先の記事では、c:\akashic\gameというフォルダに今回作成するゲームがあることを前提に記述を進めていきます。

さて、ゲームを作るからには、まずは何のゲームを作るかを決めなくてはなりません。

実験放送のゲームはランキングで動かす事ができるので、「スコア」が必要です。スコアを取り扱うからには「文字」を扱う必要もあります。 また、ある程度短い時間で終わり、視聴者と一緒に遊べるようにするのが望ましいでしょう。

演出を凝るとコード量が増えてしまうので、その辺りは妥協して進める事にします。 スコアと制限時間のある、フォントを使った、出来る限り簡単なゲーム、というお題目で進めていきたいと思います。

フォントの準備

スコアや時間の概念を扱う必要があるので、これらを画面に表示するためにはこれまで扱わなかった文字列を取り扱う必要があります。

AkashicのサンプルにはDynamicFontを扱う方法が載っているのですが、このDynamicFontを普通に扱ってもあまりゲームらしい表示になりません。

せっかくなので、ファミコンっぽい表示にするためにビットマップフォントというものを使いたいと思います。Akashicの公式サイトが用意している素材から「ビットマップフォント ダウンロード」を選択し、font.zipをダウンロードしてください。

いくつかのフォントを入手できますが、映像の上に重ねる実験放送ゲームでも十分な視認性を持ちそうなfont16_1.pngを使いたいと思います。

font16_1.png

font16_1.pngimageフォルダに、glyph_area_16.jsontextフォルダに配置し、CUIツールを起動してc:\akashic\gamecdコマンドで移動した後、以下のコマンドを実行してください。

akashic scan asset

これでフォント画像等の登録が完了します。

ビットマップフォントを取り扱う公式のサンプルは以下になります。

サンプルで提供されるファイルのフォーマットがこちらのサンプルと少し違うので、若干のアレンジが必要です。最終的なコードは以下のようになります。

function main(param) {
	var scene = new g.Scene({game: g.game, assetIds: ["font16_1", "glyph_area_16"]});

	scene.loaded.add(function() {
		// glyphとfontを指定
		var glyph = JSON.parse(scene.assets["glyph_area_16"].data);
		var font = new g.BitmapFont({
			src: scene.assets["font16_1"],
			map: glyph,
			defaultGlyphWidth: 16,
			defaultGlyphHeight: 16
		});
		// スコア表示用ラベルを配置
		var scoreLabel = new g.Label({
			scene: scene,
			font: font,
			fontSize: 16,
			text: "1234567890",
			x: g.game.width - 16 * 10,  // 右端に10文字くらい表示できるように配置
			y: 0
		});
		scene.append(scoreLabel);

		// 時間表示用ラベルを配置
		var timerLabel = new g.Label({
			scene: scene,
			font: font,
			fontSize: 16,
			text: "1234567890",
			x: 0,   // 左端に配置
			y: 0
		});
		scene.append(timerLabel);
	});

	g.game.pushScene(scene);
}

module.exports = main;

順に見ていきましょう。

まずは第2回でやったように、Sceneで利用するassetIdsを指定します。

var scene = new g.Scene({game: g.game, assetIds: ["font16_1", "glyph_area_16"]});

読み込んだassetIdを用いて、glyph(グリフ)というものを作成し、作成したglyphを用いて、fontを生成します。この辺りの詳細な説明はこの記事では省略します。

// glyphとfontを指定
var glyph = JSON.parse(scene.assets["glyph_area_16"].data);
var font = new g.BitmapFont({
	src: scene.assets["font16_1"],
	map: glyph,
	defaultGlyphWidth: 16,
	defaultGlyphHeight: 16
});

あとはそのfontを利用して、ラベルを配置します。後で使うように、得点表示用のラベルと、時間表示用のラベルをそれぞれ右と左に配置するようにしました。

// スコア表示用ラベルを配置
var scoreLabel = new g.Label({
	scene: scene,
	font: font,
	fontSize: 16,
	text: "1234567890",
	x: g.game.width - 16 * 10,  // 右端に10文字くらい表示できるように配置
	y: 0
});
scene.append(scoreLabel);
 
// 時間表示用ラベルを配置
var timerLabel = new g.Label({
	scene: scene,
	font: font,
	fontSize: 16,
	text: "1234567890",
	x: 0,   // 左端に配置
	y: 0
});
scene.append(timerLabel);

ここまでのコードを書いてakashic-sandboxで実行すると、以下のような表示になっているのが確認できます。これで、フォントの利用準備は完了です。

ラベルのみの表示

制限時間の処理

下準備も終わったので、ゲームを作っていくため、まずはゲームを作るためのガイドを参考にしましょう。

重要なのは「ランキング対応ゲーム」の項目です。スコアと制限時間は最低限対応しないとランキング対応に支障が出そうなので、ここから対応していく事にします。

制限時間は設定された内容が外部から渡されます。これを扱うためのソースコードとして、ガイドには以下の内容が載っています。

var scene = new g.Scene({ game: g.game });

// 何も送られてこない時(ニコニコ新市場以外で起動された場合)は、この値がタイムリミットになる
var totalTimeLimit = 60;

scene.message.add(function (msg) {
	if (msg.data && msg.data.type === "start") {
		// type: "start" でも parameters などが与えられないこともあるので存在を確認
		if (msg.data.parameters && msg.data.parameters.totalTimeLimit) {
			// 制限時間は `totalTimeLimit` 秒
			totalTimeLimit = msg.data.parameters.totalTimeLimit;
		}
	}
});

scene.loaded.add(function () {
	// 通常のゲームとしての初期化処理
	// ...
}

このコードを今回のコードに適用すると、アツマール等では60秒制限で、ランキングの時は外部から渡されるパラメータで動作するようになります。デフォルトを60秒のままにするか等は自由なので、適時変更してください。

ただ、このゲームの制限時間はリンク先の文書にも言及があるように、「あくまでも目安」とされており、ゲームのロード時間等を加味して少し短く取り扱うべきです。今回は、7秒短く取り扱う事にしました。

※注: この記事の内容は、以前 gameTimeLimit + 25 という内容で記載されていましたが、 totalTimeLimit というパラメータが推奨仕様になった事に合わせて 2018年11月28日に記述内容が改訂されています。

var scene = new g.Scene({game: g.game, assetIds: ["font16_1", "glyph_area_16"]});
var gameTimelimit = 60; // デフォルトを60秒にする

scene.message.add(function(msg) {
	if (msg.data && msg.data.type === "start" && msg.data.parameters && msg.data.parameters.totalTimeLimit) {
		// 制限時間を通知するイベントを受信した時点で初期化する
		// ゲームのローディング時間を考慮し、7秒短くする
		gameTimeLimit = msg.data.parameters.totalTimeLimit - 7;
	}
});

あとは、この秒数が何秒残っているかを計算すればいいのですが、制限時間が途中で変更になることも加味して単純にこの値を減算するのではなく、「経過時間をカウントし、制限時間からそれを差し引いて表示する」という形で実装します。

コード全体としては以下のようになります。

function main(param) {
	var scene = new g.Scene({game: g.game, assetIds: ["font16_1", "glyph_area_16"]});
	var gameTimeLimit = 60; // デフォルトを60秒にする
	var frameCount = 0; // 経過時間をフレーム単位で記録

	scene.message.add(function(msg) {
		if (msg.data && msg.data.type === "start" && msg.data.parameters && msg.data.parameters.totalTimeLimit) {
			// 制限時間を通知するイベントを受信した時点で初期化する
			// ゲームのローディング時間を考慮し、7秒短くする
			gameTimeLimit = msg.data.parameters.totalTimeLimit - 7;
		}
	});

	scene.loaded.add(function() {
		// glyphとfontを指定
		var glyph = JSON.parse(scene.assets["glyph_area_16"].data);
		var font = new g.BitmapFont({
			src: scene.assets["font16_1"],
			map: glyph,
			defaultGlyphWidth: 16,
			defaultGlyphHeight: 16
		});
		// スコア表示用ラベルを配置
		var scoreLabel = new g.Label({
			scene: scene,
			font: font,
			fontSize: 16,
			text: "1234567890",
			x: g.game.width - 16 * 10,  // 右端に10文字くらい表示できるように配置
			y: 0
		});
		scene.append(scoreLabel);

		// 時間表示用ラベルを配置
		var timerLabel = new g.Label({
			scene: scene,
			font: font,
			fontSize: 16,
			text: "",
			x: 0,   // 左端に配置
			y: 0
		});
		scene.append(timerLabel);

		function updateTimerLabel() {
			var s = countDown();
			var text = s / 10 + (s % 10 === 0 ? ".0" : "");
			if (timerLabel.text != text) {
				timerLabel.text = text;
				timerLabel.invalidate();
			}
		}
		function countDown() {
			return Math.floor(gameTimeLimit * 10 - frameCount / g.game.fps * 10);
		}

		function updateHandler() {
			// フレーム数を毎フレーム加算
			++frameCount;
			if (countDown() <= 0) {
				// TODO: 終了時処理
				scene.update.remove(updateHandler); // タイムアウトになったら毎フレーム処理自体を止める
			}
			updateTimerLabel();
		}
		scene.update.add(updateHandler);
	});

	g.game.pushScene(scene);
}

module.exports = main;

まずはsceneupdateトリガーを利用して、毎フレーム処理を行う処理を作ります。

経過時間の計算のためここでフレームカウントを加算し、ラベルを表示するという処理を書きます。ついで、タイムアウトになったら処理を止めるところも書いてしまいます。

function updateHandler() {
	// フレーム数を毎フレーム加算
	++frameCount;
	if (countDown() <= 0) {
		// TODO: 終了時処理
		scene.update.remove(updateHandler); // タイムアウトになったら毎フレーム処理自体を止める
	}
	updateTimerLabel();
}
scene.update.add(updateHandler);

以下は残り時間をカウントする関数です。1秒単位の更新だと画面更新が少ないので、0.1秒単位で取り扱えるようにしました。

function countDown() {
	return Math.floor(gameTimeLimit * 10 - frameCount / g.game.fps * 10);
}

以下はラベルの更新処理です。30fpsにつき0.1秒単位でしか更新されないので、テキストを事前生成して変化があったら更新にしています。また、「.0秒」を表示するために少し工夫をしています。

function updateTimerLabel() {
	var s = countDown();
	var text = s / 10 + (s % 10 === 0 ? ".0" : "");
	if (timerLabel.text != text) {
		timerLabel.text = text;
		timerLabel.invalidate();
	}
}

画像としては前節と同じなので省略しますが、これで、制限時間の処理は完成です。

ゲーム本体の実装

さて、いよいよゲーム本体です。フォントだけで作れるゲームにしたいので、以下のルールで作っていこうと思います。

  • 画面上の10か所の場所にABCDEのどれかが表示される
  • 空いている場所には1秒につきランダムで1文字追加される
  • 空きが一つもない時、文字をタップできる
  • タップされた文字と同種の文字が消える
  • 同時に消した文字の数が多い程高得点

ゲームですのでそれなりのコード量になります。全コードはこちら。

function main(param) {
	var scene = new g.Scene({game: g.game, assetIds: ["font16_1", "glyph_area_16"]});
	var gameTimeLimit = 60; // デフォルトを60秒にする
	var frameCount = 0; // 経過時間をフレーム単位で記録

	scene.message.add(function(msg) {
		if (msg.data && msg.data.type === "start" && msg.data.parameters && msg.data.parameters.gameTimeLimit) {
			// 制限時間を通知するイベントを受信した時点で初期化する
			// ゲームのローディング時間を考慮し、7秒短くする
			gameTimeLimit = msg.data.parameters.totalTimeLimit - 7;
		}
	});

	scene.loaded.add(function() {
		// glyphとfontを指定
		var glyph = JSON.parse(scene.assets["glyph_area_16"].data);
		var font = new g.BitmapFont({
			src: scene.assets["font16_1"],
			map: glyph,
			defaultGlyphWidth: 16,
			defaultGlyphHeight: 16
		});
		// スコア表示用ラベルを配置
		var scoreLabel = new g.Label({
			scene: scene,
			font: font,
			fontSize: 16,
			text: "1234567890",
			x: g.game.width - 16 * 10,  // 右端に10文字くらい表示できるように配置
			y: 0
		});
		scene.append(scoreLabel);

		// 時間表示用ラベルを配置
		var timerLabel = new g.Label({
			scene: scene,
			font: font,
			fontSize: 16,
			text: "",
			x: 0,   // 左端に配置
			y: 0
		});
		scene.append(timerLabel);

		function updateTimerLabel() {
			var s = countDown();
			var text = s / 10 + (s % 10 === 0 ? ".0" : "");
			if (timerLabel.text != text) {
				timerLabel.text = text;
				timerLabel.invalidate();
			}
		}
		function countDown() {
			return Math.floor(gameTimeLimit * 10 - frameCount / g.game.fps * 10);
		}
		var places = [];
		var placeContainer = new g.E({
			scene: scene,
			x: (g.game.width - 5 * 64) / 2,
			y: 100
		});
		scene.append(placeContainer);

		// ランダムに1文字選ぶ処理
		function pickAlpha() {
			switch (g.game.random.get(0, 4)) {
				case 0:
				return "A";
				case 1:
				return "B";
				case 2:
				return "C";
				case 3:
				return "D";
				case 4:
				return "E";
				default:
				throw new Error("invalid random number");
			}
		}
		function breakAlpha(e) {
			for (var i = 0; i < places.length; i++) {
				// 一つでも空きスペースがあれば処理を中断
				if (places[i].tag.text === "") return;
			}
			var targetText = e.target.tag.text;
			for (var i = 0; i < places.length; i++) {
				if (places[i].tag.text == targetText) {
					// テキストが合致したら壊す
					updatePlaceText(places[i], "");
				}
			}
		}
		function updatePlaceText(place, text) {
			// 内部のデータを更新する
			place.tag.text = text;
			// children[0]はLabelなので、Labelのtextも更新する
			place.children[0].text = text;
			place.children[0].invalidate();
		}
		// 置き場所を作る処理
		function createPlace(index) {
			var label = new g.Label({
				scene: scene,
				font: font,
				fontSize: 48,
				text: "",
				x: 8,
				y: 8
			});
			var place = new g.FilledRect({
				scene: scene,
				x: index % 5 * 64,
				y: Math.floor(index / 5) * 64,
				width: 64,
				height: 64,
				tag: {
					text: ""
				},
				cssColor: index % 2 === 0 ? "#ccc" : "#ddd",
				touchable: true
			});
			place.append(label);
			place.pointDown.handle(breakAlpha);
			return place;
		}
		// 10個の場所を作る
		for (var i = 0; i < 10; i++) {
			var place = createPlace(i);
			placeContainer.append(place);
			places.push(place);
		}
		function visitAlpha() {
			places.forEach(function(place) {
				// テキストが空のもののみ更新をかける
				if (place.tag.text === "") {
					updatePlaceText(place, pickAlpha());
				}
			});
		}
		function tryVisitAlpha() {
			// 1秒に1回
			if (frameCount % g.game.fps === 0) {
				visitAlpha();
			}
		}

		function updateHandler() {
			// フレーム数を毎フレーム加算
			++frameCount;
			if (countDown() <= 0) {
				scene.update.remove(updateHandler); // タイムアウトになったら毎フレーム処理自体を止める
			}
			tryVisitAlpha();
			updateTimerLabel();
		}
		scene.update.add(updateHandler);
	});

	g.game.pushScene(scene);
}

module.exports = main;

まずは画面上の10か所の場所に文字を表示する、という要件を満たすため、placeという概念を導入します。

以下のコードで、表示上の入れ物としてplaceContainerを、単純にplaceの配列を管理するためplacesという配列を作成しています。

var places = [];
var placeContainer = new g.E({
	scene: scene,
	x: (g.game.width - 5 * 64) / 2,
	y: 100
});
scene.append(placeContainer);

placeは10個なので、for文で10のplaceを作成し、表示用のコンテナと配列にそれぞれ追加します。

// 10個の場所を作る
for (var i = 0; i < 10; i++) {
	var place = createPlace(i);
	placeContainer.append(place);
	places.push(place);
}

次に実際にplaceを作成する関数を定義します。

ここがこのゲームを構成する表示要素の本体になりますが、placeFilledRectで背景色を持ち、その中にLabelで対応する文字を表示することで表現します。

「タップされた文字と同種の文字が消える」という要件を満たすため、FilledRecttocuhable: trueの引数を与え、タッチも可能なエンティティとして作成しています。

// 置き場所を作る処理
function createPlace(index) {
	var label = new g.Label({
		scene: scene,
		font: font,
		fontSize: 48,
		text: "",
		x: 8,
		y: 8
	});
	var place = new g.FilledRect({
		scene: scene,
		x: index % 5 * 64,
		y: Math.floor(index / 5) * 64,
		width: 64,
		height: 64,
		tag: {
			text: ""
		},
		cssColor: index % 2 === 0 ? "#ccc" : "#ddd",
		touchable: true
	});
	place.append(label);
	place.pointDown.handle(breakAlpha);
	return place;
}

初期状態は空文字なので、このままだとplaceは空文字列が表示され続けてしまいます。「空いてる場所に1秒につきランダムで1文字表示される」という要件を実現する為、updateの処理の中に毎フレームtryVisitAlpha関数の呼び出し処理を入れます。

tryVisitAlphaで「1秒に1回」の要件を経過フレーム数を見る事で管理し、内部で呼び出すvisitAlpha関数で「空いている場所」の判定をしつつ、pickAlpha関数で「ランダムで1文字」の処理を記述します。

そうして選ばれた文字を表示に反映するため、updatePlaceText関数も用意します。要素が多いですが、以下でまとめてみていきます。

// ランダムに1文字選ぶ処理
function pickAlpha() {
	switch (g.game.random.get(0, 4)) {
		case 0:
		return "A";
		case 1:
		return "B";
		case 2:
		return "C";
		case 3:
		return "D";
		case 4:
		return "E";
		default:
		throw new Error("invalid random number");
	}
}
// ~中略
function updatePlaceText(place, text) {
	// 内部のデータを更新する
	place.tag.text = text;
	// children[0]はLabelなので、Labelのtextも更新する
	place.children[0].text = text;
	place.children[0].invalidate();
}
// ~中略
function visitAlpha() {
	places.forEach(function(place) {
		// テキストが空のもののみ更新をかける
		if (place.tag.text === "") {
			updatePlaceText(place, pickAlpha());
		}
	});
}
function tryVisitAlpha() {
	// 1秒に1回
	if (frameCount % g.game.fps === 0) {
		visitAlpha();
	}
}

こうして作られたplaceをタップすると、breakAlphaという関数が呼び出されます。

要件は「空き1つもない時、文字をタップできる」なのでこの条件判定を行い、「タップされた文字と同種の文字が消える」を実現する為、もう一度for文で回して複数個をまとめて削除しています。

function breakAlpha(e) {
	for (var i = 0; i < places.length; i++) {
		// 一つでも空きスペースがあれば処理を中断
		if (places[i].tag.text === "") return;
	}
	var targetText = e.target.tag.text;
	for (var i = 0; i < places.length; i++) {
		if (places[i].tag.text == targetText) {
			// テキストが合致したら壊す
			updatePlaceText(places[i], "");
		}
	}
}

これで、時間計算され、文字がランダムに表示され、同種の文字を同時消しするゲームらしきものが動くようになりました。

ゲームらしきもの

スコアの実装

ゲームをゲームとして成立させるためには色々な条件があると思いますが、今回はランキングに対応させるので「スコア」の対応が必要です。

要件は「同時に消した文字の数が多い程高得点」です。

スコアの決め方はなかなか奥深いのですが、今回は簡単に基準スコアを10とし、同時消し数を掛け算する式で作ります。一つ消すごとにスコアが入る事にするので、2個消しの場合は1個消しの点数 + 2個消しの点数という形です。

同時消し数 単体の点 合計の点数
1個 10 10
2個 20 30
3個 30 60
4個 40 100
5個 50 150
6個 60 210
7個 70 280
8個 80 360
9個 90 450
10個 100 550

表示されるものはランダムなのでそこまで戦略性はないですが、「多く表示されているものを消せば高得点」で「もう少し残すか今消すかは各自の判断」という感じになり、多少ゲーム性はある形になります。

60秒だと理論上の限界として58回消せるので、31,900点が最高になりますが、それはありえないので3個消しを58回連続でやって3,480点とれたらそれなり、上手くやると4個消し5,800点くらいもありうる、くらいのバランスになるのではないでしょうか。

もう少し凝るなら、某落ち物ゲームのように次に表示されるものなどを表示するなどすると上手い人のスコアがより伸びそうですが、サンプルなので簡単にいきましょう。

スコア対応をするため、改めて公式の文書を眺めます。

要約としては、g.game.vars.gameState.scoreにスコアが入っていればよいという事が書いてあります。

スコアをここに格納し、先ほど作ったスコアのラベルに反映し、式の通りに計算すればよさそうです。

コード全体は、こちらになります。

function main(param) {
	var scene = new g.Scene({game: g.game, assetIds: ["font16_1", "glyph_area_16"]});
	var gameTimeLimit = 60;	// デフォルトを60秒にする
	var frameCount = 0;	// 経過時間をフレーム単位で記録
	// ゲームスコアの初期化
	g.game.vars.gameState = {
		score: 0
	};

	scene.message.add(function(msg) {
		if (msg.data && msg.data.type === "start" && msg.data.parameters && msg.data.parameters.gameTimeLimit) {
			// 制限時間を通知するイベントを受信した時点で初期化する
			// ゲームのローディング時間を考慮し、7秒短くする
			gameTimeLimit = msg.data.parameters.totalTimeLimit - 7;
		}
	});

	scene.loaded.add(function() {
		// glyphとfontを指定
		var glyph = JSON.parse(scene.assets["glyph_area_16"].data);
		var font = new g.BitmapFont({
			src: scene.assets["font16_1"],
			map: glyph,
			defaultGlyphWidth: 16,
			defaultGlyphHeight: 16
		});
		// スコア表示用ラベルを配置
		var scoreLabel = new g.Label({
			scene: scene,
			font: font,
			fontSize: 16,
			text: "" + g.game.vars.gameState.score,
			x: g.game.width - 16 * 10,	// 右端に10文字くらい表示できるように配置
			y: 0
		});
		scene.append(scoreLabel);

		// 時間表示用ラベルを配置
		var timerLabel = new g.Label({
			scene: scene,
			font: font,
			fontSize: 16,
			text: "",
			x: 0,	// 左端に配置
			y: 0
		});
		scene.append(timerLabel);

		function updateTimerLabel() {
			var s = countDown();
			var text = s / 10 + (s % 10 === 0 ? ".0" : "");
			if (timerLabel.text != text) {
				timerLabel.text = text;
				timerLabel.invalidate();
			}
		}
		function updateScoreLabel() {
			scoreLabel.text = "" + g.game.vars.gameState.score;
			scoreLabel.invalidate();
		}
		function countDown() {
			return Math.floor(gameTimeLimit * 10 - frameCount / g.game.fps * 10);
		}
		var places = [];
		var placeContainer = new g.E({
			scene: scene,
			x: (g.game.width - 5 * 64) / 2,
			y: 100
		});
		scene.append(placeContainer);

		// ランダムに1文字選ぶ処理
		function pickAlpha() {
			switch (g.game.random.get(0, 4)) {
				case 0:
				return "A";
				case 1:
				return "B";
				case 2:
				return "C";
				case 3:
				return "D";
				case 4:
				return "E";
				default:
				throw new Error("invalid random number");
			}
		}
		function calculateScore(bonus) {
			return 10 * bonus;
		}
		function breakAlpha(e) {
			for (var i = 0; i < places.length; i++) {
				// 一つでも空きスペースがあれば処理を中断
				if (places[i].tag.text === "") return;
			}
			var targetText = e.target.tag.text;
			var bonusCount = 1;
			for (var i = 0; i < places.length; i++) {
				if (places[i].tag.text == targetText) {
					// テキストが合致したら壊す
					updatePlaceText(places[i], "");
					// スコアを連鎖回数分加算
					g.game.vars.gameState.score += calculateScore(bonusCount++);
					updateScoreLabel();
				}
			}
		}
		function updatePlaceText(place, text) {
			// 内部のデータを更新する
			place.tag.text = text;
			// children[0]はLabelなので、Labelのtextも更新する
			place.children[0].text = text;
			place.children[0].invalidate();
		}
		// 置き場所を作る処理
		function createPlace(index) {
			var label = new g.Label({
				scene: scene,
				font: font,
				fontSize: 48,
				text: "",
				x: 8,
				y: 8
			});
			var place = new g.FilledRect({
				scene: scene,
				x: index % 5 * 64,
				y: Math.floor(index / 5) * 64,
				width: 64,
				height: 64,
				tag: {
					text: ""
				},
				cssColor: index % 2 === 0 ? "#ccc" : "#ddd",
				touchable: true
			});
			place.append(label);
			place.pointDown.handle(breakAlpha);
			return place;
		}
		// 10個の場所を作る
		for (var i = 0; i < 10; i++) {
			var place = createPlace(i);
			placeContainer.append(place);
			places.push(place);
		}
		function visitAlpha() {
			places.forEach(function(place) {
				// テキストが空のもののみ更新をかける
				if (place.tag.text === "") {
					updatePlaceText(place, pickAlpha());
				}
			});
		}
		function tryVisitAlpha() {
			// 1秒に1回
			if (frameCount % g.game.fps === 0) {
				visitAlpha();
			}
		}

		function updateHandler() {
			// フレーム数を毎フレーム加算
			++frameCount;
			if (countDown() <= 0) {
				scene.update.remove(updateHandler);	// タイムアウトになったら毎フレーム処理自体を止める
				// 終了後はタップできないよう、テキストを空にしておく
				places.forEach(function(place) {
					updatePlaceText(place, "");
				});
			}
			tryVisitAlpha();
			updateTimerLabel();
		}
		scene.update.add(updateHandler);
	});

	g.game.pushScene(scene);
}

module.exports = main;

まずはスコアの初期化処理です。0点からスタートにします。

// ゲームスコアの初期化
g.game.vars.gameState = {
	score: 0
};

スコア表示を更新する関数も作っておきます。これは毎フレーム呼び出すものでもないので、同値の場合に更新を省略する等の処理は省略しています。

function updateScoreLabel() {
	scoreLabel.text = "" + g.game.vars.gameState.score;
	scoreLabel.invalidate();
}

最後に、先ほど作成したbreakAlpha関数の中で、文字の削除に成功した場合にスコア加算とラベルの更新を行います。

calculateScoreというスコア計算用の関数も作っていますが、今回は数式がシンプルなので、コード量も少なめに済んでいます。


function calculateScore(bonus) {
	return 10 * bonus;
}
function breakAlpha(e) {
	for (var i = 0; i < places.length; i++) {
		// 一つでも空きスペースがあれば処理を中断
		if (places[i].tag.text === "") return;
	}
	var targetText = e.target.tag.text;
	var bonusCount = 1;
	for (var i = 0; i < places.length; i++) {
		if (places[i].tag.text == targetText) {
			// テキストが合致したら壊す
			places[i].tag.updateText("");
			// スコアを連鎖回数分加算
			g.game.vars.gameState.score += calculateScore(bonusCount++);
			updateScoreLabel();
		}
	}
}

大分、それっぽくなったのではないでしょうか。

ゲーム

アツマールへの投稿

もっと作りこんでもよいのですが、今回はこの辺りでアツマールに投稿しようと思います。

ゲームですのでランキングで実行することができるように指定します。

改めて公式の文書を眺めると、game.jsonsupportedModesrankingという文字列があればよいようです。

supportedModesが追加になっている以外はほぼ第2回と変わりませんが、今回のgame.jsonはこちらになります。

{
	"width": 640,
	"height": 360,
	"fps": 30,
	"main": "./script/main.js",
	"assets": {
		"main": {
			"type": "script",
			"path": "script/main.js",
			"global": true
		},
		"target": {
			"type": "image",
			"width": 64,
			"height": 64,
			"path": "image/target.png"
		},
		"font16_1": {
			"type": "image",
			"width": 256,
			"height": 96,
			"path": "image/font16_1.png"
		},
		"glyph_area_16": {
			"type": "text",
			"path": "text/glyph_area_16.json"
		}
	},
	"environment": {
		"sandbox-runtime": "2",
		"niconico": {
			"supportedModes": [
				"single",
				"ranking"
			]
		}
	}
}

あとは第2回同様、アイコンなどアツマール投稿に必要な情報を集めて投稿し、ニコニコ新市場に登録申請をすれば出来上がりです。

今後について

今回は簡単?なゲームを作成しました。

さすがにゲームを作るには、スコア表示等の最低限必要な要素があるのでコードの量が増えますが、一度定型化してしまえば制作の難易度はそこまで高くない事がご理解いただけると思います。

タイトルを入れたい、スコア発表の場面を作りたい、BGMやSEを入れたい等色々とあるとは思いますが、3回の講座で早めぐりで紹介させていただいた今回の連載では取り扱えませんでした。

今後は著者も交代制にしつつ、不定期連載で今回取り上げられなかった要素について触れていきます。講座の更新は、公式のTwitterアカウントで発表させていただきます。

長い記事にお付き合いいただきありがとうございました。

改造、ツール、ゲームを問わず、皆様の投稿をお待ちしております。


本記事でご紹介したソースコードは、すべて以下のURLで公開されています。素材、ソースコードいずれも二次利用が可能ですので、ご活用ください。