画像の先読み

実現のためのコードはいたってシンプルです。以下のようにします。

var imgObj = new Image();
imgObj.src = "sample1.jpg";

JavaScriptでImageオブジェクトのsrcプロパティに画像アドレスを設定すると、その時点でブラウザが画像をダウンロードするのを利用しています。

以下は引数に画像アドレス配列を渡すと、一括して先読みしてくれる関数です。

function preload(imgs){
 for(var i = 0; i < imgs.length; i++){
   var imgObj = new Image();
   imgObj.src = imgs[i];
 }
}
preload(["sample1.jpg", "sample2.jpg", "sample3.jpg"]);

・・・が、これだけでは先読みの終了どころか、本当に先読みしてるのかもわかりません^^;

テストのために、先読みの経過を表示するコードを追加してみます。

<html>
<head>
<script type="text/javascript">
function preload(imgs){
  var objArray = [];
  for(var i = 0; i < imgs.length; i++){
    var imgObj = new Image();
    imgObj.src = imgs[i];
    objArray.push(imgObj);
  }
  var viewStatus = function(){
    var count = 0;
    var str = "";
    for(var i = 0; i < objArray.length; i++){
      if(objArray[i].complete) count++;
    }
    str += objArray.length + "件中" + count + "件完了";
    if(count == objArray.length){
      str += ":すべて完了しました。"
    }
    var viewElem = document.getElementById("view");
    if(viewElem) viewElem.innerHTML = str;
    if(count < objArray.length){
      setTimeout(viewStatus, 100);
    }
  };
  viewStatus();
}
function test(){
  preload(["http://f.hatena.ne.jp/images/fotolife/s/susie-t/20070407/20070407220053.jpg",
         "http://f.hatena.ne.jp/images/fotolife/s/susie-t/20070407/20070407215356.jpg",
         "http://f.hatena.ne.jp/images/fotolife/s/susie-t/20070407/20070407215255.jpg"]);
}
</script>
</head>
<body>
<div id="view"></div>
<button onclick="test();">preload</button>
</body>
</html>

preloadボタンを押すと先読みを開始します。で、状態を表示するのですが。。。これぐらいだとすぐに終わってしまいますね^^;大量の画像や重い画像を指定するとよく分かるのですが。手っ取り早く、存在しないアドレスを入れれば途中で止まりますが、ずっとsetTimeoutで回り続けますのでご注意を。また、キャッシュがあるとすぐに終わりますので、再度テストする場合はキャッシュを削除してください。

Imageオブジェクトのcompleteプロパティにより、画像読み込みが完了(=true)したかがわかります。これを利用しています。Imageオブジェクトのonloadイベントを使う手もありますが、ここでは省略させていただきます。(実際には、負荷の面から、1秒ごとにチェックするくらいが妥当な気がしてます。上記コードはテスト用なので0.1秒に設定しています。)

関数名に「onload」を使うと・・・

常識かもしれないけど、初めて知ったのでメモ。
http://q.hatena.ne.jp/1179461074 で回答した際、グローバル宣言の関数ならwindow["関数名"]でアクセスできることを利用しました。

function test(str){
  alert(str);
}
window["test"]("!!!");

ここで疑問が。「test」という名前を、「onload」にしたらどうなるのだろう・・・。

function onload(str){
  alert(str);
}
window["onload"]("!!!");

通常、windowオブジェクトのonloadプロパティは、ページ読み込み直後に実行される関数を設定する場所です。

結果は・・・。

動作的に素直なのはFirefoxのほうでしょうか。単に、イベント発生時にwindow.onloadに設定されている関数を実行するということです。

これに対し、IEOperaは、onloadという名前で関数が作成された場合、イベント発生時にwindow.onloadに設定されている関数を実行しない、という動作をします。

function onload(str){
  alert(str);
}
window["onload"]("!!!");
window.onload=function(){alert("onload!");};

上記コードは、IEOperaで「onload!」が表示されません。(Firefoxでは「!!!」、「onload!」の両方表示されます。)

つまり、グローバル宣言する変数名、関数名に、windowオブジェクトのメンバ名を使うべきでないということです。考えてみれば当たり前ですが。しかし、あんまり聞かない話のような。予約語でもないのでエラーにならないですし・・・。

ともかく、気をつけましょう^^;

【参考】windowオブジェクトのメンバを表示する
※IE、Firefoxで確認。Operaは動作せず。(windowオブジェクトのメンバが列挙可能でない)
※以下では表示されないメンバもあります(IEのalert等)。参考程度に。
<html>
<head>
</head>
<body>
<div id="test"></div>
</body>
</html>
<script>
(function (){
  var str = "<table border>";
  for(var i in window){
    str += "<tr><th>" + i + "</th><td>" + window[i] + "</td></tr>";
  }
  str += "</table>";
  document.getElementById("test").innerHTML=str;
})();
</script>

テロップを流すサンプル

question:1178848515の回答の修正です。

<!DOCTYPE HTML PUBLIC "-//W3C//DTD HTML 4.01 //EN">
<html>
<head>
<script type="text/javascript">
/**
 * スタイル値取得関数
 * elemID       : 対象要素ID
 * IEStyleProp  : IE用CSS属性名(aaaBbb形式)
 * CSSStyleProp : その他用CSS属性名(aaa-bbb形式)
 */
function getElementStyle(elemID, IEStyleProp, CSSStyleProp){
  var elem;
  if(typeof(elemID) == "string"){
    elem = document.getElementById(elemID);
  }else{
    elem = elemID;
  }
  if(elem.currentStyle){
    return elem.currentStyle[IEStyleProp];
  }else if(window.getComputedStyle){
    var compStyle = window.getComputedStyle(elem, "");
    return compStyle.getPropertyValue(CSSStyleProp);
  }
}
/**
 * テロップ表示関数
 * msgs : 表示メッセージ配列
 * len  : 移動距離(px)
 * time : 移動間隔(ms)
 * id   : テロップ表示要素ID
 */
function terop(msgs, len, time, id){
  var base = document.getElementById(id);
  var stylePosition = getElementStyle(base, "position", "position");
  if(stylePosition == "" || stylePosition == "static"){
    base.style.position = "relative";
  }
  var styleWidth = getElementStyle(base, "width", "width");
  if(styleWidth == "" || styleWidth == "auto"){
    base.style.width = "100%";
  }
  base.style.overflow = 'hidden';
  var msg = document.createElement("span");
  base.appendChild(msg);
  msg.style.position = "relative";
  msg.style.left = base.offsetWidth + "px";
  msg.style.whiteSpace = "nowrap";
  var i = 0;
  msg.innerHTML = msgs[i];
  var move = function(){
    if(parseInt(msg.style.left) < -(msg.offsetWidth)){
      msg.style.left = (base.offsetWidth + 1) + "px";
      i++;
      if(i >= msgs.length) i = 0;
      msg.innerHTML = msgs[i];
    }
    if(parseInt(msg.style.left) > base.offsetWidth){
      msg.style.left = base.offsetWidth + "px"; 
    }else{
      msg.style.left = (parseInt(msg.style.left) - len) + "px";
    }
    setTimeout(move, time);
  };
  move();
}
/**
 * ページ読み込み完了時実行関数
 */
function init(){

  terop([
    "こんなかんじで",
    "つぎつぎに",
    "流れていきます。"
  ], 1, 20, "terop1");

  terop([
    "<span style='color:blue;'>"
    + "あああああああああああああああああああああ"
    + "あああああああああああああああああああああ"
    + "</span>",
    "<span style='color:red;'>"
    + "いいいいいいいいいいいいいいいいいいいいい"
    + "いいいいいいいいいいいいいいいいいいいいい"
    + "</span>",
    "<span style='font-weight:bold;'>"
    + "ううううううううううううううううううううう"
    + "ううううううううううううううううううううう"
    + "</span>"
  ], 1, 10, "terop2");

};
</script>
<style type="text/css" media="screen">
.terop{
  border:solid blue 2px;
}
</style>
</head>
<body onload="init();">
<div id="terop1" class="terop"></div>
<div id="terop2" class="terop"></div>
</body>
</html>

2007/11/13 文字列の自動折り返しを禁止しました。(<br/>タグでの折り返しはできます)
2007/09/20 移動間隔とテロップ表示要素IDを引数で指定できるようにしました。
2009/03/11 テロップ表示要素がリサイズされて文字列が要素から外れた場合、表示要素の左端から文字列の表示を開始するようにしました。

theater.jsのサイト開設

theater.jsのサイトを開設しました。(まだ内容が不十分ですが)

http://susie-t.main.jp/

製作途中の自作ライブラリの解説を、ブログでするのは無理がある気がしたので^^;

今後はtheater.jsの解説を、上記サイトで行っていく予定です。

基本メニュー表示と結果取得

アドベンチャーゲームを作るという以上、ユーザに入力を促し、その結果によってシナリオを分岐させる機能は必須です。このとき、モーダル(modal)であること*1がより望ましいです。これを実現するのに最も簡単なのはJavaScriptのconfirm、およびpromptを利用することです。(参考:できるだけ単純にJavaScriptでアドベンチャーゲームを作る。 - Backstage of theater.js) しかし、デザイン・機能的に全く融通が利かないのがツライところです。*2

そこで、PrototypeWindowClassのように半透明の要素でブラウザウィンドウを覆い、その上にメニューを表示するようにしてみました。PrototypeWindowClassをそのまま使うこともできるのですが、高機能すぎて重い(^^;ので、自作しました。

これを使用して、Theaterクラスに以下のメソッドを作成しました。

alertメソッド

【使用例】
new Theater({
  scenario: [
    function(){
      this.alert("あらーと1");
    },
    function(){
      this.alert("あらーと2");
    }
  ]
});

シナリオ(関数配列)内の関数で「this.alert(表示文字列);」として使用します。指定文字列とOKボタンが表示されます。OKボタンを押すと閉じ、自動的に次の関数を実行します。なので、上記は「あらーと1」「あらーと2」が続けて表示されます。

confirmメソッド

【使用例】
new Theater({
  scenario: [
    function(){
      this.confirm("かくにん");
    },
    function(result){
      this.alert("けっか:" + result);
    }
  ]
});

シナリオ(関数配列)内の関数で「this.confirm(表示文字列);」として使用します。指定文字列と「はい」と「いいえ」のボタンが表示されます。「はい」か「いいえ」のいずれかのボタンを押すと、閉じて次の関数を実行します。このとき、関数の引数に結果が設定されます。「はい」が押された場合はtrue、「いいえ」が押された場合はfalseです。上記例では表示しているだけですが、これによりシナリオを分岐させることもできます。

promptメソッド

【使用例】
new Theater({
  scenario: [
    function(){
      this.prompt("なにかいれてね", "でふぉると");
    },
    function(result){
      this.alert("けっか:" + result);
    }
  ]
});

シナリオ(関数配列)内の関数で「this.confirm(表示文字列[, デフォルト値]);」として使用します。デフォルト値は省略可能です。指定文字列とテキストボックス、「入力」ボタンを表示します。「入力」ボタンを押すと、閉じて次の関数を実行します。このとき、関数の引数にテキストボックスに入力された値が設定されます。

上記3メソッドで表示されるメニューは、いずれも画像ボックス*3の中心に表示され、ドラッグ可能です。

これ以外のメニューを作成する方法については今後解説します。

とりあえず、例です。promptメソッドで名前入力を促し、未入力ならalertメソッドでメッセージを出して再入力を促します。入力ありならconfirmメソッドで入力を確認してもらい、「いいえ」が押されたら入力画面に戻ります。「はい」が押されたら次へ遷移します。

http://susie-t.seesaa.net/article/35350351.html

以下は上記例のブログ内で記述したコードです。(ファイルの読み込みはブログの共通設定で行っています。)

<button id='d20070306reset' class='reset'>
はじめから</button>
<div id="d20070306play" class='play'>
<div id="d20070306frame" class='frame'></div>
<div id="d20070306msg" class='msg'></div>
</div>
<script type="text/javascript">
var d20070306 = {}
d20070306.imgRoot = 'http://susie-t.up.seesaa.net/image/'
d20070306.img = {
  sample1: d20070306.imgRoot + 'sample1.gif',
  sample2: d20070306.imgRoot + 'sample2.gif',
  sample3: d20070306.imgRoot + 'sample3.gif'		
};
d20070306.scenario = [
  function (){
    this.work.name = "";
    this.act({
      img: d20070306.img.sample1,
      msg: '名前入力テスト'
    });
  },
  function (){
    this.act({
      img: d20070306.img.sample2,
      msg: 'お名前を入力してください'
    });
  },
  function (){
    this.push([
      function(){
        this.prompt(
          "お名前を入力してください",
          this.work.name
        );
      },
      function (result){
        this.work.name = result;
        if(result == ''){
          this.back();
          this.alert("お名前が入力されていません><;");
        }else{
          this.confirm("あなたのお名前:"
          + result + "<br/>よろしいですか?");
        }
      }
    ]);
    this.play();
  },
  function (result){
    if(result){
      this.act({
        img: d20070306.img.sample3,
        msg: "ようこそ" + this.work.name + "さん"
      });
    }else{
      this.back();
      this.play();
    }
  },
  function (){
    this.work.name = "";
    this.act({
      img: d20070306.img.sample1,
      msg: '名前入力テストおわり。'
    });
  }
];
new Theater({
  frameId: 'd20070306frame',
  msgId: 'd20070306msg',
  resetId: 'd20070306reset',
  playId: 'd20070306play',
  filter: ImageChanger.getFilter({no:10}),
  outEffect:'Fade',
  inEffect:'Appear',
  scenario: d20070306.scenario,
  img: d20070306.img
});
</script>

・・・説明しなきゃいけないことが多いな><;

Theaterクラスインスタンス作成時にframeId等を指定しています。無指定時はデフォルトで'frame'等が割り振られるのですが、同ページに複数Theaterクラスの利用があると、競合してしまうので個別のIDを振る必要があります。なので、いちいち設定しています。また、競合を避けるためd20070306オブジェクトを作成・利用しています。・・・分かりづらくてすみません。

Theaterクラスインスタンス作成時のoutEffect、inEffectオプションは、IE以外にも画像切替効果をつけようとして追加したオプションです。値はscriptaculousのEffectに準じています・・・が、全部が動くわけではありません^^; とりあえずoutEffect(消滅時)に'Fade'、innEffect(出現時)に'Appear'は設定できます・・・。詳しくは今後解説します。

pushメソッドはサブシナリオを設定するメソッドです。引数はシナリオ(関数配列)です。サブシナリオが終了すると、親のシナリオへ復帰します。

backメソッドは、シナリオを「ひとつ戻す」メソッドです。ただし、これにより必ずしも「直前のシナリオ関数」が実行できるわけではありません。やっていることは、当該シナリオのインデックスを減算*4するだけです。このため、サブシナリオから復帰した直後の場合、backメソッドを使用してもサブシナリオへは戻れません。

ここでは、この特性をシナリオ作成に利用しています。サブシナリオ直後に判定し、結果によってbackし、再度サブシナリオを実行させています。

back以外にも、シナリオ操作メソッドとしてjump(指定インデックスへ飛ぶ)、move(現インデックスから指定数減算・加算する)も用意していますが、この二つの使用はあまりお勧めできません。シナリオ修正が異常にしにくくなるためです。たとえば、上記のback+サブシナリオの方法であれば、サブシナリオ部分の修正はbackメソッドに影響しません。サブシナリオを使用せずにmoveメソッドを使用すると、戻る先からbackメソッド間でシナリオ関数の追加・削除が発生すると、moveの引数を変更する必要があります。そして、これは非常に気づきにくいです。jumpメソッドについても同様の理由です。

pushやbackは、それだけでは「シナリオを設定する」だけで、実行はしません。直後に実行したい場合はplayメソッドを呼び出します。ただし、各種メニューを呼び出した場合は、閉じると自動的に次のシナリオ関数が呼び出されます。

・・・この辺りの仕様は改善の余地があるかもしれません。

以上。あ゛ー。分かりにくいですねぇ・・・TT

なにかご質問がありましたらコメントORメールください・・・。

*1:他の操作ができないこと

*2:IEならwindow.showModalDialogを使うという手もあります。FireFoxではwindow.openの表示オプションでmodalが指定できるようです。(ただし特権の許可が必要とのこと。未確認)

*3:id=frameもしくはTheaterインスタンス作成時のオプションframeIdで指定されたIDの要素

*4:-2する。インデックスは常に次のシナリオ関数を指しているため

ブログでアドベンチャーゲーム試作(ちょっと無理やり)

↑こんなタイトルですが、はてなスクリプトタグが使えないので、seesaaブログに間借りしました^^;

http://susie-t.seesaa.net/category/2731315-1.html

こんな感じのブログもアリじゃないかなと思う次第。

ただ、やっぱり重いです・・・。それにこのままだと、複数日表示時に問題が。もうちょっと工夫する必要があります。やっぱり趣味の世界ですねぇ。。。

Menu表示機能も追加し、ほぼ完成しました。前Verをスクラップしてから結構速く仕上がったと思います。あのときはどうなることかと思いましたが。

seesaaで遊びながら、こちらで解説をしていきます。

Positionオブジェクトの枠線幅問題対策

FirefoxのBODY枠線問題に対応しました。

【関連】
Positionオブジェクト(1) - Backstage of theater.js
Positionオブジェクト(3) - Backstage of theater.js
offsetTop/offsetLeft/offsetParentの闇 - Backstage of theater.js

prototype.jsのPosition.cumulativeOffset、Position.positionedOffset、Position.pageメソッドは、対象要素の上位offsetParentに枠線幅があると、正しく値を取得できない場合があります。これは、要素のoffsetTop/LeftプロパティがoffsetParent側の基準を枠線内側においている場合があり、このためメソッド処理中の各offsetTop/Left累積時に枠線幅分が加算されないのが理由です。

対策としては、offsetParent側の基準が枠線内側の場合、枠線幅を加算すればいいのですが、基準が枠線内側か外側かの条件は単純ではありません。特に、FireFoxNetscapeのTABLE要素内の要素に関しては嫌になるほど複雑です。

・・・なので、TABLE要素に関してはあきらめることにしました^^;

それ以外の場合ですが、現在分かっているのは

なのですが、今後の変更への対応、また他の環境にも対応させるために、以下のように判定することにします。

  • 枠線幅を持つDIV要素A内に、DIV要素Bをひとつだけ作成。要素Aにはposition:absolute;を指定してoffsetParentの資格を与える。
  • 要素BのoffsetLeftをチェック。0なら基準は枠線内側。1以上(=要素Aの枠線幅)なら基準は枠線外側。

上記チェックで基準枠線内側の場合のみ、枠線幅を加算するようにします。

以下、改修案です。ここではファイル名を「prototype_extend.js」とし、prototype.jsの後に読み込んで上書きすることを想定しています。

【prototype_extend.js】

Object.extend(Position, {
  positionedOffset: function(element) {
    var valueT = 0, valueL = 0;
    do {
      valueT += element.offsetTop  || 0;
      valueL += element.offsetLeft || 0;

      if(!this.isIncludeBorder){
        var border = this.getBorder(element.offsetParent);
        valueT += border[0];
        valueL += border[1];
        if(element.offsetParent == document.body
        && this.isMinusBodyBorder){
          valueT += border[0];
          valueL += border[1];
        }
      }

      element = element.offsetParent;
      if (element) {
        if(element.tagName=='BODY') break;
        var p = Element.getStyle(element, 'position');
        if (p == 'relative' || p == 'absolute') break;
      }
    } while (element);
    return [valueL, valueT];
  },

  page: function(forElement) {
    var valueT = 0, valueL = 0;

    var element = forElement;
    do {
      valueT += element.offsetTop  || 0;
      valueL += element.offsetLeft || 0;

      if(!this.isIncludeBorder){
        var border = this.getBorder(element.offsetParent);
        valueT += border[1];
        valueL += border[0];
        if(element.offsetParent == document.body
        && this.isMinusBodyBorder){
          valueT += border[0];
          valueL += border[1];
        }
      }
      
      // Safari fix
      if (element.offsetParent==document.body)
        if (Element.getStyle(element,'position')=='absolute') break;

    } while (element = element.offsetParent);

    element = forElement;
    do {
      valueT -= element.scrollTop  || 0;
      valueL -= element.scrollLeft || 0;
    } while (element = element.parentNode);

    return [valueL, valueT];
  },
  
  getBorder: function(element){
    if(element == null){
      return [0, 0];
    }
    var top , left, style;
    style = Element.getStyle(element, 'border-top-style');
    if(style != 'none'){
      top  = parseInt(Element.getStyle(element, 'border-top-width'));
    }
    style = Element.getStyle(element, 'border-left-style');
    if(style != 'none'){
      left = parseInt(Element.getStyle(element, 'border-left-width'));
    }
    top  = (isNaN(top))  ? 0 : top;
    left = (isNaN(left)) ? 0 : left;
    return [left, top];
  },
  
  setIsIncludeBorder: function(){
    var id = (new Date()).getTime();
    new Insertion.Bottom(document.body,
      "<div id='parent_" + id + "'" 
      + " style='border:solid blue 10px; padding:0px;"
      + " position:absolute; visibility:hidden;'>"
      + " <div id='child_" + id + "'></div></div>");
    
    if($('child_' + id).offsetTop == 0){
      this.isIncludeBorder = false;
    }else{
      this.isIncludeBorder = true;
    }
    Element.remove('parent_' + id);

    var top = document.body.offsetTop;
    var margin = parseInt(Element.getStyle(document.body, "margin-top"));
    if(isNaN(margin)) margin = 0;
    if(top < margin){
      this.isMinusBodyBorder = true;
    }else{
      this.isMinusBodyBorder = false;
    }
  }
});
Event.observe(window, 'load', Position.setIsIncludeBorder.bind(Position));

if (/Konqueror|Safari|KHTML/.test(navigator.userAgent)) {
  Position.cumulativeOffset = function(element) {
    var valueT = 0, valueL = 0;
    do {
      valueT += element.offsetTop  || 0;
      valueL += element.offsetLeft || 0;
      
      if(!this.isIncludeBorder){
        var border = this.getBorder(element.offsetParent);
        valueT += border[1];
        valueL += border[0];
        if(element.offsetParent == document.body
        && this.isMinusBodyBorder){
          valueT += border[0];
          valueL += border[1];
        }
      }
      
      if (element.offsetParent == document.body)
        if (Element.getStyle(element, 'position') == 'absolute') break;

      element = element.offsetParent;
    } while (element);

    return [valueL, valueT];
  }
}else{
  Position.cumulativeOffset = function(element) {
    var valueT = 0, valueL = 0;
    do {
      valueT += element.offsetTop  || 0;
      valueL += element.offsetLeft || 0;
      
      if(!this.isIncludeBorder){
        var border = this.getBorder(element.offsetParent);
        valueT += border[1];
        valueL += border[0];
        if(element.offsetParent == document.body
        && this.isMinusBodyBorder){
          valueT += border[0];
          valueL += border[1];
        }
      }
      element = element.offsetParent;
    } while (element);
    return [valueL, valueT];
  }
}

以下、例です。

<!DOCTYPE HTML PUBLIC "-//W3C//DTD HTML 4.01 //EN">
<html>
<head>
<title></title>
<style>
#parent{
  left:200px;
  background-color:yellow;
  position:relative;
  width:200px;
  height:200px;
}
#mark{
  background-color:red;
  position:relative;
  top:100px;
  left:100px;
  width:20px;
  height:20px;
}
</style>
<script language="javascript" src="prototype.js" charset="utf-8"></script>
<script language="javascript" src="prototype_extend.js" charset="utf-8"></script>
<script>
function test(){
  var str = "Position.cumulativeOffset($('mark')) = "
          + Object.inspect(Position.cumulativeOffset($('mark'))) + "<br/>"
          + "$('mark').offsetLeft/Top = "
          + $('mark').offsetLeft + ", " +  $('mark').offsetTop;
  Element.update('view', str);
}
function switchBorder(elm){
  var style = (elm.checked) ? {'border':'solid blue 20px'} : {'border-style':'none'};
  Element.setStyle('parent', style);
}
</script>
</head>
<body>
<div id="parent">
<div id="mark">
MARK
</div>
</div>
<button id="test" onclick="test();">TEST</button>
<input type='checkbox' onclick='switchBorder(this);'/>
<div id="view"></div>
</body>
</html>

TESTボタンを押すと、要素MARKのPosition.cumulativeOffsetの結果を表示します。チェックボックスにチェックを入れると、親要素(黄色)に枠線(青)が付きます。それから再度TESTボタンを押すと、IEFirefoxNetscapeでは値が変わると思います。枠線は20pxを指定したので、+20です。prototype_extend.jsを読み込まないと、この値は変わりません。Operaでは、枠線があっても余計な加算はされません。

先に述べたように、TABLE要素内の要素に関しては対象外です。

不具合がありましたらご一報ください・・・。