第13章: ブラウザイベント

ウェブページに興味深い機能を追加するには、ドキュメントを検査または変更できるだけでは通常十分ではありません。ユーザーが何をしているかを検出し、それに応答できるようにする必要があります。このために、イベントハンドラと呼ばれるものを使用します。キーの押下はイベントであり、マウスクリックはイベントであり、マウスの動きさえも一連のイベントとして見なすことができます。第11章では、ボタンが押されたときに何かを実行するために、onclickプロパティをボタンに追加しました。これは単純なイベントハンドラです。

ブラウザイベントの動作方法は、基本的には非常に単純です。特定イベントタイプと特定のDOMノードに対してハンドラを登録できます。 イベントが発生するたびに、そのイベントのハンドラ(存在する場合)が呼び出されます。キー押下などの一部のイベントでは、イベントが発生したことだけを知るだけでは不十分で、どのキーが押されたかも知る必要があります。このような情報を格納するために、すべてのイベントはイベントオブジェクトを作成し、ハンドラはそれを参照できます。

イベントはいつでも発生する可能性がありますが、2つのハンドラが同時に実行されることはないことに注意することが重要です。他のJavaScriptコードがまだ実行中の場合、ブラウザはそれが終了するまで待ってから、次のハンドラを呼び出します。これは、setTimeoutなどでトリガーされるコードにも当てはまります。プログラマ用語では、ブラウザJavaScriptはシングルスレッドであり、2つの'スレッド'が同時に実行されることはありません。ほとんどの場合、これは良いことです。複数のことが同時に発生すると、奇妙な結果になることが非常に簡単です。

イベントは、処理されない場合、DOMツリーを「バブル」することができます。これは、たとえば段落内のリンクをクリックした場合、リンクに関連付けられたハンドラが最初に呼び出されることを意味します。そのようなハンドラがない場合、またはこれらのハンドラがイベントの処理を終了したことを示さない場合、リンクの親である段落のハンドラが試行されます。その後、document.bodyのハンドラが実行されます。最後に、JavaScriptハンドラがイベントを処理しなかった場合、ブラウザがそれを処理します。リンクをクリックすると、これはリンクが辿られることを意味します。


ご覧のとおり、イベントは簡単です。それらについて唯一難しいのは、ブラウザが、ほぼ同じ機能をすべてサポートしている一方で、異なるインターフェースを通じてこの機能をサポートしていることです。いつものように、最も互換性のないブラウザはInternet Explorerであり、他のほとんどのブラウザが従う標準を無視します。その後、ページを離れるときに発生するonunloadイベントなどのいくつかの便利なイベントを適切にサポートせず、キーボードイベントに関する紛らわしい情報を提供する場合があるOperaがあります。

イベント関連のアクションには、実行したい可能性のあるものが4つあります。

主要なブラウザ全体で同じように動作するものは1つもありません。


イベント処理の練習場として、ボタンとテキストフィールドを含むドキュメントを開きます。この章の残りの間、このウィンドウを開いたまま(アタッチされた状態)にしてください。

attach(window.open("example_events.html"));

最初のアクションであるハンドラの登録は、要素のonclick(またはonkeypressなど)プロパティを設定することで実行できます。これは実際にブラウザ間で機能しますが、重要な欠点があります。つまり、要素にアタッチできるハンドラは1つだけです。ほとんどの場合、1つで十分ですが、特にプログラムが他のプログラム(ハンドラを追加している可能性もある)と連携して動作する必要がある場合は、これが煩わしい場合があります。

Internet Explorerでは、次のようにしてボタンにクリックハンドラを追加できます

$("button").attachEvent("onclick", function(){print("Click!");});

他のブラウザでは、次のようになります

$("button").addEventListener("click", function(){print("Click!");},
                             false);

2番目のケースでは、"on"が省略されていることに注意してください。addEventListenerへの3番目の引数falseは、イベントがDOMツリーを通常どおり「バブル」する必要があることを示します。代わりにtrueを指定すると、その「下」のハンドラよりもこのハンドラを優先するために使用できますが、Internet Explorerはそのようなものをサポートしていないため、これはめったに役に立ちません。


例13.1

これらの2つのモデルの非互換性をラップするために、registerEventHandlerという関数を作成します。これは、3つの引数を取ります。最初に、ハンドラがアタッチされるDOMノード、次に、"click""keypress"などのイベントタイプの名前、最後に、ハンドラ関数です。

どのメソッドを呼び出すかを判断するには、メソッド自体を探します。DOMノードにattachEventというメソッドがある場合、これが正しいメソッドであると想定できます。これは、ブラウザがInternet Explorerかどうかを直接確認するよりもはるかに望ましいことに注意してください。Internet Explorerのモデルを使用する新しいブラウザが登場した場合、またはInternet Explorerが突然標準モデルに切り替えた場合でも、コードは機能します。もちろん、どちらも可能性は低いですが、賢明な方法で何かを行うことは決して損にはなりません。

function registerEventHandler(node, event, handler) {
  if (typeof node.addEventListener == "function")
    node.addEventListener(event, handler, false);
  else
    node.attachEvent("on" + event, handler);
}

registerEventHandler($("button"), "click",
                     function(){print("Click (2)");});

長くてぎこちない名前を心配しないでください。後で、このラッパーをラップする追加のラッパーを追加する必要があり、それはより短い名前になります。

このチェックを1回だけ実行し、ブラウザに応じて異なる関数を保持するようにregisterEventHandlerを定義することもできます。これはより効率的ですが、少し奇妙です。

if (typeof document.addEventListener == "function")
  var registerEventHandler = function(node, event, handler) {
    node.addEventListener(event, handler, false);
  };
else
  var registerEventHandler = function(node, event, handler) {
    node.attachEvent("on" + event, handler);
  };

イベントの削除は、イベントの追加と非常によく似ていますが、今回はdetachEventremoveEventListenerメソッドが使用されます。ハンドラを削除するには、アタッチした関数にアクセスする必要があることに注意してください。

function unregisterEventHandler(node, event, handler) {
  if (typeof node.removeEventListener == "function")
    node.removeEventListener(event, handler, false);
  else
    node.detachEvent("on" + event, handler);
}

イベントハンドラによって生成された例外は、技術的な制限により、コンソールでキャッチすることはできません。したがって、それらはブラウザによって処理されます。つまり、どこかの「エラーコンソール」に隠されたり、メッセージがポップアップしたりする可能性があります。イベントハンドラを作成しても機能しないように見える場合は、何らかのエラーが発生しているため、サイレントに中断している可能性があります。


ほとんどのブラウザは、イベントオブジェクトをハンドラへの引数として渡します。Internet Explorerは、eventというトップレベルの変数にそれを格納します。JavaScriptコードを見ると、event || window.eventのようなものによく遭遇します。これは、ローカル変数event、またはそれが未定義の場合は、同じ名前のトップレベルの変数を使用します。

function showEvent(event) {
  show(event || window.event);
}

registerEventHandler($("textfield"), "keypress", showEvent);

フィールドにいくつかの文字を入力し、オブジェクトを見て、再びそれをシャットダウンしてください

unregisterEventHandler($("textfield"), "keypress", showEvent);

ユーザーがマウスをクリックすると、3つのイベントが生成されます。最初にmousedownは、マウスボタンが押された瞬間です。次に、mouseupは、ボタンが離された瞬間です。そして最後に、clickは、何かがクリックされたことを示します。これが短時間で2回発生すると、dblclick(ダブルクリック)イベントも生成されます。マウスボタンがしばらくの間保持されている場合、mousedownイベントとmouseupイベントがしばらく間隔を置いて発生する可能性があることに注意してください。

たとえば、ボタンにイベントハンドラをアタッチする場合、クリックされたという事実が、知る必要があるすべての場合がよくあります。一方、ハンドラが子を持つノードにアタッチされている場合、子からのクリックはそれに「バブル」アップし、どの子がクリックされたかを知りたい場合があります。この目的のために、イベントオブジェクトには、targetまたはブラウザによってはsrcElementと呼ばれるプロパティがあります。

もう1つの興味深い情報は、クリックが発生した正確な座標です。マウスに関連するイベントオブジェクトには、画面上のマウスのx座標とy座標をピクセル単位で示すclientXプロパティとclientYプロパティが含まれています。ただし、ドキュメントはスクロールできるため、多くの場合、これらの座標はマウスがオーバーしているドキュメントの部分についてあまり教えてくれません。一部のブラウザは、この目的のためにpageXプロパティとpageYプロパティを提供しますが、他のブラウザ(どれか推測してください)は提供しません。幸いなことに、ドキュメントがスクロールされたピクセル数に関する情報は、document.body.scrollLeftdocument.body.scrollTopで見つけることができます。

このハンドラは、ドキュメント全体にアタッチされており、すべてのマウスクリックをインターセプトし、それらに関する情報を出力します。

function reportClick(event) {
  event = event || window.event;
  var target = event.target || event.srcElement;
  var pageX = event.pageX, pageY = event.pageY;
  if (pageX == undefined) {
    pageX = event.clientX + document.body.scrollLeft;
    pageY = event.clientY + document.body.scrollTop;
  }

  print("Mouse clicked at ", pageX, ", ", pageY,
        ". Inside element:");
  show(target);
}
registerEventHandler(document, "click", reportClick);

そして、再びそれを削除します

unregisterEventHandler(document, "click", reportClick);

明らかに、これらのすべてのチェックと回避策を作成することは、すべてのイベントハンドラで実行したいことではありません。しばらくしてから、もう少し非互換性に慣れた後、ブラウザ全体で同じように機能するようにイベントオブジェクトを「正規化」する関数を作成します。

イベントオブジェクトのwhichプロパティとbuttonプロパティを使用して、どのマウスボタンが押されたかを確認できる場合もあります。残念ながら、これは非常に信頼性が低いです。一部のブラウザはマウスにボタンが1つしかないふりをし、他のブラウザは右クリックをコントロールキーが押された状態でクリックしたと報告するなどします。


クリックとは別に、マウスの動きにも関心があるかもしれません。DOMノードのmousemoveイベントは、マウスがその要素上にあるときにマウスが移動するたびに発生します。マウスがノードに入るときまたは離れるときにのみ発生するmouseovermouseoutもあります。この最後のタイプのイベントでは、target(またはsrcElement)プロパティはイベントが発生したノードを指し、relatedTarget(またはtoElement、またはfromElement)プロパティは、マウスが(mouseoverの場合)来たノード、または(mouseoutの場合)離れたノードを示します。

mouseovermouseoutは、子ノードを持つ要素に登録されている場合、トリッキーになる可能性があります。子ノードに対して発生したイベントは親要素にバブルアップするため、マウスが子ノードの1つに入るときにもmouseoverイベントが発生します。targetプロパティとrelatedTargetプロパティを使用して、このようなイベントを検出(および無視)できます。


ユーザーがキーを押すたびに、keydownkeyupkeypress の3つのイベントが生成されます。一般的には、どのキーが押されたかを正確に知りたい場合、例えば矢印キーが押されたときに何かをしたい場合は、最初の2つを使用する必要があります。一方、keypressは、入力されている文字に関心がある場合に使用します。これは、keyupイベントとkeydownイベントには文字情報がないことが多く、Internet Explorerでは矢印キーなどの特殊キーに対してkeypressイベントがまったく生成されないためです。

どのキーが押されたかを知るだけでも、かなりの難題となりえます。keydownイベントとkeyupイベントの場合、イベントオブジェクトには、数値を含むkeyCodeプロパティがあります。ほとんどの場合、これらのコードを使用して、ブラウザに依存しない合理的な方法でキーを識別できます。どのコードがどのキーに対応するかを調べるには、簡単な実験を行うとよいでしょう...

function printKeyCode(event) {
  event = event || window.event;
  print("Key ", event.keyCode, " was pressed.");
}

registerEventHandler($("textfield"), "keydown", printKeyCode);
unregisterEventHandler($("textfield"), "keydown", printKeyCode);

ほとんどのブラウザでは、単一のキーコードはキーボード上の単一の物理キーに対応しています。ただし、Operaブラウザでは、Shiftキーが押されているかどうかによって、一部のキーに対して異なるキーコードが生成されます。さらに悪いことに、これらのShiftキーが押されている場合のコードの一部は、他のキーにも使用されているコードと同じです。例えば、ほとんどのキーボードで括弧を入力するために使用されるShift + 9は、下矢印と同じコードを取得するため、区別するのが困難です。これがプログラムを妨害する恐れがある場合は、通常、Shiftキーが押されているキーイベントを無視することで解決できます。

キーまたはマウスイベント中にShift、Control、またはAltキーが押されているかどうかを調べるには、イベントオブジェクトのshiftKeyctrlKeyaltKeyプロパティを確認できます。

keypressイベントの場合は、どの文字が入力されたかを知りたいでしょう。イベントオブジェクトには、運が良ければ入力された文字に対応するUnicode番号を含むcharCodeプロパティがあります。この番号は、String.fromCharCodeを使用して1文字の文字列に変換できます。残念ながら、このプロパティを定義していない、または0として定義し、代わりにkeyCodeプロパティに文字コードを格納するブラウザもあります。

function printCharacter(event) {
  event = event || window.event;
  var charCode = event.charCode;
  if (charCode == undefined || charCode === 0)
    charCode = event.keyCode;
  print("Character '", String.fromCharCode(charCode), "'");
}

registerEventHandler($("textfield"), "keypress", printCharacter);
unregisterEventHandler($("textfield"), "keypress", printCharacter);

イベントハンドラーは、処理中のイベントを「停止」できます。これを行うには、2つの異なる方法があります。イベントが親ノードやそれらに定義されたハンドラーにバブリングするのを防ぐことと、ブラウザがそのようなイベントに関連付けられた標準アクションを実行するのを防ぐことです。ブラウザは必ずしもこれに従うとは限りません。特定の「ホットキー」の押下に対するデフォルトの動作を防止しても、多くのブラウザでは、実際にはこれらのキーの通常の効果が実行されるのを防ぐことはできません。

ほとんどのブラウザでは、イベントのバブリングの停止はイベントオブジェクトのstopPropagationメソッドで行われ、デフォルトの動作の防止はpreventDefaultメソッドで行われます。Internet Explorerの場合、これはオブジェクトのcancelBubbleプロパティをtrueに設定し、returnValueプロパティをそれぞれfalseに設定することで行います。

そして、これがこの章で説明する非互換性の長いリストの最後です。つまり、これでようやくイベント正規化関数を記述して、より興味深いものに移ることができます。

function normaliseEvent(event) {
  if (!event.stopPropagation) {
    event.stopPropagation = function() {this.cancelBubble = true;};
    event.preventDefault = function() {this.returnValue = false;};
  }
  if (!event.stop) {
    event.stop = function() {
      this.stopPropagation();
      this.preventDefault();
    };
  }

  if (event.srcElement && !event.target)
    event.target = event.srcElement;
  if ((event.toElement || event.fromElement) && !event.relatedTarget)
    event.relatedTarget = event.toElement || event.fromElement;
  if (event.clientX != undefined && event.pageX == undefined) {
    event.pageX = event.clientX + document.body.scrollLeft;
    event.pageY = event.clientY + document.body.scrollTop;
  }
  if (event.type == "keypress") {
    if (event.charCode === 0 || event.charCode == undefined)
      event.character = String.fromCharCode(event.keyCode);
    else
      event.character = String.fromCharCode(event.charCode);
  }

  return event;
}

イベントのバブリングとデフォルトのアクションの両方をキャンセルするstopメソッドが追加されます。一部のブラウザではすでにこれを提供しているので、その場合はそのままにしておきます。

次に、registerEventHandlerunregisterEventHandlerの便利なラッパーを記述できます。

function addHandler(node, type, handler) {
  function wrapHandler(event) {
    handler(normaliseEvent(event || window.event));
  }
  registerEventHandler(node, type, wrapHandler);
  return {node: node, type: type, handler: wrapHandler};
}

function removeHandler(object) {
  unregisterEventHandler(object.node, object.type, object.handler);
}

var blockQ = addHandler($("textfield"), "keypress", function(event) {
  if (event.character.toLowerCase() == "q")
    event.stop();
});

新しいaddHandler関数は、指定されたハンドラー関数を新しい関数でラップし、イベントオブジェクトの正規化を行うことができます。この関数は、この特定のハンドラーを削除したいときにremoveHandlerに渡すことができるオブジェクトを返します。テキストフィールドに「q」と入力してみてください。

removeHandler(blockQ);

前章のaddHandlerdom関数があれば、文書操作のより困難な偉業に挑戦する準備が整いました。演習として、倉庫番として知られるゲームを実装します。これは古典的なものですが、以前に見たことがないかもしれません。ルールは次のとおりです。壁、空きスペース、および1つ以上の「出口」で構成されるグリッドがあります。このグリッドには、多数の箱または石があり、プレーヤーが操作する小さなキャラクターがいます。このキャラクターは、空のマスに水平および垂直に移動でき、後ろに空のスペースがある場合は、岩を押し動かすことができます。ゲームの目的は、所定数の岩を出口に移動することです。

第8章のテラリアのように、倉庫番のレベルはテキストとして表現できます。example_events.htmlウィンドウの変数sokobanLevelsには、レベルオブジェクトの配列が含まれています。各レベルには、レベルのテキスト表現を含むプロパティfieldと、レベルを完了するために排出する必要がある岩の数を示すプロパティbouldersがあります。

show(sokobanLevels.length);
show(sokobanLevels[1].boulders);
forEach(sokobanLevels[1].field, print);

このようなレベルでは、#文字は壁、スペースは空のマス、0文字は岩、@はプレーヤーの開始位置、*は出口を表します。


ただし、ゲームをプレイするときに、このテキスト表現を見たくはありません。代わりに、文書にテーブルを配置します。このテーブルのセルに固定の正方形サイズを与えるために、小さなスタイルシート(sokoban.css、興味があれば見てみてください)を作成し、例のドキュメントに追加しました。このテーブルの各セルには、正方形の種類(空、壁、または出口)を表す背景画像が表示されます。プレーヤーと岩の位置を示すために、これらのテーブルセルに画像が追加され、必要に応じて異なるセルに移動されます。

このテーブルをデータの主要な表現として使用することは可能です。特定のマスに壁があるかどうかを確認したい場合は、該当するテーブルセルの背景を調べ、プレーヤーを見つけるには、正しいsrcプロパティを持つ画像ノードを検索するだけです。場合によっては、このアプローチが実用的ですが、このプログラムでは、グリッド用の別のデータ構造を保持することを選択しました。これにより、物事がはるかに簡単になるためです。

このデータ構造は、プレイフィールドのマスを表すオブジェクトの2次元グリッドです。各オブジェクトは、背景の種類と、そのセルに岩またはプレーヤーが存在するかどうかを格納する必要があります。また、ドキュメントに表示するために使用されるテーブルセルへの参照も含める必要があります。これにより、テーブルセルへの画像の移動が簡単になります。

これにより、2種類のオブジェクトができます。1つはプレイフィールドのグリッドを保持し、もう1つはこのグリッド内の個々のセルを表します。ゲームが適切なタイミングで次のレベルに移動したり、失敗したときに現在のレベルをリセットしたりするようなことも行いたい場合は、適切なタイミングでフィールドオブジェクトを作成または削除する「コントローラー」オブジェクトも必要になります。便宜上、第8章の最後に概説されているプロトタイプアプローチを使用します。そのため、オブジェクト型は単なるプロトタイプであり、new演算子ではなくcreateメソッドを使用して新しいオブジェクトを作成します。


まず、ゲームのフィールドのマスを表すオブジェクトから始めましょう。これらのオブジェクトは、セルの背景を正しく設定し、必要に応じて画像を追加する役割を担います。img/sokoban/ディレクトリには、ゲームを視覚化するために使用される、別の古いゲームに基づいた一連の画像が含まれています。まず、Squareプロトタイプは次のようになります。

var Square = {
  construct: function(character, tableCell) {
    this.background = "empty";
    if (character == "#")
      this.background = "wall";
    else if (character == "*")
      this.background = "exit";

    this.tableCell = tableCell;
    this.tableCell.className = this.background;

    this.content = null;
    if (character == "0")
      this.content = "boulder";
    else if (character == "@")
      this.content = "player";

    if (this.content != null) {
      var image = dom("IMG", {src: "img/sokoban/" +
                                   this.content + ".gif"});
      this.tableCell.appendChild(image);
    }
  },

  hasPlayer: function() {
    return this.content == "player";
  },
  hasBoulder: function() {
    return this.content == "boulder";
  },
  isEmpty: function() {
    return this.content == null && this.background == "empty";
  },
  isExit: function() {
    return this.background == "exit";
  }
};

var testSquare = Square.create("@", dom("TD"));
show(testSquare.hasPlayer());

コンストラクターへのcharacter引数は、レベルの設計図から実際のSquareオブジェクトに文字を変換するために使用されます。セルの背景を設定するには、スタイルシートクラス(sokoban.cssで定義)が使用され、td要素のclassNameプロパティに割り当てられます。

hasPlayerisEmptyのようなメソッドは、この型のオブジェクトを使用するコードをオブジェクトの内部構造から「分離」する方法です。この場合は厳密には必要ありませんが、他のコードの見栄えが良くなります。


例. 13.2

SquareプロトタイプにmoveContentメソッドとclearContentメソッドを追加します。最初のメソッドは、別のSquareオブジェクトを引数として取り、thisマスのコンテンツを、contentプロパティを更新し、このコンテンツに関連付けられた画像ノードを移動することで引数に移動します。これは、岩やプレーヤーをグリッド上で移動するために使用されます。そのマスが現在空ではないことを前提とします。clearContentは、コンテンツをどこにも移動せずにマスから削除します。空のマスに対するcontentプロパティには、nullが含まれていることに注意してください。

第12章で定義したremoveElement関数も、ノードを削除するのに便利なように、この章でも使用できます。画像はテーブルセルの唯一の子ノードであり、たとえばthis.tableCell.lastChildなどを介して到達できると想定できます。

Square.moveContent = function(target) {
  target.content = this.content;
  this.content = null;
  target.tableCell.appendChild(this.tableCell.lastChild);
};
Square.clearContent = function() {
  this.content = null;
  removeElement(this.tableCell.lastChild);
};

次のオブジェクト型はSokobanFieldと呼ばれます。そのコンストラクターにはsokobanLevels配列からのオブジェクトが与えられ、DOMノードのテーブルとSquareオブジェクトのグリッドの両方を構築する役割を担います。このオブジェクトは、プレーヤーが移動したい方向を示す引数が与えられたmoveメソッドを介して、プレーヤーと岩の移動の詳細も処理します。

個々のマスを識別し、方向を示すために、第8章Pointオブジェクト型を再び使用します。これは、ご記憶のとおり、addメソッドを持っています。

フィールドのプロトタイプの基本は次のようになります。

var SokobanField = {
  construct: function(level) {
    var tbody = dom("TBODY");
    this.squares = [];
    this.bouldersToGo = level.boulders;

    for (var y = 0; y < level.field.length; y++) {
      var line = level.field[y];
      var tableRow = dom("TR");
      var squareRow = [];
      for (var x = 0; x < line.length; x++) {
        var tableCell = dom("TD");
        tableRow.appendChild(tableCell);
        var square = Square.create(line.charAt(x), tableCell);
        squareRow.push(square);
        if (square.hasPlayer())
          this.playerPos = new Point(x, y);
      }
      tbody.appendChild(tableRow);
      this.squares.push(squareRow);
    }

    this.table = dom("TABLE", {"class": "sokoban"}, tbody);
    this.score = dom("DIV", null, "...");
    this.updateScore();
  },

  getSquare: function(position) {
    return this.squares[position.y][position.x];
  },
  updateScore: function() {
    this.score.firstChild.nodeValue = this.bouldersToGo + 
                                      " boulders to go.";
  },
  won: function() {
    return this.bouldersToGo <= 0;
  }
};

var testField = SokobanField.create(sokobanLevels[0]);
show(testField.getSquare(new Point(10, 2)).content);

コンストラクタは、レベル内の行と文字を調べて、Squareオブジェクトをsquaresプロパティに格納します。プレイヤーがいるマスに遭遇すると、この位置をplayerPosとして保存し、後でプレイヤーがいるマスを簡単に見つけられるようにします。getSquareは、フィールド上の特定のx,y位置に対応するSquareオブジェクトを見つけるために使用されます。フィールドの端は考慮されていないことに注意してください。退屈なコードを書くことを避けるために、フィールドは適切に壁で囲まれており、そこから歩き出すことは不可能であると仮定しています。

tableノードを作成するdom呼び出しの"class"という単語は、文字列として引用符で囲まれています。これは、classがJavaScriptの「予約語」であり、変数またはプロパティ名として使用できないため必要です。

レベルをクリアするために必要な岩の数(これはレベル上の岩の総数よりも少ない場合があります)は、bouldersToGoに格納されます。岩が出口に運ばれるたびに、これから1を減算し、ゲームがすでに終了したかどうかを確認できます。プレイヤーに自分の進捗状況を示すために、この量を何らかの方法で表示する必要があります。この目的のために、テキスト付きのdiv要素が使用されます。divノードは、固有のマークアップがないコンテナです。スコアテキストは、updateScoreメソッドで更新できます。wonメソッドは、ゲームが終了したかどうかを判断するためにコントローラーオブジェクトで使用され、プレイヤーは次のレベルに進むことができます。


実際にプレイフィールドとスコアを表示したい場合は、それらを何らかの方法でドキュメントに挿入する必要があります。それがplaceメソッドの目的です。また、終了時にフィールドを簡単に削除できるように、removeメソッドを追加します。

SokobanField.place = function(where) {
  where.appendChild(this.score);
  where.appendChild(this.table);
};
SokobanField.remove = function() {
  removeElement(this.score);
  removeElement(this.table);
};

testField.place(document.body);

すべてがうまくいけば、倉庫番のフィールドが表示されるはずです。


例 13.3

しかし、このフィールドはまだあまり機能しません。moveというメソッドを追加します。これは、移動を引数として指定するPointオブジェクト(たとえば、左に移動するには-1,0)を受け取り、ゲーム要素を正しい方法で移動させる処理を行います。

正しい方法は次のとおりです。playerPosプロパティを使用して、プレイヤーが移動しようとしている場所を判断できます。ここに岩がある場合は、この岩の後ろのマスを見てください。そこに出口がある場合は、岩を削除してスコアを更新します。そこに空きスペースがある場合は、岩をそこへ移動させます。次に、プレイヤーを移動させようとします。移動しようとしているマスが空でない場合は、移動を無視します。

SokobanField.move = function(direction) {
  var playerSquare = this.getSquare(this.playerPos);
  var targetPos = this.playerPos.add(direction);
  var targetSquare = this.getSquare(targetPos);

  // Possibly pushing a boulder
  if (targetSquare.hasBoulder()) {
    var pushTarget = this.getSquare(targetPos.add(direction));
    if (pushTarget.isEmpty()) {
      targetSquare.moveContent(pushTarget);
    }
    else if (pushTarget.isExit()) {
      targetSquare.moveContent(pushTarget);
      pushTarget.clearContent();
      this.bouldersToGo--;
      this.updateScore();
    }
  }
  // Moving the player
  if (targetSquare.isEmpty()) {
    playerSquare.moveContent(targetSquare);
    this.playerPos = targetPos;
  }
};

最初に岩の処理を行うことで、プレイヤーが通常に移動する場合と岩を押す場合の両方で、移動コードを同じように動作させることができます。岩の後ろのマスが、directionplayerPosに2回加算することで見つけられることに注意してください。左に2マス移動してテストします。

testField.move(new Point(-1, 0));
testField.move(new Point(-1, 0));

うまくいった場合、岩を取り出すことができなくなった場所に岩を移動させたので、このフィールドを破棄した方が良いでしょう。

testField.remove();

すべての「ゲームロジック」は処理済みであり、あとはプレイ可能にするためのコントローラーが必要です。コントローラーはSokobanGameというオブジェクト型になり、以下のことを担当します。

再び未完成のプロトタイプから始めます。

var SokobanGame = {
  construct: function(place) {
    this.level = null;
    this.field = null;

    var newGame = dom("BUTTON", null, "New game");
    addHandler(newGame, "click", method(this, "newGame"));
    var reset = dom("BUTTON", null, "Reset level");
    addHandler(reset, "click", method(this, "reset"));
    this.container = dom("DIV", null,
                         dom("H1", null, "Sokoban"),
                         dom("DIV", null, newGame, " ", reset));
    place.appendChild(this.container);

    addHandler(document, "keydown", method(this, "keyDown"));
    this.newGame();
  },

  newGame: function() {
    this.level = 0;
    this.reset();
  },
  reset: function() {
    if (this.field)
      this.field.remove();
    this.field = SokobanField.create(sokobanLevels[this.level]);
    this.field.place(this.container);
  },

  keyDown: function(event) {
    // To be filled in
  }
};

コンストラクタは、フィールドを保持するためのdiv要素と、2つのボタンとタイトルを作成します。methodを使用して、thisオブジェクトのメソッドをイベントにアタッチする方法に注意してください。

このようにして、倉庫番ゲームをドキュメントに配置できます。

var sokoban = SokobanGame.create(document.body);

例 13.4

残るはキーイベントハンドラーを埋めることです。プロトタイプのkeyDownメソッドを、矢印キーの押下を検出し、見つけた場合はプレイヤーを正しい方向に移動させるメソッドに置き換えます。次のDictionaryが役立つでしょう。

var arrowKeyCodes = new Dictionary({
  37: new Point(-1, 0), // left
  38: new Point(0, -1), // up
  39: new Point(1, 0),  // right
  40: new Point(0, 1)   // down
});

矢印キーの処理後、this.field.won()をチェックして、それが勝利の移動だったかどうかを確認します。プレイヤーが勝利した場合は、alertを使用してメッセージを表示し、次のレベルに進みます。次のレベルがない場合(sokobanLevels.lengthを確認)、代わりにゲームを再開します。

イベントを処理した後、キー押下のイベントを停止することをお勧めします。そうしないと、矢印キーの上と下を押すと、ウィンドウがスクロールされ、非常に煩わしくなります。

SokobanGame.keyDown = function(event) {
  if (arrowKeyCodes.contains(event.keyCode)) {
    event.stop();
    this.field.move(arrowKeyCodes.lookup(event.keyCode));
    if (this.field.won()) {
      if (this.level < sokobanLevels.length - 1) {
        alert("Excellent! Going to the next level.");
        this.level++;
        this.reset();
      }
      else {
        alert("You win! Game over.");
        this.newGame();
      }
    }
  }
};

このようにキーをキャプチャすること ― documentにハンドラーを追加し、探しているイベントを停止すること ― は、ドキュメントに他の要素がある場合はあまり適切ではないことに注意する必要があります。たとえば、ドキュメントの上部にあるテキストフィールドでカーソルを動かしてみてください。― うまくいきません。倉庫番ゲームの小さな人を動かすだけです。このようなゲームを実際のサイトで使用する場合は、おそらく独自のフレームまたはウィンドウに入れるのが最適であり、独自のウィンドウを対象としたイベントのみをキャッチするようにする必要があります。


例 13.5

出口に運ばれると、岩はかなり突然消えます。Square.clearContentメソッドを変更することにより、削除される予定の岩の「落下」アニメーションを表示してみてください。それらが消える前に、少しの間小さくなるようにします。たとえば、style.width = "50%"、および同様にstyle.heightを使用して、画像を通常よりも半分小さく表示できます。

setIntervalを使用して、アニメーションのタイミングを処理できます。メソッドが終了後にインターバルをクリアするように注意してください。そうしないと、ページが閉じられるまで、コンピューターの時間を無駄にし続けます。

Square.clearContent = function() {
  self.content = null;
  var image = this.tableCell.lastChild;
  var size = 100;

  var animate = setInterval(function() {
    size -= 10;
    image.style.width = size + "%";
    image.style.height = size + "%";

    if (size < 60) {
      clearInterval(animate);
      removeElement(image);
    }
  }, 70);
};

さて、数時間暇があれば、すべてのレベルをクリアしてみてください。


役立つ可能性のあるその他のイベントタイプは、focusおよびblurです。これらは、フォーム入力などの「フォーカス」できる要素で発生します。明らかに、focusは、要素をクリックするなどしてフォーカスを要素に置いたときに発生します。blurは、JavaScriptでは「フォーカス解除」を意味し、フォーカスが要素から離れるときに発生します。

addHandler($("textfield"), "focus", function(event) {
  event.target.style.backgroundColor = "yellow";
});
addHandler($("textfield"), "blur", function(event) {
  event.target.style.backgroundColor = "";
});

フォーム入力に関連するもう1つのイベントはchangeです。これは、入力の内容が変更されたときに発生します...ただし、テキスト入力などの一部の入力では、要素がフォーカス解除されるまで発生しません。

addHandler($("textfield"), "change", function(event) {
  print("Content of text field changed to '",
        event.target.value, "'.");
});

好きなだけ入力できます。イベントは、入力の外側をクリックするか、タブキーを押すか、他の方法でフォーカスを解除した場合にのみ発生します。

フォームには、submitイベントもあり、送信時に発生します。送信が発生しないように停止できます。これにより、前の章で見たフォーム検証を行うためのはるかに良い方法が提供されます。submitハンドラーを登録するだけで、フォームの内容が有効でない場合にイベントを停止します。そうすれば、ユーザーがJavaScriptを有効にしていない場合でも、フォームは引き続き機能しますが、インスタント検証は行われません。

Windowオブジェクトには、ドキュメントが完全にロードされたときに発生するloadイベントがあり、スクリプトがドキュメント全体が存在するまで待機する必要がある初期化を行う必要がある場合に役立ちます。たとえば、この本のページにあるスクリプトは、現在の章を見て、演習の解決策を非表示にします。演習がまだロードされていない場合は、それを行うことはできません。また、ユーザーがドキュメントを離れるときに発生するunloadイベントもありますが、これはすべてのブラウザーで適切にサポートされているわけではありません。

ほとんどの場合、ドキュメントのレイアウトはブラウザーに任せるのが最善ですが、ドキュメント内の一部のノードの正確なサイズをJavaScriptで設定することによってのみ生成できる効果があります。これを行う場合は、ウィンドウのresizeイベントもリッスンし、ウィンドウのサイズが変更されるたびに要素のサイズを再計算してください。


最後に、知りたくないイベントハンドラーについてお話しする必要があります。Internet Explorerブラウザー(つまり、執筆時点では、大多数のWebサーファーが使用しているブラウザー)には、値が通常どおりにクリーンアップされないバグがあります。使用されなくなった場合でも、それらはマシンのメモリに残ります。これはメモリリークと呼ばれており、十分なメモリがリークすると、コンピューターの動作が著しく遅くなります。

このリークはいつ発生するのでしょうか? Internet Explorer のガベージコレクター(不要な値を回収するシステム)の欠陥が原因で、DOMノードがそのプロパティを通じて、またはより間接的な方法で、通常のJavaScriptオブジェクトを参照しており、さらにそのオブジェクトがDOMノードを参照し返している場合、両方のオブジェクトは回収されません。これは、DOMノードと他のJavaScriptオブジェクトが異なるシステムによって回収されるという事実に起因しています。DOMノードをクリーンアップするシステムは、JavaScriptオブジェクトによって参照されているノードをそのまま残すように注意し、通常のJavaScript値を回収するシステムも同様に機能します。

上記の説明が示すように、問題はイベントハンドラーに特有のものではありません。例えば、以下のコードは回収できないメモリを作成します。

var jsObject = {link: document.body};
document.body.linkBack = jsObject;

このようなInternet Explorerブラウザが別のページに移動した後でも、ここに示されているdocument.bodyを保持し続けます。このバグがイベントハンドラーと関連付けられることが多いのは、ハンドラーを登録する際にこのような循環リンクを作成するのが非常に簡単だからです。DOMノードは自身のハンドラーへの参照を保持し、ハンドラーはほとんどの場合、DOMノードへの参照を持ちます。この参照が意図的に作成されていない場合でも、JavaScriptのスコープルールが暗黙的に追加する傾向があります。この関数を考えてみてください。

function addAlerter(element) {
  addHandler(element, "click", function() {
    alert("Alert! ALERT!");
  });
}

addAlerter関数によって作成された匿名関数は、element変数を「見る」ことができます。それを使用していなくても、それは問題ではありません。見ることができるだけで、それへの参照を持つことになります。この関数を同じelementオブジェクトのイベントハンドラーとして登録することで、循環が作成されました。

この問題に対処するには3つの方法があります。1つ目のアプローチは非常に一般的なもので、無視することです。ほとんどのスクリプトではわずかなリークしか発生しないため、問題が顕著になるまでに長い時間と多くのページが必要になります。そして、問題がそれほど微妙な場合、誰があなたに責任を負わせるでしょうか? このアプローチを好むプログラマーは、Microsoftのずさんなプログラミングを厳しく非難し、問題は自分の責任ではなく、自分たちが修正すべきではないと主張することがよくあります。

もちろん、このような推論は完全に根拠がないわけではありません。しかし、ユーザーの半数があなたの作成したWebページで問題を抱えている場合、実際的な問題があることを否定することは困難です。そのため、「本格的な」サイトに取り組む人々は、通常、メモリリークが発生しないように努めます。これにより、2番目のアプローチに進みます。DOMオブジェクトと通常のオブジェクトの間で循環参照が作成されないように細心の注意を払うことです。これは、たとえば、上記のハンドラーを次のように書き換えることを意味します。

function addAlerter(element) {
  addHandler(element, "click", function() {
    alert("Alert! ALERT!");
  });
  element = null;
}

これで、element変数はDOMノードを指さなくなり、ハンドラーはリークしません。このアプローチは実行可能ですが、プログラマーは本当に注意を払う必要があります。

3つ目の解決策は、最終的には、リークを引き起こす構造の作成をあまり心配せず、使い終わったらそれらをクリーンアップすることです。これは、不要になったイベントハンドラーを登録解除し、ページがアンロードされるまで必要なハンドラーを登録解除するためにonunloadイベントを登録することを意味します。addHandler関数のようなイベント登録システムを拡張して、これを自動的に行うことができます。このアプローチを採用する場合、イベントハンドラーがメモリリークの唯一の発生源ではないことに注意する必要があります。DOMノードオブジェクトにプロパティを追加すると、同様の問題が発生する可能性があります。