マリオのステージ1の実装(プログラミングでマリオを作る53回(最終回?))

今回は、いよいよこれを書いた目標でもあるステージ1の実装を行います。

いままで作ってきた細かい部品を組み合わせて、ステージ1を作ります。

実装結果ですが今回は、動画が長くなるので、gifではなくyoutubeにアップしました。

組み合わせるはずでよかったはずが、結構まだ作っていなかった項目があったので、それらを実装する必要があります。

細かいところで、修正しないといけないところが、山ほどあったので、
そこはgithubで見てもらうとして、大きな変更点をまとめて、解説したいと思います。

今回実装する主な内容

1.ファミコン版マリオのstage1の内容をコピーする

敵の出現位置やマップチップの座標も同じように設定します。

2.スターブロック・連続コインブロックの作成

ブロックから出現させる処理を書いていなかったので、書く必要があります。

3.敵を位置によって動かすようにする処理

今ままでは、ゲーム開始と同時に敵が動いていたので、マリオが一定の位置以内に来た時のみ敵を動かすようにします。

4.穴に落ちたら死亡させる処理

これも実装していませんでした。

5.マップチップから出現するアイテムの移動方向を決定できるようにする

6.隠しブロックからの1upキノコ出現処理

7.敵の出現位置の調整と初期化

8.最大描画マップチップの変更

9.縦長土管を描画できるようにする

結構実装しないといけない内容があるので、分けようかと思ったんですが、
一気に実装しちゃいます。

実装

解説しないといけないだろうところだけ、解説します。

2.連続コインブロックの実装

連続コインブロックは最初にブロックを叩いてから、一定時間経つと、
普通のブロックに代わるようになります。

ブロック用の変数はマリオクラスに持たせていたので、同じようにマリオクラスに、連続コインブロック用のタイマーを設定します。

mario.js

連続コインブロック用のタイマーとフラグ用のメンバー変数を追加します。

// chapter53
// 連続コインの時間
this.coinTimer = 0;
this.onCoinTimer = false;

ブロックの当たり判定箇所にて、連続コインブロック用のマップチップ処理を追加します。

マップチップ用の画像にブロックを追加して、それを連続コインブロックとします。

初回に連続コインブロックを叩い時にタイマーを作動させて、
タイマーが切れていた時は、ブロックを空にします。

// 連続コインブロックだった場合
if(isSequenceCoinBlock(map[this.upMapY][mapsX[i]])){
	// 初回チェック
	if(!this.onCoinTimer){
		this.setBlockCoinTimer(true);
	}
	else{
		// タイマーが切れているか
		if(this.isOverCoinTimer()){
			// フラグを解除する
			this.setBlockCoinTimer(false);
			// ボックスを空にする
			replaceEmptyBoxMap(map,mapsX[i],this.upMapY);							
		}
	}
	// コインブロック用のアニメーションをセットする
	var coinX = mapsX[i] * MAP_SIZE;
	// 一つ上にセットする
	var coinY = (this.upMapY - 1) * MAP_SIZE - 16;
	this.setBlockCoinMapAnim(i,coinX,coinY);
	// ブロックも動かす
	this.blockMoveAction(mapsX[i],this.upMapY,map);
	// coinの取得
	this.getCoin();
}

ブロックを動かす用の関数

/**
 * チビマリオがブロックに頭突きした際にブロックを上下に動かす
 * @param {*} mapIndexX 
 * @param {*} mapIndexY 
 * @param {*} map 
 */
Mario.prototype.blockMoveAction = function(mapIndexX,mapIndexY,map){
	// 対象のマップチップ座標を代入
	this.blockAttackIndexX[this.blockAttackIndex] = mapIndexX;
	this.blockAttackIndexY[this.blockAttackIndex] = mapIndexY;
	this.isBlockUp[this.blockAttackIndex] = true;	// 上昇フラグon
	this.blockUpX[this.blockAttackIndex] = mapIndexX * MAP_SIZE;
	this.blockUpY[this.blockAttackIndex] = mapIndexY * MAP_SIZE;
	this.blockAttackAddY[this.blockAttackIndex] = 8;
	// animationフラグとして利用
	this.blockAttackCnt[this.blockAttackIndex]++;
	// 対象のブロック
	if(++this.blockAttackIndex >= MAX_MAP_BLOCK)this.blockAttackIndex = 0;
}

タイマー用とタイマーを発動させる関数

/**
 * コインタイマーを更新する
 */
Mario.prototype.updateCoinTimer = function(){
	if(this.onCoinTimer){
		this.coinTimer++;
	}
}

/**
 * ブロックコインタイマーが終わったかどうかのフラグ
 */
Mario.prototype.isOverCoinTimer = function(){
	if(this.coinTimer >= 180){
		return true;
	}
	return false;
}

4.穴に落ちたら残機を減らす処理

この処理も同様mario.jsに書きます。

落下位置まで来たら、死亡フラグを立てるのみです。

/**
 * 落下判定を行う
 * 落下したら死亡判定を立てる
 */
Mario.prototype.isFall = function(){
	if(this.posY >= 512){
		this.setDeadParam();
	}
}

この関数を更新関数で呼べば処理の完成です。

5.マップチップから出現するアイテムの移動方向を決定できるようにする

マリオステージ1では、マップチップによって、キノコなどのオブジェクトが右に移動するか左に移動するか、決まっているので、そのための処理を書く必要があります。

ステージ1の隠し1upキノコは右に移動するようになっています。

実装方法はいつものように、キノコだったら、2つマップチップを用意して、
片方が左に出現、一方が右に出現させるマップチップということにしました。

新しく叩いたマップチップがどの方向から出現するか返す関数をcollisionUtil.jsに書きます。

collisionUtil.js

一例として、スターブロック判定関数を上げます。

アイテムブロックからの出現、左右通常ブロックからの出現と分かれるので、
判定が3つ
なっています。

/**
	chapter41
	starブロック判定
	
	mapNumber : マップチップ番号
*/
function isStarBlock(mapNumber){
	if(mapNumber == 83 || mapNumber == 67 || mapNumber == 68){
		return true;
	}
	return false;
}

続いて、左右どちらか出現させるか返す関数を書きます。

/**
 * 対象のマップチップの移動方向を返す
 * @param {*} mapNumber 
 */
function getItemDir(mapNumber){
	if(mapNumber == 192 || mapNumber == 80 || mapNumber == 67){
		return LEFT_DIR
	}
	return RIGHT_DIR;
}

この関数を使って、オブジェクトの方向を代入するようにします。

// キノコブロックだった場合(chapter34&38)
if(isKinokoBlock(map[this.upMapY][mapsX[i]])){
	// chapter40 キノコを有効化する処理を関数にした
	var kinokoPosX = mapsX[i] * MAP_SIZE;
	var kinokoPosY = this.upMapY * MAP_SIZE;
	let dir = getItemDir(map[this.upMapY][mapsX[i]]);
	this.activateKinoko(kinokoPosX,kinokoPosY,dir);
	// ボックスを空にする
	replaceEmptyBoxMap(map,mapsX[i],this.upMapY);
}

先ほどの関数でマップチップから方向を引っ張って、キノコオブジェクトに代入するようにしています。

で、キノコ初期化関数ならびに、すべての方向代入処理関数の変数名directionでなくdirになっており間違っていたので、すいませんが、修正してください。

Kinoko.prototype.activate = function(posX,posY,dir){
  this.posX = posX;
  this.posY = posY;
  this.state = NORMAL_STATE;
  this.direction = dir;
  this.isFirstAnimation = true;
  this.offsetY = 0;
}

これで、オブジェクトの方向移動の決定処理ができました。

3.敵をマリオの位置によって動かすようにする処理

クリボもノコノコも実装する内容は一緒です。

update関数にて、マリオがクリボに一定距離以内にいるか判定し、
一定以内にいたら、stop flagを解除する
ようにします。

肝心の距離ですが、敵が画面内に見える距離プラスマップチップ1つ分としました。

マリオのスクロール位置によって、出現位置が代わることに注意してください。

/**
 * chapter53
 * マリオの位置によりstopFlagをオフにする
 * 
 * @param {*} mario 
 */
Kuribo.prototype.checkMove = function(mario){
	// 一度だけ判定させる
	if(this.isStop){
		if(isOffStopFlag(this.posX,mario)){
			this.isStop = false;
		}
	}
}

isOffStopFlag関数はutils.jsに書きました。

/**
 * 敵が動ける位置まで来たかを返す
 * @param {*} posX 
 * @param {*} mario 
 */
function isOffStopFlag(posX,mario){
	// 画面分野スクロールと一つのマップチップ分だけ距離が縮まったら出現させる
	if(Math.abs(posX - mario.moveNumX <= 672 - mario.posX)){
		return true;
	}
	return false;
}

左右に対応できるように絶対値をとっています。

そして、今まではクリボのmoveの部分にのみstopフラグ対象にしていましたが、それだと、重力など囲っていない箇所は効いてしまうので、全体を囲むように修正しました。

Kuribo.prototype.update = function(map,mario,moveNum){
	if(!this.isDead()){
		// マリオが一定の範囲内に来たら移動フラグを立てる
		this.checkMove(mario);
		if(!this.isStop){
			this.move(map,moveNum);
			// chapter37
			this.gravityAction(map);
			this.collisionWithMario(map,mario);
			// chapter39
			for(var i = 0;i < mario.MAX_FIRE_NUM;++i){
				this.collisionWithFire(mario.fire[i]);	
			}
		}
	}
	this.deadAction();
}

これで、処理が完成です。

敵座標の代入処理

一度マリオがやられたら、敵の座標やマップチップも元に戻す必要がありますが、敵の座標をいちいち手作業で入力するのはめんどくさいので、
enemyPos.jsを作って、その中に定数を定義してそこから参照できるようにします。

enemyPos.js

gKuriboPos = [[[20 * MAP_SIZE,384,LEFT_DIR],[44 * MAP_SIZE,384,LEFT_DIR],[51 * MAP_SIZE,384,LEFT_DIR],[53 * MAP_SIZE,384,LEFT_DIR],[80 * MAP_SIZE,MAP_SIZE * 4,LEFT_DIR],
    [82 * MAP_SIZE,4 * MAP_SIZE,LEFT_DIR],[96 * MAP_SIZE,384,LEFT_DIR],[98 * MAP_SIZE,384,LEFT_DIR],[114 * MAP_SIZE,384,LEFT_DIR],[116 * MAP_SIZE,384,LEFT_DIR],[124 * MAP_SIZE,384,LEFT_DIR],[126 * MAP_SIZE,384,LEFT_DIR],[130 * MAP_SIZE,384,LEFT_DIR],[132 * MAP_SIZE,384,LEFT_DIR],[175 * MAP_SIZE,384,LEFT_DIR],[177 * MAP_SIZE,384,LEFT_DIR]],
    [[]]];

第1配列がステージ数で、第2配列がステージ数のマップの場面(ボーナスステージなど)、第3配列が座標になります。

main.jsの敵の初期化関数のところで、この定数を使います。

function initStage1Enemy(){
  // kuribo
  for(var i = 0;i < gKuriboPos[0].length;++i){
    for(var j = 0;j < 3;++j){
      gKuribos[0][i].init(gKuriboPos[0][i][0],gKuriboPos[0][i][1],gKuriboPos[0][i][2]);
    }
  }
  // nokonoko
  gNokos[0][0].init(106 * MAP_SIZE,384,LEFT_DIR );
}

めんどくさいので、土管も同様に、2次元配列から3次元配列にしちゃいます。

配列の構成はクリボと同じです。

// マップによって変化できるように配列で持つ移動用
let gDocans = [
  // stage1
  [
    // 初期マップ
    [new Docan(57 * 32,320,64, 128,MAP_TWO,gBonusMapChip[0].length,DOCAN_DOWN,DOCAN_DOWN,48,64)],
    // ボーナスマップ
    [new Docan(576,384,64,64,MAP_ONE,gMapChip[0].length,DOCAN_RIGHT,DOCAN_UP,164 * 32 + 16,320)]
  ],
  [

  ]
];

// マップによって変化できるように配列で持つ、描画用
let gDocanObjs = [
  // stage1
  [
    [new DocanObj(57 * 32,384,64,128,DOCAN_UP),new DocanObj(164 * 32,384,64,64,DOCAN_UP)],
    [new DocanObj(32,0,64,64,DOCAN_DOWN),new DocanObj(576,384,64,64,DOCAN_LEFT)]
  ]
  ,
  [

  ]
];

土管を3次元配列にしたので、docanオブジェクトを渡す関数も変更する必要があります。

gMario.update(gMapChip,gKuribos[gMapStage],gNokos[gMapStage],gDocans[gTotalStageNumber - 1][gMapStage]);

ステージによっては入れる土管の無いステージもあるので、
空の土管の場合にも参照エラーをおこさないようにnullチェックをします。

if(gLeftPush){
	if(docans){
		for(var i = 0;i < docans.length;++i){
			this.docanXEnter(docans[i],LEFT_DIR);
		}
	}

8.最大描画マップチップの変更

敵やマリオが画面からはみ出した時に、参照エラーにならないように最大マップ領域をMAX_MAP_CHIP_Xという定数で設定していました。

本当は、ステージごとのマップ別に最大定数を用意するかするか、
そもそもマリオは範囲外に出れないので、その箇所を消去するかしたほうがいいんですが、今回はstage1に合わせる形にしました。

// 最大のマップチップ量X(マリオは範囲外にでないので必要ない)
var MAX_MAP_CHIP_X = 215;

9.縦長土管を描画できるようにする

今までの土管クラスはマップチップ2つ分の高さの土管しか描画できるようになっていなかったので、修正します。

docan.js

DocanObjクラスに高さと幅を保存するメンバー変数を定義します。

/**
 * 描画専用のドカンオブジェクト 
 * @param {*} posX 
 * @param {*} posY 
 * @param {*} width 
 * @param {*} height 
 * @param {*} direction 
 */
function DocanObj(posX,posY,width,height,direction){
    this.posX = posX;
    this.posY = posY;
    this.width = width;
    this.height = height;    
    this.direction = direction;
}

とりあえず、今回使う上を向いている土管の表示のみ修正します。

        case DOCAN_UP:
            // 土管の下を描画
            for(var i = 0;i < parseInt(this.height / MAP_SIZE) - 1;++i){
                ctx.drawImage(texture,448,128,64,32,this.posX - scrollX,this.posY - (i * MAP_SIZE),MAP_SIZE * 2,MAP_SIZE);
            }
            ctx.drawImage(texture,448,96,64,32,this.posX - scrollX,this.posY - this.height + MAP_SIZE,MAP_SIZE * 2,MAP_SIZE);
            break;

mario.jsに定義している、docanDownEnter関数を土管の高さに対応できるように修正します。

Mario.prototype.docanDownEnter = function(docan){
	let offset = 8
	// x軸
	if(this.moveNumX >= docan.posX + offset && this.moveNumX + 32 <= docan.posX + docan.width - offset){
		// デカくなったときはサイズが変わる
		let tall = this.height - 32;
		// y軸
		if(this.posY + tall == docan.posY - docan.height + (MAP_SIZE * 2)){
			this.setDocanParam(docan);
		}
	}
}

以上です。

まとめ

今回でゼロベースから、ステージ1のマリオを作るという目標は達成しました。

仮にステージ全部作るとしても、今までやってきたことの応用でいけると思います。

全体のcodeはgithubで確認してください。

感想

自分の頭の中だけで完結しているので、無駄なところもたくさんあると思いますが、だいたいこんな感じでやっていけば、複雑の地形の判定の対応などもできるんだと思います。

しかし、そんなに達成感がないのは、作るまえから当たり判定とか大まかな部分の作り方を決めていたからかもしれない。

ゼロになってどうすればもっと良くなるのか考えれば、達成感があったのかもしれない。

それと、プレイしてみて、音がないとやはり寂しいと思ったので、
もうちょっと作り込みをしたいと思います。