Monthly Archives: 10月 2014

2014-10-30 ,

加速度センサーを使ったJavaScriptスキーゲーム

左右の傾きを検出できるようになったところなので何か応用を。……スキーゲームでも作りましょうか。

適当にどんな感じにするか決めて……(汚い字でスミマセン)

wpid-wp-1414652864075-150x150.jpeg

作ったのが以下(単独のプレイページはこちら)。


デバイス左右の傾きでスキーヤーが左右に動きます。ゲートをくぐって得点を競いましょう。

一応キーボードにも対応しました。あえて傾き操作のみにしようと思っていたのですが、開発中のテストプレイが面倒だったので対応しました。でも傾けるインタフェースに合わせてわざと操作しづらくしてあります。

ソースコードはGitHubで。

あ、そういえば、Nexus7(2012)で試したらdeviceorientationを使った方法だと姿勢が安定しなかったので、devicemotionを使って傾きを検出しています。Nexus5やXperia Z2だとどちらを使ってもそれほど違いは無かったのですが、Nexus7(2012)だと大きく違いが出ました。キャリブレーションすれば直るかもしれませんが、一応大丈夫な方を取りました。

スキーヤーの当たり判定は体の前半分にしかありません。なので、後ろ半分はポールに当たっても大丈夫です。

フィールドの座標系は上がY正方向、右がX正方向、原点はスタート地点下部中央という取り方をしてみました。慣れない座標系を使うと色々ミスをしますね。パパッと作りたい場合は普通にY正方向が画面下になる座標系の方が良かったかなぁと思ったり。

メモ書きではleftWallとrightWall(左右の黒い部分)というオブジェクトを見いだしていたのですが、実際に作っている途中でroad(中央の白い部分)にしました。壁だと左右を別々に管理しなければならず、かといって壁を延ばすときは同じ長さだけ伸ばすように注意しなければならないなど、少し無駄に面倒な気がしたので。

左右移動の速度調整が一番面倒でした。

あとはゲートの出現頻度。1分半(90000ms)までは100フレーム毎から40フレーム毎まで単調に減少していき、それ以降は(20+40*(100000/(100000+(time-90000))))フレーム毎という緩やかに漸近線をとりながら減少していくのを基本としつつランダムで±20フレームの揺れを入れてみました。2~3分くらいからもう少し沢山出た方が忙しくなって良いかもしれないけどどうかなぁ。

2014-10-28

JavaScriptでデバイスを左右に傾けて移動するユーザーインタフェース

デバイスの向きが取得できるようになったところなので、実際の応用を考えてみることにします。画面の中に何かが表示されていて、右に傾けたら右へ、左に傾けたら左へ移動するようなユーザーインタフェースというのはどうでしょう。

左右の傾きを検出するにはどうしたら良いでしょうか。

さて、そもそも「左右の傾き」とはいったい何でしょうか。

deviceorientationのgammaでしょうか? gammaはデバイスのY軸、つまり画面縦方向を軸とした回転角でした。確かに机の上にデバイスを水平において、少し左右に傾けたときの姿勢はY軸回りの回転だけで表されそうです。

しかしデバイスを手で持って顔の前で使っている場合はどうでしょう。左右に傾けるというのはZ軸(画面奥から手前に向けての軸)回りの回転ではないでしょうか。

仰向けに寝そべって天井に向けてデバイスを持っている場合はどうでしょう。これはY軸の回転で左右操作をしそうですね。人によってはZ軸で操作するかもしれません。

これらに共通するものは何でしょうか。人は左右の傾きを何を基準に入力しているのでしょうか。

おそらく、X軸(デバイス横方向の軸)の傾きではないでしょうか。つまり、X軸の正方向(右方向)が地を向けば右、逆に天を向けば左なのではないでしょうか。これはデバイス(板)の上に乗っているボールが重力に沿って左右に動く様を想像しているのかもしれません。

となれば、デバイスのX軸が地面に対して何度傾いているかを計算すれば、左右の傾きになりそうです。

デバイスの向きがdeviceorientationイベントのalpha, beta, gammaで表されるとき、デバイス座標rから地球座標r'への変換は次の式になるのでした。

r' = matRotZ(alpha)*matRotX(beta)*matRotY(gamma)*r

(rとr'は列ベクトル、matRotZ(alpha)はZ軸回りにalpha度回転する変換行列)

今知りたいのはデバイスのX軸正方向を地球座標へ変換したときにZ軸(地面に対して垂直な軸)方向が正になるか負になるかです。

デバイスのX軸正方向を代表する座標 r=[1,0,0] を変換元にして、行列を全て結合すると次のようになります。

r'= [ cos(alpha)*cos(gamma)-sin(alpha)*sin(beta)*sin(gamma) ]
    [ sin(alpha)*cos(gamma)+cos(alpha)*sin(beta)*sin(gamma) ]
    [ -cos(beta)*sin(gamma)                                 ]

つまり、r'_z=-cos(beta)*sin(gamma) が答えになります。これが正なら左に傾き、負なら右に傾いていることになります。

地面と平行な面との角度を知りたければsin^-1をとります。斜辺が長さ1のデバイスX軸正方向に沿ったベクトル、垂線がr'_zの三角形を思い浮かべれば分かると思います。

th = sin^-1(-cos(beta)*sin(gamma))

これだと画面の回転を考慮していません。デバイスの自然な向きで使用しているときは問題ありませんが、portraitやlandscapeといった画面の向きを変えるとおかしな事になります。

それを補正するために、screen.orientation.angleを使用します(もしこのプロパティが使えないならlockOrientationで画面の向きを固定するか、諦めて他の入力方法に切り替えた方が良いかもしれません)。

画面上の座標からデバイス座標へ変換するには、screen.orientation.angleだけZ軸まわりに逆回転させます。なので、画面上の座標から地球座標へ変換する式は次のようになります。

r' = matRotZ(alpha)*matRotX(beta)*matRotY(gamma)*matRotZ(-screen.orientation.angle)*r

先ほどと同じように行列を結合すると次のようになります。

angle = -screen.orientation.angle
r' = [ cos(alpha)*cos(gamma)*cos(angle) -sin(alpha)*( cos(beta)*sin(angle)+sin(beta)*sin(gamma)*cos(angle) ) ]
     [ sin(alpha)*cos(gamma)*cos(angle) +cos(alpha)*( cos(beta)*sin(angle)+sin(beta)*sin(gamma)*cos(angle) ) ]
     [ sin(beta)*sin(angle)-cos(beta)*sin(gamma)*cos(angle)                                                  ]

角度は次のようになります。

th = sin^-1(sin(beta)*sin(angle)-cos(beta)*sin(gamma)*cos(angle))

最終的なJavaScriptのコードは次のようになります。

function toRad(deg){return deg*(Math.PI/180);}
function toDeg(rad){return rad*(180/Math.PI);}
function getLeftTiltAngle(ev){
    // r = rotz(alpha)*rotx(beta)*roty(gamma)*rotz(-screen.orientation.angle)*colvec[1,0,0]
    // tilt = sin^-1(r.z)
    var b = toRad(ev.beta);
    var c = toRad(ev.gamma);
    var d = toRad(-screen.orientation.angle);
    var rz = Math.sin(b)*Math.sin(d)-Math.cos(b)*Math.sin(c)*Math.cos(d);
    return toDeg(Math.asin(rz));
}

以上の論法はDeviceOrientation Event SpecificationのWorked Exampleに書いてあるのとほとんど同じです。そちらはデバイス背面方向の方位を求める例ですね。

他にもdevicemotionイベントを使用して傾きを求める方法もあります。傾きを求めるだけならばコンパス(方位)の情報は不要なので、むしろこちらの方が本筋かもしれません。accelerationからaccelerationIncludingGravityを引くと重力加速度のベクトルが得られます。そのベクトルからデバイスX軸正方向の成分だけを取り出せば良いのです。もちろん画面の向きを考慮するなら、X軸正方向のベクトルをscreen.orientation.angleでZ軸回りに逆回転させて、そのベクトルと重力加速度のベクトルとの内積を求めます。JavaScriptのコードにすると次のようになります。

function getLeftTiltAngleFromMotionEvent(motionEvent){
  var gravityX = motionEvent.acceleration.x - motionEvent.accelerationIncludingGravity.x;
  var gravityY = motionEvent.acceleration.y - motionEvent.accelerationIncludingGravity.y;
  var gravityZ = motionEvent.acceleration.z - motionEvent.accelerationIncludingGravity.z;
  var screenAngleRev = toRad(-screen.orientation.angle);
  var rightX = Math.cos(screenAngleRev);
  var rightY = Math.sin(screenAngleRev);
  var dotGR = rightX * gravityX  + rightY * gravityY;
  var rightGravity = dotGR / Math.sqrt(gravityX*gravityX+gravityY*gravityY+gravityZ*gravityZ);
  return -(90 - toDeg(Math.acos(rightGravity)));
}

両方の方法を試してみましたが、deviceorientationを使った方が若干安定しているように見えます。devicemotionを使った方は、デバイスを振ったときに角度が大きく変化してしまいました。

以下、動作例。対応しているブラウザだと赤いバーが左右に動くはず。

2014-10-28

JavaScript DeviceOrientationでデバイスの向きや加速度を得る

JavaScriptのDeviceOrientationイベントについて調べたついでに、向きや加速度をグラフで表示するものを作りました。Android(Nexus5, Xperia Z2 Tablet)のChromeとFirefoxで動作を確認。

DeviceOrientationイベントでは、デバイスの標準的な画面の向きに対して右がX軸正方向、上がY軸正方向、手前がZ軸正方向という座標系をデバイス座標フレーム(Device coordinate frame)と称して、それを前提にした回転角や加速度が色々と得られます。

例えばwindowオブジェクトのdeviceorientationイベントが発生したとき、そのイベントオブジェクトのalpha, beta, gammaプロパティの値はそれぞれZ軸, X軸, Y軸まわりの回転角(度数単位)を表します。X軸正方向を東、Y軸正方向を北、Z軸正方向が天頂を指すような姿勢を基準として、そこから現在のデバイスの姿勢への回転角が取得できます。ただし、absoluteプロパティがfalseの時は、そのような絶対的な値ではない、何らかの近似値となるようです(詳細不明)。角度が得られない場合、各プロパティはnullになります。

加速度はdevicemotionイベントで得られます。accelerationプロパティとaccelerationIncludingGravityプロパティではデバイス座標フレームでの角軸方向の加速度(m/s^2)が得られます。accelerationは手に支えられて落下しないときに0、accelerationIncludingGravityは自由落下(無重力)の時に0(手で持っているときは天頂方向へ約9.8m/s^2加速)となります。(2014-12-16追記: AndroidとiOSとで軸の向きが逆になっているようです。右へ加速させたとき、Androidだとxは正になりますがiOSだと負になります。yもzもそれぞれ逆向きです)

devicemotionイベントのrotationRateプロパティでは角速度がdeg/sの単位で得られます。

devicemotionイベントのintervalプロパティでは加速度検出の分解能がミリ秒で得られるようです。ただ、Android版のFirefoxでは100msを返しておきながら、数ミリ秒(5ms前後)間隔というきわめて細かい単位でイベントが発生しており、どうなっているのかよく分かりません。イベント毎にグラフを更新したら重すぎてしかたがなかったので、ある程度間引く処理を入れました。

ちなみに、deviceorientationイベントの方は定期的にイベントが起きるわけではなく、姿勢に変化が起きたときか、または、新しいリスナーが登録されたときとのことです。

一番注意しなければならないのは、デバイス座標フレームはあくまでデバイスの姿勢を基準にしたものであって、画面の内容を基準にしたものでは無いという点です。つまり、画面の向き(screen.orientation)が縦(portrait)であろうが横(landscape)であろうが、得られる値はデバイスの標準的な向きを基準にした値のみが得られます。なので、画面の向きを基準にしたい場合は、何らかの補正処理が必要になります。

補正にはscreen.orientation.angleプロパティが使えそうですが、対応していないブラウザも多いようです。screen.orientation.typeでは4種類の向きの内いずれかが分かりますが、結局どれが標準的な向きなのかが分からないので困ります。向きをscreen.orientation.lock("natural")してしまえば良いのかもしれませんが、それで済むとも限らないですよね。どうすれば良いんでしょうね。

2014-10-22 ,

KB3000988でインストールが遅いのは解決せず

KB2918614でインストールが遅くなった件ですが、10/13にWindows Installerに関する新しい更新プログラム KB3000988 が提供されたので、問題が解決されたか確認しました。

「コンポーネント登録を更新しています」の段階で長時間待たされ、その間インストールする全てのファイルを読み込んでおり、その後、ファイルをコピーする段階で再度全てのファイルを読み込むという、二度読みする挙動はそのまま変わっていませんでした。遅い。遅すぎる。どうするんでしょうね、これ。

2014-10-21

2014秋の新番組

一通り見終わった、かな?

うーん、パッとしない。

2014-10-21

CSS Transformが適用されている要素内でのマウスカーソルの位置を求める

こういうことが簡単にできないのがJavaScriptの恐ろしいところですネ。従来は要素のoffsetParent, offsetLeft, offsetTopgetBoundingClientRect なんかを使うのが定石でしたが(これもどうかと思うけど)、最近はCSS Transformなんてものがあるので困ってしまうわけです。CSSOM View Moduleに書いてある offsetLeft, offsetTop の説明にはtransformの影響を受けないと書かれています。影響を受けたところで、座標変換のためには伸縮や回転を考慮しなければならないので、話がややこしくなるだけでしょう。GeometryUtilsなんて面白そうなインタフェースが書かれていますが、今のところ中身は無し。何も決まっていないようです。これが出来れば将来的にはもっと楽に出来るようになるのかも?

WebKitの場合 webkitConvertPointFromPageToNode というそのものズバリの関数があるので、使える場合はおとなしくそれを使いましょう。Firefoxの場合MouseEventにlayerX,layerYというプロパティがあり、どうもそれが要素上の座標を示しているっぽいのですが、微妙にずれていたりしてよく分からないし、対応しているのはMozilla系だけなので無視します。

プラットフォーム側でサポートが無いのであれば、自分で行列を作って変換するのが良さそうです。transformプロパティには結構色々なことが書けるのですが、それもきちんと解析しないといけません。webkitCSSMatrixが他のブラウザでも使えるようになれば少しは楽になるかもしれませんが、まぁ、そんなことを言っていても仕方ないです。

まずはおさらいから。offsetParent,offsetLeft,offsetTopを使うとページ左上からの位置をたどることが出来ます。

offsetParentはparentNodeとは色々と異なります。offsetLeft,offsetTopは単純に一つ上の親からの相対位置にはならないんですね。なので、一工夫して一つ上の親からの相対位置に分解します。親と子でoffsetParentが同じなら子のoffsetLeftから親のoffsetLeftを引けば、親から子への相対位置が求められます。ただしoffsetParentがnullのときはルート、body要素、position:fixedな要素のいずれかです(Firefoxはposition:fixedでnullを返さないバグあり)ので、それ以上親を考慮する必要はありません。offsetParentがparentNodeと一致する場合は、親=offsetParentなので、offsetLeftがそのまま親からの相対位置になります。それらを考慮して親からの相対位置を求める関数relPosX(elem),relPosY(elem)を作ってみました。

    function relPosX(elem){
        return !elem.offsetParent ? elem.offsetLeft :
            elem.parentNode == elem.offsetParent ? elem.offsetLeft :
            elem.parentNode.offsetParent == elem.offsetParent ? elem.offsetLeft - elem.parentNode.offsetLeft :
            0;
    }
    function relPosY(elem){
        return !elem.offsetParent ? elem.offsetTop :
            elem.parentNode == elem.offsetParent ? elem.offsetTop :
            elem.parentNode.offsetParent == elem.offsetParent ? elem.offsetTop - elem.parentNode.offsetTop :
            0;
    }

この関数が返す値はtransformを考慮しません。なので、完全な相対位置を求めるには、transformプロパティとtransform-originプロパティを解析する必要があります(3D変形は今回は対応しません)。

transform プロパティの文字列を解析して変換行列を返す関数parseCSSTransformを作りました。

    function parseCSSTransform(tformStr)
    {
        //http://www.w3.org/TR/css3-transforms/#transform-property
        var funStrs = tformStr.split(")");
        funStrs.pop();

        var mat = matNewIdentity();

        for(var i = 0; i < funStrs.length; ++i){

            var funNameValue = funStrs[i].split("(");
            var fun = funNameValue[0].trim();
            var args = funNameValue[1];
            switch(fun){
            // 2D Transform Functions
            // http://www.w3.org/TR/css3-transforms/#two-d-transform-functions
            case "matrix":
                matMul(mat, mat, matNewMatrix3x2.apply(null, args.split(",")));
                break;
            case "translate":
                var xy = args.split(",");
                matMul(mat, mat, matNewTranslate(parseFloat(xy[0]), parseFloat(xy[1] || "0")));
                break;
            case "translateX":
                matMul(mat, mat, matNewTranslate(parseFloat(args), 0));
                break;
            case "translateY":
                matMul(mat, mat, matNewTranslate(0, parseFloat(args)));
                break;
            case "scale":
                var sxy = args.split(",");
                matMul(mat, mat, matNewScale(parseFloat(sxy[0]), parseFloat(sxy.length >= 2 ? sxy[1] : sxy[0])));
                break;
            case "scaleX":
                matMul(mat, mat, matNewScale(parseFloat(args), 1));
                break;
            case "scaleY":
                matMul(mat, mat, matNewScale(1, parseFloat(args)));
                break;
            case "rotate":
                matMul(mat, mat, matNewRotate2D(toRadian(args)));
                break;
            case "skew":
                var wxy = args.split(",");
                matMul(mat, mat, matNewSkew2D(toRadian(wxy[0]), toRadian(wxy[1] || "0")));
                break;
            case "skewX":
                matMul(mat, mat, matNewSkew2D(toRadian(args), 0));
                break;
            case "skewY":
                matMul(mat, mat, matNewSkew2D(0, toRadian(args)));
                break;
            //http://www.w3.org/TR/css3-transforms/#three-d-transform-functions
            ///@todo support 3D Transform Functions
            }
        }
        return mat;
    }
    function toRadian(angle)
    {
        return angle.indexOf("deg") >= 0 ? parseFloat(angle) * (Math.PI/180) :
            angle.indexOf("grad") >= 0 ? parseFloat(angle) * (Math.PI/200) :
            parseFloat(angle);
    }

一緒に簡単な行列ライブラリ(matで始まる関数)も作成しています。少し長くなるので別途ソース参照のこと。

transform-origin プロパティの文字列を解析して座標を返す関数parseCSSTransformOriginを作りました。

    function parseCSSTransformOrigin(originStr, boxSizes)
    {
        //http://www.w3.org/TR/css3-transforms/#transform-origin-property
        var values = originStr.split(" ");
        var origins = [];
        for(var i = 0; i < values.length; ++i){
            var value = values[i].trim().toLowerCase();
            if(value == ""){
                continue;
            }
            var refSize = boxSizes[origins.length] || 0;
            switch(value){
            case "left": origins.push(0); break;
            case "top": origins.push(0); break;
            case "right": origins.push(boxSizes[0] || 0); break;
            case "bottom": origins.push(boxSizes[1] || 0); break;
            case "center": origins.push(refSize*0.5); break;
            default:
                if(value.indexOf("%") > 0){
                    origins.push(refSize * parseFloat(value) / 100);
                }
                else{
                    origins.push(parseFloat(value));
                }
                break;
            }
        }

        while(origins.length < 2){
            refSize = boxSizes[origins.length] || 0;
            origins.push(refSize * 0.5); //default 50%
        }

        return origins;
    }

この二つの関数が出来ると、要素に設定されているCSS Transformの影響を変換行列の形で求めることが出来ます。

    function getElementComputedStyle(elem)
    {
        return window.getComputedStyle ? window.getComputedStyle(elem, "") :
            document.defaultView && document.defaultView.getComputedStyle ? document.defaultView.getComputedStyle(elem, "") :
            elem.currentStyle ||
            elem.style;
    }
    /// CSS Transformのプロパティ(transformプロパティやtransform-originプロパティなど)による変換行列を求めます。
    function getCSSTransformMatrix(elem)
    {
        var elemStyle = getElementComputedStyle(elem);
        var origins = parseCSSTransformOrigin(elemStyle.transformOrigin, [elem.offsetWidth, elem.offsetHeight]);
        var tform = parseCSSTransform(elemStyle.transform);
        return matNewMul(matNewTranslate(origins[0], origins[1]), matNewMul(tform, matNewTranslate(-origins[0], -origins[1])));
    }

transform-originで指定される原点を引いて、transformを適用し、原点を足して戻すという変換になります。

プロパティの値はできるだけ計算値から求めるようにしています。(transformの計算値は常にmatrixだけのようなので、parseCSSTransformを律儀に作成しなくても良かった気もしますが、まぁ、他でも使えるかもしれないのでいいや)

親子の相対位置とCSS Transformによる変形行列を合成して、どんどんルートまで合成していくと、最終的に要素座標からページ座標へ変換する行列が完成します。

    /// 要素内bounding-box左上を原点とする座標系からページ左上を原点とする座標系へ変換する行列を求めます。
    function getMatrixFromNodeToPage(elem)
    {
        var mat = matNewIdentity();
        while(elem){
            var tformMat = getCSSTransformMatrix(elem);
            if(tformMat){
                matMul(mat, tformMat, mat);
            }

            matMul(mat, matNewTranslate(relPosX(elem), relPosY(elem)), mat );
            var elemStyle = getElementComputedStyle(elem);
            if(elemStyle && elemStyle.position == "fixed"){ //see Firefox Bug https://bugzilla.mozilla.org/show_bug.cgi?id=434678
                matMul(mat, matNewTranslate(window.pageXOffset, window.pageYOffset), mat);
                break;
            }
            if(!elem.offsetParent){
                break; //root or body
            }
            elem = elem.parentNode;
        }
        return mat;
    }

position:fixedの場合、スクロールしてもoffsetLeft, offsetTopの値は一切変わらないみたいなのですが、MouseEventのpageX, pageYは当然スクロールに応じて大きな値になるので、fixedの時だけwindow.pageXOffset,window.pageYOffsetを足すことにしています。

後は逆行列を作れば、ページ座標から要素座標へ変換できるようになります。MouseEventのpageX,pageYから要素上の座標へ変換できます。

    function convertPointFromPageToNode(elem, pageX, pageY)
    {
        if(global.webkitConvertPointFromPageToNode && global.WebKitPoint){
            // webkitConvertPointFromPageToNodeが使える場合はそれを使う!
            var r = webkitConvertPointFromPageToNode(elem, new WebKitPoint(pageX, pageY));
            return [r.x, r.y];
        }
        else{
            return convertPointFromPageToNode_Impl(elem, pageX, pageY);
        }
    }

    function convertPointFromPageToNode_Impl(elem, pageX, pageY)
    {
        var matPageToElement = matNewInverse(getMatrixFromNodeToPage(elem));
        if(matPageToElement){
            return matNewMulVec(matPageToElement, [pageX, pageY]);
        }
        else{
            return [0,0];
        }
    }

この方法で得られるのは境界矩形(bounding-box)左上を原点とする座標系の位置です。つまり、paddingとborderを含む領域の左上が(0,0)になります。この座標系はcanvas要素で使うには不便です。コンテンツ領域左上を原点とする座標系に変換したいです。なので、padding-left, padding-top, border-left-width, border-top-widthの計算値を求めて、少しずらしてやります。

    /// 要素コンテンツ領域左上側の幅と高さを求めます。
    function getElementOuterLeftTop(elem)
    {
        var elemStyle = getElementComputedStyle(elem);
        function getNumber(propName){ return parseFloat(elemStyle.getPropertyValue(propName));}
        var left = getNumber("border-left-width") + getNumber("padding-left") || 0;
        var top = getNumber("border-top-width") + getNumber("padding-top") || 0;
        return [left, top];
    }

    // ページ座標から要素内コンテンツ領域左上を原点とする座標系へ変換します。
    function convertPointFromPageToNodeContentArea(elem, pageX, pageY)
    {
        var outerLeftTop = getElementOuterLeftTop(elem);
        var pos = convertPointFromPageToNode(elem, pageX, pageY);
        pos[0] -= outerLeftTop[0];
        pos[1] -= outerLeftTop[1];
        return pos;
    }

まとめ:

参考URL:

CSSOM View Module
Viewの仕様。まだドラフト。
CSSOM View Module 日本語訳
日本語訳感謝!
jquery.transform.js/jquery.transform2d.js at master · louisremi/jquery.transform.js
jQueryは詳しくないのですが、なんか似たようなことやってます。
function to get the MouseEvent coordinates for an element that has CSS3 Transforms
作った後に見つけました。
javascript - How to get the MouseEvent coordinates for an element that has CSS3 Transform? - Stack Overflow
バイナリサーチで求めるという奇策が……
Coordinate Conversion Made Easy – the power of GeometryUtils ✩ Mozilla Hacks – the Web developer blog
Firefox33だけどまだー?(1033276 – Enable GeometryUtils APIs in release builds)
2014-10-17 ,

gameboot.js & gamescreen.js

JavaScriptで作られたゲームみたいなコンテンツをhtmlに埋め込むときに便利なライブラリを作りました。

http://github.com/misohena/js_gamescreen/

ゲームなどのコンテンツをWebページに貼り付けるとき、Youtube動画を埋め込むみたいにプレイボタンを押してから開始するようにしたくありませんか? プレビュー(サムネイル)画像を用意するのは仕方ないとして、いちいちプレイボタン画像を用意するのは面倒ですよね? ページを軽くするためにJavaScriptはボタンを押してから読み込むようにしたいですよね。読み込み終わるまでグルグルアニメーションしたいですよね。必要な.jsを読み込み終わったらプレビュー用の要素と実際のコンテンツの要素を交換したいですよね。そんな場合にgameboot.jsを使います。

埋め込んだコンテンツをウィンドウ内で最大化表示したり、全画面化(フルスクリーン化)したりしたいですよね? ゲームには色々UIが必要になってきますが、簡単なゲームだと出来合いのメニューバーみたいなものがあるとなんだかんだ言って楽ですよね。Youtubeの画面下部にあるようなコントロールバーがあれば便利ですよね? そんな場合にgamescreen.jsを使います。


(キーボード←,→,スペースを使用。フォーカス注意)

2014-10-08 ,

WiX Toolsetによるインストーラのひな形

インストーラの話題をしたところなので、WiX ToolsetでWindows Installer(.msi)を作るためのMyテンプレートなどを一つ。

ディレクトリ構成は次のような感じで。

WiX Toolsetをダウンロードしてパスを通し、上のディレクトリ構成でbuild.batを実行するとoutput/DISK1の中にインストーラが出力されます。

build.bat

ビルドするためのバッチファイルです。 出力ディレクトリの作成、wxsのコンパイル、wixobjのリンクを行います。

mkdir output
mkdir output\DISK1

candle hoge.wxs -o output/hoge.wixobj
candle MyExitDialog.wxs -o output/MyExitDialog.wixobj
candle MyInstallDirDlg.wxs -o output/MyInstallDirDlg.wixobj
candle MyUI_InstallDir.wxs -o output/MyUI_InstallDir.wixobj

light output/hoge.wixobj ^
  output/MyExitDialog.wixobj ^
  output/MyInstallDirDlg.wixobj ^
  output/MyUI_InstallDir.wixobj ^
  -ext WixUIExtension ^
  -ext WixUtilExtension ^
  -o output/DISK1/hoge.msi ^
  -pdbout output/hoge.wixpdb ^
  -cultures:ja-jp

hoge.wxs

メインとなるソースファイルです。

{YOUR-GUID}のところは全て個別のGUIDを生成して置き換えてください。

ファイル先頭ではプリプロセッサ変数を定義しています。この変数の値によって、生成されるmsiが色々変わるようになってます。

<!– Files –>と書いてある部分はheatで生成しても良いと思います。

<?xml version="1.0" encoding="utf-8"?>

<?define ProductName = "製品名" ?>
<?define Manufacturer = "製造者" ?>
<?define SrcDir = "$(sys.CURRENTDIR)src\" ?>
<?define MainExeFileName = "hoge.exe" ?>
<?define ReadmeFileName = "readme.txt" ?>
<?define UseCustomUI = "yes" ?>
<?define AppRegKey = "Software\$(var.Manufacturer)\$(var.ProductName)" ?>
<?define Compressed = "no" ?>

<Wix xmlns="http://schemas.microsoft.com/wix/2006/wi">
  <Product Id="{YOUR-GUID}"
           UpgradeCode="{YOUR-GUID}"
           Name="$(var.ProductName)"
           Manufacturer="$(var.Manufacturer)"
           Version="1.0.0.0"
           Language="1041" Codepage="932">
    <Package InstallerVersion="200" Compressed="$(var.Compressed)" />

    <?if $(var.Compressed)="yes" ?>
    <MediaTemplate EmbedCab="yes" />
    <?else ?>
    <Media Id="1" DiskPrompt="DISK1" />
    <Property Id="DiskPrompt" Value="インストールディスク [1]" />
    <?endif ?>

    <Icon Id="MainIcon" SourceFile="$(var.SrcDir)$(var.MainExeFileName)" />
    <Property Id="ARPPRODUCTICON" Value="MainIcon" />

    <!-- Directory Structure -->
    <Directory Id="TARGETDIR" Name="SourceDir">
      <!-- INSTALLDIR -->
      <Directory Id="ProgramFilesFolder" Name="ProgramFiles">
        <Directory Id="ProgramFilesManufacturer" Name="$(var.Manufacturer)">
          <Directory Id="INSTALLDIR" Name="$(var.ProductName)">
          </Directory>
        </Directory>
      </Directory>
      <!-- Start Menu -->
      <Directory Id="ProgramMenuFolder" Name="Programs">
        <Directory Id="AppStartMenuDir" Name="$(var.Manufacturer) $(var.ProductName)">
        </Directory>
      </Directory>
      <!-- Desktop -->
      <Directory Id="DesktopFolder" Name="Desktop">
      </Directory>
    </Directory>

    <!-- Feature -->
    <Feature Id="EssentialFeature" Level="1">
      <ComponentGroupRef Id="EssentialFiles" />
      <ComponentRef Id="EssentialRegistries" />
      <ComponentRef Id="EssentialShortcuts" />
      <ComponentRef Id="EssentialDesktopShortcut" />
    </Feature>

    <!-- Files -->
    <ComponentGroup Id="EssentialFiles" Directory="INSTALLDIR">
      <Component Id="file0001" Guid="{YOUR-GUID}">
        <File Id="file0001" KeyPath="yes" Source="$(var.SrcDir)$(var.MainExeFileName)" />
      </Component>
      <Component Id="file0002" Guid="{YOUR-GUID}">
        <File Id="file0002" KeyPath="yes" Source="$(var.SrcDir)hoge.dat" />
      </Component>
      <Component Id="file0003" Guid="{YOUR-GUID}">
        <File Id="file0003" KeyPath="yes" Source="$(var.SrcDir)$(var.ReadmeFileName)" />
      </Component>
    </ComponentGroup>

    <!-- Shortcuts -->
    <DirectoryRef Id="AppStartMenuDir">
      <Component Id="EssentialShortcuts" Guid="{YOUR-GUID}">
        <RegistryValue Root="HKCU" Key="$(var.AppRegKey)" Name="InstalledStartMenuShortcut" Type="integer" Value="1" KeyPath="yes" />
        <RemoveFolder Id="AppStartMenuDir" On="uninstall" />
        <Shortcut Id="startMenuShortcut0001" Name="$(var.ProductName)" Target="[INSTALLDIR]$(var.MainExeFileName)" WorkingDirectory="INSTALLDIR" />
      </Component>
    </DirectoryRef>
    <Property Id="INSTALLDESKTOPSHORTCUT" Value="1" />
    <DirectoryRef Id="DesktopFolder">
      <Component Id="EssentialDesktopShortcut" Guid="{YOUR-GUID}">
        <Condition>INSTALLDESKTOPSHORTCUT</Condition>
        <RegistryValue Root="HKCU" Key="$(var.AppRegKey)" Name="InstalledDesktopShortcut" Type="integer" Value="1" KeyPath="yes" />
        <Shortcut Id="desktopShortcut0001" Name="$(var.ProductName)" Target="[INSTALLDIR]$(var.MainExeFileName)" WorkingDirectory="INSTALLDIR" />
      </Component>
    </DirectoryRef>

    <!-- Registries -->
    <DirectoryRef Id="INSTALLDIR">
      <Component Id="EssentialRegistries" Guid="{YOUR-GUID}">
        <RegistryKey Root="HKCU" Key="$(var.AppRegKey)" ForceCreateOnInstall="yes" ForceDeleteOnUninstall="yes">
          <RegistryValue Type="string" Name="InstalledPath" Value="[INSTALLDIR]" KeyPath="yes" />
        </RegistryKey>
      </Component>
    </DirectoryRef>

    <!-- User Interface -->
    <!-- UI: Install Directory -->
    <Property Id="WIXUI_INSTALLDIR" Value="INSTALLDIR" />

    <!-- UI: Bitmap -->
    <WixVariable Id="WixUIBannerBmp" Value="banner.bmp" /><!-- 493x58 -->
    <WixVariable Id="WixUIDialogBmp" Value="dialog.bmp" /><!-- 493x312 -->

    <?if $(var.UseCustomUI) = "yes" ?>
      <UIRef Id="MyUI_InstallDir" />
      <!-- UI: Run application after installed (for MyUI_InstallDir) -->
      <?if $(var.MainExeFileName) And $(var.MainExeFileName)!="" ?>
        <Property Id="MYUI_EXITDIALOGOPTIONALCHECKBOXTEXT" Value="プログラムを実行する" />
        <Property Id="MYUI_EXITDIALOGOPTIONALCHECKBOX" Value="1" />
        <CustomAction Id="LaunchApplication" Directory="INSTALLDIR" ExeCommand="[INSTALLDIR]$(var.MainExeFileName)" Return="asyncNoWait" />
        <UI>
          <Publish Dialog="MyExitDialog" Control="Finish" Event="DoAction" Value="LaunchApplication">MYUI_EXITDIALOGOPTIONALCHECKBOX = 1 and NOT Installed</Publish>
        </UI>
      <?endif ?>
      <?if $(var.ReadmeFileName) And $(var.ReadmeFileName)!="" ?>
        <Property Id="MYUI_EXITDIALOGOPTIONALCHECKBOXTEXT2" Value="Readmeを開く" />
        <Property Id="MYUI_EXITDIALOGOPTIONALCHECKBOX2" Value="1" />
        <Property Id="WixShellExecTarget" Value="[INSTALLDIR]$(var.ReadmeFileName)" />
        <CustomAction Id="LaunchReadme" BinaryKey="WixCA" DllEntry="WixShellExec" Impersonate="yes" />
        <UI>
          <Publish Dialog="MyExitDialog" Control="Finish" Event="DoAction" Value="LaunchReadme">MYUI_EXITDIALOGOPTIONALCHECKBOX2 = 1 and NOT Installed</Publish>
        </UI>
      <?endif ?>
      <!-- UI: Skip license (for MyUI_InstallDir) -->
      <UI>
        <Publish Dialog="WelcomeDlg" Control="Next" Event="NewDialog" Value="MyInstallDirDlg">1</Publish>
        <Publish Dialog="MyInstallDirDlg" Control="Back" Event="NewDialog" Value="WelcomeDlg">1</Publish>
      </UI>

    <?else ?>

      <UIRef Id="WixUI_InstallDir" />
      <!-- UI: Run application after installed (for WixUI_InstallDir) -->
      <Property Id="WIXUI_EXITDIALOGOPTIONALCHECKBOXTEXT" Value="プログラムを実行する" />
      <Property Id="WIXUI_EXITDIALOGOPTIONALCHECKBOX" Value="1" />
      <CustomAction Id="LaunchApplication" Directory="INSTALLDIR" ExeCommand="[INSTALLDIR]$(var.MainExeFileName)" Return="asyncNoWait" />
      <UI>
        <Publish Dialog="ExitDialog" Control="Finish" Event="DoAction" Value="LaunchApplication">WIXUI_EXITDIALOGOPTIONALCHECKBOX = 1 and NOT Installed</Publish>
      </UI>
      <!-- UI: Skip license (for WixUI_InstallDir) -->
      <UI>
        <Publish Dialog="WelcomeDlg" Control="Next" Event="NewDialog" Value="InstallDirDlg">1</Publish>
        <Publish Dialog="InstallDirDlg" Control="Back" Event="NewDialog" Value="WelcomeDlg">1</Publish>
      </UI>
    <?endif ?>


  </Product>
</Wix>

MyInstallDirDlg.wxs

インストール先を指定するダイアログを定義するファイルです。

WiXのソースに含まれているファイル(src/ext/UIExtension/InstallDirDlg.wxs)をコピーして一部を改編しました。改編点は次の通りです。

  • DialogのIdをInstallDirDlgからMyInstallDirDlgへ変更しました。
  • 「デスクトップにショートカットを作成する」チェックボックスを追加しました。
<?xml version="1.0" encoding="UTF-8"?>
<Wix xmlns="http://schemas.microsoft.com/wix/2006/wi">
   <Fragment>
       <UI>
           <Dialog Id="MyInstallDirDlg" Width="370" Height="270" Title="!(loc.InstallDirDlg_Title)">
               <Control Id="Next" Type="PushButton" X="236" Y="243" Width="56" Height="17" Default="yes" Text="!(loc.WixUINext)" />
               <Control Id="Back" Type="PushButton" X="180" Y="243" Width="56" Height="17" Text="!(loc.WixUIBack)" />
               <Control Id="Cancel" Type="PushButton" X="304" Y="243" Width="56" Height="17" Cancel="yes" Text="!(loc.WixUICancel)">
                   <Publish Event="SpawnDialog" Value="CancelDlg">1</Publish>
               </Control>

               <Control Id="Description" Type="Text" X="25" Y="23" Width="280" Height="15" Transparent="yes" NoPrefix="yes" Text="!(loc.InstallDirDlgDescription)" />
               <Control Id="Title" Type="Text" X="15" Y="6" Width="200" Height="15" Transparent="yes" NoPrefix="yes" Text="!(loc.InstallDirDlgTitle)" />
               <Control Id="BannerBitmap" Type="Bitmap" X="0" Y="0" Width="370" Height="44" TabSkip="no" Text="!(loc.InstallDirDlgBannerBitmap)" />
               <Control Id="BannerLine" Type="Line" X="0" Y="44" Width="370" Height="0" />
               <Control Id="BottomLine" Type="Line" X="0" Y="234" Width="370" Height="0" />

               <Control Id="FolderLabel" Type="Text" X="20" Y="60" Width="290" Height="30" NoPrefix="yes" Text="!(loc.InstallDirDlgFolderLabel)" />
               <Control Id="Folder" Type="PathEdit" X="20" Y="100" Width="320" Height="18" Property="WIXUI_INSTALLDIR" Indirect="yes" />
               <Control Id="ChangeFolder" Type="PushButton" X="20" Y="120" Width="56" Height="17" Text="!(loc.InstallDirDlgChange)" />

               <!-- Begin MyUI -->
               <Control Id="DesktopShortcutCheckBox" Type="CheckBox" X="20" Y="160" Width="290" Height="17" Property="INSTALLDESKTOPSHORTCUT" CheckBoxValue="1" Text="デスクトップにショートカットを作成する。" />
               <!-- End MyUI -->
           </Dialog>
       </UI>
   </Fragment>
</Wix>

MyExitDialog.wxs

インストールが終了したときに表示するダイアログを定義するファイルです。

WiXのソースに含まれているファイル(src/ext/UIExtension/ExitDialog.wxs)をコピーして一部を改編しました。改編点は次の通りです。

  • DialogのIdをExitDialogからMyExitDialogへ変更しました。
  • チェックボックスを二つ表示できるようにしました。 アプリの起動用とreadmeの表示用です。 なお、チェックボックスのText=をそのまま使うと背景がグレーになって見た目が悪いので、チェックボックスを最小限のサイズで表示して、その横に新たにテキストを配置することで背景が透過するようにしています。副作用として、テキスト部分を押してもチェックボックスが反応しなくなってしまいますが、そこは妥協しています(InstallShieldが作るmsiも同じことをしているせいで押しにくい)。
<?xml version="1.0" encoding="UTF-8"?>
<Wix xmlns="http://schemas.microsoft.com/wix/2006/wi">
   <Fragment>
       <UI>
           <Dialog Id="MyExitDialog" Width="370" Height="270" Title="!(loc.ExitDialog_Title)">
               <Control Id="Finish" Type="PushButton" X="236" Y="243" Width="56" Height="17" Default="yes" Cancel="yes" Text="!(loc.WixUIFinish)" />
               <Control Id="Cancel" Type="PushButton" X="304" Y="243" Width="56" Height="17" Disabled="yes" Text="!(loc.WixUICancel)" />
               <Control Id="Bitmap" Type="Bitmap" X="0" Y="0" Width="370" Height="234" TabSkip="no" Text="!(loc.ExitDialogBitmap)" />
               <Control Id="Back" Type="PushButton" X="180" Y="243" Width="56" Height="17" Disabled="yes" Text="!(loc.WixUIBack)" />
               <Control Id="BottomLine" Type="Line" X="0" Y="234" Width="370" Height="0" />
               <Control Id="Description" Type="Text" X="135" Y="70" Width="220" Height="40" Transparent="yes" NoPrefix="yes" Text="!(loc.ExitDialogDescription)" />
               <Control Id="Title" Type="Text" X="135" Y="20" Width="220" Height="60" Transparent="yes" NoPrefix="yes" Text="!(loc.ExitDialogTitle)" />

               <!-- Begin MyUI -->
               <Control Id="OptionalText" Type="Text" X="135" Y="110" Width="220" Height="80" Transparent="yes" NoPrefix="yes" Hidden="yes" Text="[MYUI_EXITDIALOGOPTIONALTEXT]">
                   <Condition Action="show">MYUI_EXITDIALOGOPTIONALTEXT AND NOT Installed</Condition>
               </Control>
               <Control Id="OptionalCheckBox" Type="CheckBox" X="150" Y="150" Width="10" Height="9" Hidden="yes" Property="MYUI_EXITDIALOGOPTIONALCHECKBOX" CheckBoxValue="1">
                   <Condition Action="show">MYUI_EXITDIALOGOPTIONALCHECKBOXTEXT AND NOT Installed</Condition>
               </Control>
               <Control Id="OptionalCheckBoxText" Type="Text" X="165" Y="150" Width="200" Height="13" Transparent="yes" Hidden="yes" Text="[MYUI_EXITDIALOGOPTIONALCHECKBOXTEXT]">
                   <Condition Action="show">MYUI_EXITDIALOGOPTIONALCHECKBOXTEXT AND NOT Installed</Condition>
               </Control>
               <Control Id="OptionalCheckBox2" Type="CheckBox" X="150" Y="180" Width="10" Height="9" Hidden="yes" Property="MYUI_EXITDIALOGOPTIONALCHECKBOX2" CheckBoxValue="1">
                   <Condition Action="show">MYUI_EXITDIALOGOPTIONALCHECKBOXTEXT2 AND NOT Installed</Condition>
               </Control>
               <Control Id="OptionalCheckText2" Type="Text" X="165" Y="180" Width="200" Height="13" Transparent="yes" Hidden="yes" Text="[MYUI_EXITDIALOGOPTIONALCHECKBOXTEXT2]">
                   <Condition Action="show">MYUI_EXITDIALOGOPTIONALCHECKBOXTEXT2 AND NOT Installed</Condition>
               </Control>
               <!-- End MyUI -->
           </Dialog>

           <InstallUISequence>
               <Show Dialog="MyExitDialog" OnExit="success" Overridable="yes" />
           </InstallUISequence>

           <AdminUISequence>
               <Show Dialog="MyExitDialog" OnExit="success" Overridable="yes" />
           </AdminUISequence>
       </UI>
   </Fragment>
</Wix>

MyUI_InstallDir.wxs

UIの流れを定義するファイルです。

WiXのソースに含まれているファイル(src/ext/UIExtension/WixUI_InstallDir.wxs)をコピーして一部を改編しました。上で改編したダイアログを使うように、一部のダイアログIdにMyを付加しました。

<?xml version="1.0" encoding="UTF-8"?>
<Wix xmlns="http://schemas.microsoft.com/wix/2006/wi">
   <Fragment>
       <UI Id="MyUI_InstallDir">
           <TextStyle Id="WixUI_Font_Normal" FaceName="Tahoma" Size="8" />
           <TextStyle Id="WixUI_Font_Bigger" FaceName="Tahoma" Size="12" />
           <TextStyle Id="WixUI_Font_Title" FaceName="Tahoma" Size="9" Bold="yes" />

           <Property Id="DefaultUIFont" Value="WixUI_Font_Normal" />
           <Property Id="WixUI_Mode" Value="InstallDir" />

           <DialogRef Id="BrowseDlg" />
           <DialogRef Id="DiskCostDlg" />
           <DialogRef Id="ErrorDlg" />
           <DialogRef Id="FatalError" />
           <DialogRef Id="FilesInUse" />
           <DialogRef Id="MsiRMFilesInUse" />
           <DialogRef Id="PrepareDlg" />
           <DialogRef Id="ProgressDlg" />
           <DialogRef Id="ResumeDlg" />
           <DialogRef Id="UserExit" />
           
           <Publish Dialog="BrowseDlg" Control="OK" Event="DoAction" Value="WixUIValidatePath" Order="3">1</Publish>
           <Publish Dialog="BrowseDlg" Control="OK" Event="SpawnDialog" Value="InvalidDirDlg" Order="4"><![CDATA[WIXUI_INSTALLDIR_VALID<>"1"]]></Publish>

           <Publish Dialog="MyExitDialog" Control="Finish" Event="EndDialog" Value="Return" Order="999">1</Publish>

           <Publish Dialog="WelcomeDlg" Control="Next" Event="NewDialog" Value="LicenseAgreementDlg">NOT Installed</Publish>
           <Publish Dialog="WelcomeDlg" Control="Next" Event="NewDialog" Value="VerifyReadyDlg">Installed AND PATCH</Publish>

           <Publish Dialog="LicenseAgreementDlg" Control="Back" Event="NewDialog" Value="WelcomeDlg">1</Publish>
           <Publish Dialog="LicenseAgreementDlg" Control="Next" Event="NewDialog" Value="MyInstallDirDlg">LicenseAccepted = "1"</Publish>

           <Publish Dialog="MyInstallDirDlg" Control="Back" Event="NewDialog" Value="LicenseAgreementDlg">1</Publish>
           <Publish Dialog="MyInstallDirDlg" Control="Next" Event="SetTargetPath" Value="[WIXUI_INSTALLDIR]" Order="1">1</Publish>
           <Publish Dialog="MyInstallDirDlg" Control="Next" Event="DoAction" Value="WixUIValidatePath" Order="2">NOT WIXUI_DONTVALIDATEPATH</Publish>
           <Publish Dialog="MyInstallDirDlg" Control="Next" Event="SpawnDialog" Value="InvalidDirDlg" Order="3"><![CDATA[NOT WIXUI_DONTVALIDATEPATH AND WIXUI_INSTALLDIR_VALID<>"1"]]></Publish>
           <Publish Dialog="MyInstallDirDlg" Control="Next" Event="NewDialog" Value="VerifyReadyDlg" Order="4">WIXUI_DONTVALIDATEPATH OR WIXUI_INSTALLDIR_VALID="1"</Publish>
           <Publish Dialog="MyInstallDirDlg" Control="ChangeFolder" Property="_BrowseProperty" Value="[WIXUI_INSTALLDIR]" Order="1">1</Publish>
           <Publish Dialog="MyInstallDirDlg" Control="ChangeFolder" Event="SpawnDialog" Value="BrowseDlg" Order="2">1</Publish>
           
           <Publish Dialog="VerifyReadyDlg" Control="Back" Event="NewDialog" Value="MyInstallDirDlg" Order="1">NOT Installed</Publish>
           <Publish Dialog="VerifyReadyDlg" Control="Back" Event="NewDialog" Value="MaintenanceTypeDlg" Order="2">Installed AND NOT PATCH</Publish>
           <Publish Dialog="VerifyReadyDlg" Control="Back" Event="NewDialog" Value="WelcomeDlg" Order="2">Installed AND PATCH</Publish>

           <Publish Dialog="MaintenanceWelcomeDlg" Control="Next" Event="NewDialog" Value="MaintenanceTypeDlg">1</Publish>

           <Publish Dialog="MaintenanceTypeDlg" Control="RepairButton" Event="NewDialog" Value="VerifyReadyDlg">1</Publish>
           <Publish Dialog="MaintenanceTypeDlg" Control="RemoveButton" Event="NewDialog" Value="VerifyReadyDlg">1</Publish>
           <Publish Dialog="MaintenanceTypeDlg" Control="Back" Event="NewDialog" Value="MaintenanceWelcomeDlg">1</Publish>

           <Property Id="ARPNOMODIFY" Value="1" />
       </UI>

       <UIRef Id="WixUI_Common" />
   </Fragment>
</Wix>

ソース一式

ソース一式は github/simple_wix_template に置いてあります。