14KB JavaScriptマリオをコードリーディングしてみたよ
JavaScriptで書かれたスーパーマリオ。わずか14KBのサイズであのマリオを再現したということで話題です。
http://blog.nihilogic.dk/2008/04/super-mario-in-14kb-javascript.html
これはソースを読まねばなるまい、ということでコードリーディングしてみました。14KBといってもYUI compressorで圧縮されているので、見るべきは圧縮前の方のソースです。
http://www.nihilogic.dk/labs/mario/mario.js
圧縮前といってもものすごいですよ、これ。空行やコメントを適切に入れて、分かりやすい変数名をつけて、しかも画像や音楽もすべてテキストデータとしてソース中に定義して、それでたったの35KB、1200行。
うーむ、信じられない。敵キャラのAIは?画像データは?JavaScriptでどうやって音楽鳴らすの?とか興味はつきません。
ここでゲームの仕様についてちょっとまとめておきます。
- 1面のみ。地下なし
- 1回死んだらゲームオーバー。1UPなし
- BGMはゲーム中とゲームオーバーの2種
- 敵はクリボーのみ
- キノコ、スターなし
- ステージクリアの旗なし。ステージクリアーしたら固まって終了。
かなり大胆に元の要素を切り捨てていますが、それでもどう見てもマリオであるところにセンスを感じます。
では読んでいきます。作戦としては表示や入力の仕組みを個別に見て行って、省サイズの秘密を読み解いていきます。
そしてそのあとにゲーム全体の流れを掴む予定。
ライセンス(1行目)
MITライセンスです。
/* * The Javascript Mario Experiment v0.1 * Copyright (c) 2008 Jacob Seidelin, cupboy@gmail.com * MIT License [http://www.opensource.org/licenses/mit-license.php] */
フォント(465行目)
ゲームってフォント出力を自前でやらなくちゃいけないから大変ですね。mario.jsのフォントはbase128形式でエンコードした文字列として定義されていました。
var aFont = [ "<F・・・R<", // 0 ",<,,,,`", // 1 "_・'>]イチ", // 2 "`&,>#・_", // 3
定義している文字は「0123456789MARIOx-WLDTE」のみ、というのも結構すごい。確かにゲーム画面に出てくる単語はWORLDとかTIMEくらいだし。気付かなかったけど「START」も「GAME OVER」も表示してないのね。
使い方ですが、writeText関数に文字列を渡すと、1文字ずつwriteChar関数を呼んで、このフォントデータをbase128ToBitString関数でデコードしながらプロットしてくれます。ちょ、プロットって間単に言うな。JavaScriptだぞ。
ピクセル描画処理(250行目)
plotPixel関数が問題のプロット処理です。まず大きな三項演算子で、canvasタグに対応しているブラウザかどうかを判断し関数の中身を切り替えてます。うん、canvasを使うとグラフィック処理とかできるよね。でもこのマリオ、canvasがないはずのIEでも動くんですけど……。
これがcanvas対応の方の描画処理。普通です。iPixSizeは表示倍率を持っています。
plotPixel = bHasCanvas ? function(iColor, x, y, oCtx, iWidth) { if (aPalette[iColor] != "") { oCtx.fillStyle = "#" + aPalette[iColor]; oCtx.fillRect(x * iPixSize - iWidth * iPixSize, y * iPixSize, iWidth * iPixSize, iPixSize); } } :
えーと、canvas未対応の方は……。うわあ、1ピクセルにつき1個ずつ、指定色を背景にしたSPANタグを生成しています!無茶するなあ。
function(iColor, x, y, oSpan, iWidth) { appChild(oSpan, createSpan( (x - iWidth) * iPixSize, y * iPixSize, iWidth * iPixSize, iPixSize, aPalette[iColor] ) ); },
ビットマップ定義(494行目)
マップやキャラクターはどうやって定義してるのかな。例によってbase128エンコードですね。スプライトとか簡単に言ってるけど、ブラウザにそんな機能ないってば。どうすんの?
aSpriteData = [ "}\"ケ-コ\"タ+コ\"タ+コ\"タ+コ\"ソ、タ ~C_ ^?+コ\"タ+コ\"タ+コ\"タ*P7イOK%セ+スu_\"タ<。a。a。bM@ア@ェ", // 0 ground "a ' ![ア 7ーウb」[mt<Nオ7z]~ィORサ[f_7l},tl},^?+}%XNイSb[bl」[ア%Y_ケ !@ $", // 1 qbox "!A % @,[] ア}ー@;オnヲ&X」 <$ ァ、 8}}@Prc'U#Z'H'@キ カ\"is 、&08@」(", // 2 mario
aSpriteHTML = ["", loadSpriteData([5,4,0,3], aSpriteData[0]), // 01 Ground loadSpriteData([3,4,5,0], aSpriteData[1]), // 02 Question Box loadSpriteData(aPalette2, aSpriteData[5]), // 03 Pipe Left
loadSpriteData関数は、使う色をパレットテーブルから4つ選んで、スプライトデータを指定すると、描画されたものがDOMエレメントとして戻ってきます。メモリ上に描画するだけで画面にはまだ描かれません。省サイズのために、良く使うパレットの組み合わせはaPalette2のように変数で定義してますね。また第3引数を指定すると逆向きの画像を描画してくれるので、自キャラは右向きと左向きの両方を用意しておきます。
音楽(651行目)
BGMの定義はこんな感じ。
aSounds = [ // very small, very simple mario theme. Sequenced by Mike Martel. "data:audio/mid;base64,TVRoZAAAAAYAAQAEAMBNVHJrAAAAGQD/UQMFe ……(省略)
これをもとに、以下のようなembedタグを動的に生成してMIDIとして鳴らしてます。なるほどJavaScriptの世界ではなく、HTMLの世界で鳴らすんですね。データはbase64でエンコードされてますが、これは自前でデコードしなくともこのままブラウザが解釈してくれます。
<embed id="sound_1" src="data:audio/mid;base64,TVRoZAAAAAYAAQAE (省略) 8A" autostart="true" style="position: absolute; left: -1000px;" type="audio/mid"/>
死んだときはこのタグを削除して、代わりにゲームオーバー用のembedタグを挿入します。
ソースコードへ音楽や画像を埋め込む方法についてはEmbedding and Encoding in JavaScriptにも詳しく書いてあるので参考に。
キー入力(392行目、1153行目)
使ったことなかったけど、JavaScriptではこうやってキー入力イベント取れるのか。
ここでは省略しましたがキーアップ処理もあって、ステータスを元に戻したりしてます。
document.onkeydown = fncKeyDown;
fncKeyDown = function (e) { var keyCode = (e||event).keyCode; if (keyCode == 39 && iPlayerMovementX < 1) { bPlayerMoveX = 1; iPlayerDirection = 0; } if (keyCode == 37 && iPlayerMovementX > -1) { bPlayerMoveX = iPlayerDirection = 1; } if (keyCode == 17 || keyCode == 38) { if (bPlayerIsOnGround) jump(); else iPreJump = 8; } },
ちなみにキーコードの意味は37(←)、39(→)、17(Ctrl)、38(↑)です。
加速度(419行目)
マリオは移動速度が一定ではなく、右ボタンを押してから徐々に速度が上がります。その処理がこれ。50msec毎に呼ばれて、速度が6未満だったら1ずつ加速していきます。
fncMoveTimer = function() { if (bPlayerMoveX) { if (iPlayerDirection == 0 && iPlayerMovementX < 6) { iPlayerMovementX += 1; } else if (iPlayerMovementX > -6) { iPlayerMovementX -= 1; } } else { if (iPlayerMovementX < 0) iPlayerMovementX++; if (iPlayerMovementX > 0) iPlayerMovementX--; } setTimer(fncMoveTimer, 50); },
別の箇所(1010行目)ですが、落下速度もこんな感じで増加していきます。
// constantly increase fall speed to the max if (iPlayerMovementY < 8) iPlayerMovementY++;
敵データの動き(632行目)
うーむ、これだけですべての敵の動作を定義しています。
データは順番に初期座標X、Y、左端、右端です。敵は初期座標から左向きに移動していって相対位置で左端に達したら向きを変えます。そしてまた右端に達したら向きを変えるという動き。土管やブロックと衝突判定してるわけじゃないんですね。
[ 23,12,-24,1, 41,12,-4,1, 52,12,-7,1, 50,12,-5,3, 94,12,-8,36, 92,12,-6,38, 111,12,-25,19, 109,12,-23,21, 121,12,-35,9, 119,12,-33,11, 125,12,-39,5, 123,12,-37,7, 79,5,-2,5, 77,5,0,7 ] // goombas
ちなみに敵(クリボー)は、ソース中ではGoombaと呼ばれています。
重ね合わせ描画(876行目等)
重ね合わせですが、DOMによってスプライト機能を実現しています。まず全ての親はoLevelElementです。初期化処理では、これに全ての背景エレメントをappendChildします。次に敵であるoGoombaElementや、自キャラであるoPlayerElementもoLevelElementにappendChildします。
// setup player sprite
oPlayerElement = appChild(oLevelElement, createSpan(4*iTileSize,4*iTileSize,iTileSize,iTileSize)),
後はキャラのstyle.top属性やstyle.left属性を変更することでマップ上を動き回ります。
oPlayerStyle.left = iNewX;
ビットマップ画像に見えるけど、実は<canvas>だったり<span>だったりするので、DOM操作で簡単に移動や重ねあわせができる、って確かにそうだけどちょっと目からウロコ。
スクロール(1114行目)
スクロール処理も同じ発想。全ての親であるoLevelElementのstyle.left属性を増やしていくだけでした。そのあと画面の両端のはみ出る部分を計算して、showHide関数で表示/非表示設定してますね。
var iNewX = toInt(oLevelElement.style.left) + x; if (iNewX < 0) { oLevelElement.style.left = iNewX; var iLeftX = floor(-iNewX / iTileSize)-1, iRightX = iLeftX + 17, bShowRight; if (x > 0) { iRightX++; iLeftX++; bShowRight = 1; } for (var iRow in aTileMap) { var aRow = aTileMap[iRow]; if (aRow[iRightX]) showHide(aRow[iRightX], !bShowRight); if (aRow[iLeftX]) showHide(aRow[iLeftX], bShowRight); } }
アニメーション(857行目等)
キャラやコインのアニメーションパターンは、同じ位置にアニメーションパターンのDOMを設定しておいて、タイマーでshowHide関数を呼んで表示/非表示を切り替えています。
if (aCoinSprites[i]) { showHide(aCoinSprites[i].s[0]); showHide(aCoinSprites[i].s[1]); showHide(aCoinSprites[i].s[2]); showHide(aCoinSprites[i].s[3]); } showHide(aCoinSprites[i].s[iCoinState],1);
省サイズの工夫(10行目)
数バイト削るために涙ぐましい別名定義してますが、圧縮をかけると変数名がすべて1文字になるためけっこう効いてきます。
// our own variables can be compressed, builtin functions can't var toInt = parseInt, setTimer = setTimeout, getElement = function(id){return document.getElementById(id);},
あとはマジックナンバーが多いですね。aSpriteHTML[12]は右向きマリオだとか。コメントでフォローしてあるのでさほど読みづらくはないと思います。
エントリーポイント(8行目)
このJavaScriptプログラムは全体が大きなひとつのMario関数になってます。クラスは使ってません。
BODYタグのonloadでこの関数を呼びます。引数はBGMの有無と表示倍率です。
var Mario = function(bMusic, iScale)
タイマー
ゲームといえばやっぱりタイマー処理が複雑で重要ですね。タイマーで周期的に呼ばれる処理は以下のものがありました。
- ゲームの最小サイクルのタイマー(gameCycle, 32msec周期)
- スクロール位置のチェック(checkScroll, 32msec周期)
- 残り時間のカウントダウン(updateTime, 1000msec周期)
- プレイヤー移動速度調整(funcMoveTimer, 50msec周期)
- コインのアニメーション(fncAnimCoin, 100msec周期)
- 「?」ブロックのアニメーション(fncFlashCoinBoxes, 100msec周期)
- 敵のアニメーション(fncFlipGoomba, 300msec周期)
- 画面にフォーカスを戻す処理(100msec周期)
ゲームサイクル(894行目)
メインループとなるgameCycleは大きいので引用しませんが、中身は以下のような流れです。
- 衝突判定
- コイン出現処理
- 自キャラアニメーション
- 自キャラ移動
- 敵との衝突判定
- 敵のアニメーション
おわりに
とりあえず以上です。サイズの割りに読みやすいことにも驚きました。MITライセンスなので、これを元に自分でゲームを作ったりするのにも使えそうです。
ふー、お疲れ様でした。