ゲームプログラミング初心者向けの記事です。
前回の記事では「元祖落ちゲー」の途中までを実装しました。
本稿では、次に進める前に、いったん実装の見直し(リファクタリング)をしたいと思います。
真面目に作っても、たかだか数百行程度のプログラムでソフトウェア設計を語るも何もないですが、逆に小粒の方が話がしやすいところはあります。
画面データへのアクセス関数を制限する
画面は12列x22行のマスで構成されていて、1次元配列として定義しました。
const SCREEN_W = 12
const SCREEN_H = 22
let screen = Array(SCREEN_H*SCREEN_W);
元の実装
このscreen
配列に、色んな関数から直接参照していました。図にすると下のようになります。
問題点
この構成だと、screen
の大きさ(SCREEN_W)が変わった時やscreen
を二次元配列に変えた場合に、参照している関数の実装を全て変更する必要が出てきます。
改善方法
データ構造へのアクセスは最小限の関数に限定すると、ソースのメンテナンス性は向上します。下図のような構成にリファクタリングします。
こうすることで、仮にscreen
を2次元配列にしても、isBlockHit()
、putBlock()
、drawScreen()
は変更が必要ありません。screen
の大きさ(SCREEN_W)を変更した時も同じです。
オブジェクト指向とまでは行かなくても、データ構造を隠蔽するために一層API(関数)を噛ませるのは、ソースコードのメンテナンス性を上げるのによく使われる手法です。
変更後の実装
初期化部分も合わせてinitScreen()
にまとめます。
// 画面サイズ
const SCREEN_W = 12
const SCREEN_H = 22
let screen = Array(SCREEN_H*SCREEN_W);
function putScreen(x, y, value) {
screen[x + y * SCREEN_W] = value;
}
function getScreen(x, y) {
return screen[x + y * SCREEN_W];
}
function initScreen() {
screen.fill(0);
// 画面の一番下のラインにブロックを置く
for (let i = 0; i < SCREEN_W; i++) {
putScreen(i, SCREEN_H-1, 1);
}
for (let j = 0; j < SCREEN_H-1; j++) {
putScreen(0, j, 1);
putScreen(SCREEN_W-1, j, 1);
}
}
入力ハンドラーの改善
元の実装
下記の通り、documentに登録したイベントハンドラーから、直接落下ブロックの座標blockX
を変更していました。
// 入力ハンドラー
document.addEventListener('keydown', keyDownHandler, false);
function keyDownHandler(event) {
if (event.key === 'Left' || event.key === 'ArrowLeft') {
if(!isBlockHit(blockX-1, blockY, blockShape))
blockX -= 1;
}
if (event.key === 'Right' || event.key === 'ArrowRight') {
if (!isBlockHit(blockX+1, blockY, blockShape))
blockX += 1;
}
}
問題点
具体的に起きる現象は、左右キーを押しっぱなしにするか連打すると、落下ブロックが突然ワープした感じになります。
この実装の問題点は、blockX
変数を変更するタイミングがメインループと同期していないことです。図にすると以下のようになります。
画面の描画はupdate()
関数内で行っていて、update()
関数はsetInterval()
関数で設定した周期で呼ばれます。
一方で、keyDownHandler()
はこのタイミングとは関係なく、キーが押されたときに呼ばれます。つまり、描画しない間もキーイベントが処理されるのでブロックの座標が変更されます。
改善方法
以下のような構造にします。
実装は以下のように変更します。
簡単にいうと、update()
関数の中で入力イベントをチェックして、block
変数を更新します。そして同じ流れ(スレッド)で描画をします。
こうすることで、落下ブロックの座標変更が描画と同期して、見た目がスムーズになります。
// 入力ハンドラー
let input = undefined;
document.addEventListener('keydown', (e) => input = e.key, false);
EventListener登録では、変数input
にイベントを代入する(更新する)だけとします。
function handleInputs() {
if (input === 'Left' || input === 'ArrowLeft') {
if (!isBlockHit(blockX - 1, blockY, blockShape))
blockX -= 1;
}
if (input === 'Right' || input === 'ArrowRight') {
if (!isBlockHit(blockX + 1, blockY, blockShape))
blockX += 1;
}
input = undefined; // 入力をリセット
}
・・・
function update() {
・・・
handleInputs();
・・・
}
定期的に呼ばれるupdate()
関数で、入力イベントを見にいくhandleInput()
関数を呼びます。
ブロックの落下速度を調整する
画面描画、キー入力、落下ブロックの移動、はすべてupdate()
関数の中で処理します。
update()
関数が呼ばれる周期は一定なので、この3つの状態更新も同じ周期になってしまいます。
しかし、ブロックの落下は初めのうちゆっくりで、これに合わせると入力の反応も遅くなってしまいます。
ブロックの落下の周期だけ調整できるようにしたいところです。
やり方は簡単で、ブロックの落下処理の部分だけ関数に括り出して、カウンターをつけて処理する周期を分けるようにします。以下の実装になります。
let count = 0;
function fallingBlock() {
count += 1;
if (count % 30 != 0)
return;
// ブロックを落下させる
blockY += 1;
if (blockY*BLOCK_SIZE > canvas.height) {
blockY = 0;
}
count = 0;
}
function update() {
・・・
fallingBlock();
・・・
}
fallingBlock()
関数の実装の中で、30回に1回処理するようにcountを見ている感じです。
この30という数字を直に埋めていますが、ゲーム性として時間が経つにつれで短くすることも考えられます。そういうふうに実装したい場合に変数にするとよいと思います。
update関数の更新周期
さて、もともとは 500ms毎にupdate()
関数を呼ぶようにsetInterval()で設定していました。
が、落下ブロックは周期を分けましたので、
setInterval(update, 20);
20msに一回呼ぶように改めました。
画面描画更新、入力イベント処理はこの周期で更新します。つまり、左右キーの反応が速くなりました。
更新後のソースコード
<html>
<center>
<canvas id="gameCanvas" width="240" height="440" style="border:1px solid #000000; background-color: #999;"></canvas>
<script type="text/javascript" src="index.js"></script>
</html>
const canvas = document.getElementById('gameCanvas');
const context = canvas.getContext('2d');
// ブロック
let blockX = 5;
let blockY = 0;
const BLOCK_SIZE = 20
const blockShape = [1,1,0,0,
1,1,0,0]
// 画面サイズ
const SCREEN_W = 12
const SCREEN_H = 22
let screen = Array(SCREEN_H*SCREEN_W);
function putScreen(x, y, value) {
screen[x + y * SCREEN_W] = value;
}
function getScreen(x, y) {
return screen[x + y * SCREEN_W];
}
function initScreen() {
screen.fill(0);
// 画面の一番下のラインにブロックを置く
for (let i = 0; i < SCREEN_W; i++) {
putScreen(i, SCREEN_H-1, 1);
}
for (let j = 0; j < SCREEN_H-1; j++) {
putScreen(0, j, 1);
putScreen(SCREEN_W-1, j, 1);
}
}
// 入力ハンドラー
let input = undefined;
document.addEventListener('keydown', (e) => input = e.key, false);
function isBlockHit(x, y, shape) {
for (j = 0; j < 2; j++) {
for (i = 0; i < 4; i++) {
if (shape[i+j*4]==1) {
if(getScreen(x+i, y+j) == 1)
return true;
}
}
}
return false;
}
function putBlock(x, y, shape) {
for (j = 0; j < 2; j++) {
for (i = 0; i < 4; i++) {
if (shape[i+j*4]==1)
putScreen(x+i, y+j, 1);
}
}
}
function drawRect(x,y) {
context.strokeStyle = '#00f';
context.lineWidth = 2;
context.strokeRect( x * BLOCK_SIZE, y * BLOCK_SIZE, BLOCK_SIZE, BLOCK_SIZE);
context.fillStyle = '#fff'
context.fillRect( x * BLOCK_SIZE, y * BLOCK_SIZE, BLOCK_SIZE, BLOCK_SIZE);
}
function drawBlockShape(x, y, shape) {
for (j = 0; j < 2; j++) {
for (i = 0; i < 4; i++) {
if (shape[i+j*4]==1) {
drawRect(x+i, y+j);
}
}
}
}
function drawScreen() {
for (j = 0; j < SCREEN_H; j++) {
for (i = 0; i < SCREEN_W; i++) {
if (getScreen(i,j)==1)
drawRect(i, j);
}
}
}
function handleInputs() {
if (input === 'Left' || input === 'ArrowLeft') {
if (!isBlockHit(blockX - 1, blockY, blockShape))
blockX -= 1;
}
if (input === 'Right' || input === 'ArrowRight') {
if (!isBlockHit(blockX + 1, blockY, blockShape))
blockX += 1;
}
input = undefined; // 入力をリセット
}
let count = 0;
function fallingBlock() {
count += 1;
if (count % 30 != 0)
return;
// ブロックを落下させる
blockY += 1;
if (blockY*BLOCK_SIZE > canvas.height) {
blockY = 0;
}
count = 0;
}
function update() {
context.clearRect(0, 0, canvas.width, canvas.height);
handleInputs();
fallingBlock();
// ブロックがブロックにぶつかったら積み上げる
if (isBlockHit(blockX, blockY+1, blockShape)) {
putBlock(blockX, blockY, blockShape); // ブロックを積み上げる
blockX = 5; // ブロックの位置をリセット
blockY = 0;
}
drawBlockShape(blockX, blockY, blockShape);
drawScreen();
}
initScreen();
setInterval(update, 20);
余談
「達人プログラマー」にDRY(Don't Repeat Yourself)という原則が紹介されています。
「同じことをするコードは1か所にまとめ、重複を避けることで保守性を高める」
という考え方です。
この原則を頭に浮かべた時に、今回の実装で非常に気になるところがあります。
落下ブロックの形をなめていくforループ文です。
isBlockHit()
、putBlock()
、drawBlockShape()
の3箇所で出てきます。
for (j = 0; j < 2; j++) {
for (i = 0; i < 4; i++) {
if (shape[i+j*4]==1) {
・・・
}
}
}
配列のサイズは違いますがdrawScreen()
も同じようなループです。
さて、これをどうしましょう?
LISPのマクロとかがあれば、それを使ってしまうところです。
がJavascriptにはないので、関数オブジェクトを渡して似たようなことをすることくらいしか思いつきません。
例えば、以下のような実装になります。
function doFuncAsBlockShape(shape, w, h, func) {
for (j = 0; j < h; j++) {
for (i = 0; i < w; i++) {
if (shape[i+j*4]==1) {
func(i, j);
}
}
}
}
function isBlockHit(x, y, shape) {
let ret = false;
doFuncAsBlockShape(shape, 2, 4, (i, j) => {
if (getScreen(x+i, y+j) == 1) {
ret = true;
}
});
return ret;
}
function putBlock(x, y, shape) {
doFuncAsBlockShape(x, y, shape, (i, j) => {
setScreen(i, j, 1);
});
}
function drawBlockShape(x, y, shape) {
doFuncAsBlockShape(x, y, shape, (i, j) => {
drawRect(i, j);
});
}
あまりトリッキーなことをすると、あとで解読が必要になってしまうワナに陥ってしまいます。
可読性が下がっては意味がないので、やめにしました。