ページめくりのアニメーション

福岡でのプレゼンではいつも通り JavaFX のプレゼンツールで行いました。

Slideshare の表紙を見ていただければ分かるかもしれませんが、今回は本をテーマにしたプレゼン資料を作成しました。

なので、資料のページ送りも、紙のページをめくったようなアニメーションで行いました。で、ページ送りの部分だけ独立させてみました。

この動画だと、ページを 3D で動かしているように見えるかもしれませんが、全部 2D です ^ ^;;

そのせいで、いろいろとめんどうなことやってます。

ページのクリッピングのアニメーション

ページは重ねて表示してあり、上に現在表示しているページ、下に次に表示するページとなっています。

まず、行うのは上の現在表示しているページをクリッピングすること。

クリッピングというのは任意のノードで表示領域を制限することです。

単にクリッピングしただけではしかたないので、クリッピングした領域をアニメーションで左に移動させています。こうすることで、ページの右側からだんだんと見えなくなるということが可能になります。

一番上の四角がクリッピング領域でその下のイメージがそれによって右側がクリッピングされます。そして、このクリッピング領域を左の方に移動させるわけです。

このアニメーションは簡単。単に TranslateTransition を使うだけです。

        Node present = group.getChildren().get(group.getChildren().size() - 1);

        // 現在のページのクリップ
        Rectangle presentClip = new Rectangle(0, 0, width, height);
        present.setClip(presentClip);

        // 現在のページのクリップの移動アニメーション
        TranslateTransition presentClipAnim 
            = new TranslateTransition(Duration.millis(DURATION), presentClip);
        presentClipAnim.setToX(-width);
        presentClipAnim.setInterpolator(Interpolator.EASE_IN);

group はページを表示しているコンテナです。

レイアウトされるといろいろと支障があるので、今回のページ送りは Group 限定です。ページの中身は何でもいいので、そちらは適当にレイアウトでも何でもやってください。

で、現在表示されているページが present です。

present をクリップするのは四角系である Rectangle オブジェクト presentClip で、present と同じ大きさで、初期位置は present と同じ場所です。

それを、イメージの幅分だけ左に移動させます。

ポイントは補間方法を EASE IN にすること。

ページをめくる時、めくりはじめはゆっくり動きます。でも、ちょうどページが消える時は、ページが上を向いている時なので、一番速度が速くなっているはず。

なので、はじめゆっくり、後は速くという EASE IN にします。

これを EASE BOTH にしてしまうと、終わりもゆっくりになってしまって、ちぐはぐした印象になります。

これで上のイメージが徐々に消えて、下のイメージが表示されるようになります。

ページの裏

次にやるのは、ページにすけたイメージを表示させること。

ページにすけているので、鏡像になっています。

JavaFX Script の頃は、ノードを複製する機能があったので、簡単に鏡像ができたのですが、JavaFX 2.x になったらなくなってしまいました。

でも、Reflection のエフェクトでは鏡像ができているので、どうにかすればできるはず。

で、どうやったら鏡像ができるかというと、スナップショットを撮りました。それまではノードを複製することばかり考えていたのですが、別にノードでなくてもよくて、イメージさえとれればいいことに気がついたわけです。

スナップショットは Node の snapshot メソッドで取得します。Node クラスのメソッドなので、任意のノードのスナップショットが取得可能です。

戻り値が WritableImage で、引数はスナップショットを撮る時のパラメータと、WritableImage。パラメータは何もしていせず、WritableImage は null で大丈夫です。

        ImageView flipImage = new ImageView(present.snapshot(new SnapshotParameters(), null));

スナップショットをページの裏を作っていきます。

まず、鏡像にするために y 軸を中心に 180 度回転させ、透明度も調整します。透明度を設定しているため、そのままだと下の絵が透けてしまうので、白い四角の上に描画するようにしています。

        // ページ裏
        Group flip = new Group();

        // ページ裏の下地
        Rectangle flipRect = new Rectangle(0, 0, width, height);
        flipRect.setFill(Color.WHITE);
        flip.getChildren().add(flipRect);

        // ページを反転したイメージ
        ImageView flipImage = new ImageView(present.snapshot(new SnapshotParameters(), null));
        flipImage.setRotationAxis(new Point3D(0.0, 1.0, 0.0));
        flipImage.setRotate(180.0);
        flipImage.setOpacity(0.4);
        flip.getChildren().add(flipImage);

        // ページ裏用のクリップ
        Rectangle flipClip = new Rectangle(0, 0, width, height);
        flip.setClip(flipClip);

        group.getChildren().add(flip);

ページの裏のアニメーション

ページ裏が準備できたので、次はページの裏をめくるアニメーションです。

でも、実際はめくっているわけではなくて、単に右から左へ移動させているだけです。しかし、単に移動させるだけでは、めくっているように見えないので、ここでもクリッピングと合わせて移動させます。

つまり、裏返したページの移動 + クリッピング領域の移動 の 2 つのアニメーションでめくっているように見せます。

ポイントは移動の速度が違うこと。裏返しページはクリッピング領域の 2 倍の速度で動かします。

といのも裏返しページの初期位置が異なっているためです。クリッピング領域は始め x 座標が 0 の位置にあります。それに対し、裏返しページの x 座標は width です。

アニメーション後はどちらも、-width の位置になります。つまり、クリッピング領域は width だけ移動しますが、裏返しページは 2×width 移動するわけです。

一番上の四角がページ裏のクリッピング領域、その下がページ裏です。

ページ裏のクリッピング領域は、元のページのクリッピング領域と同じ速度で移動するので、見切れた部分が折り返しているように見えるわけです。

2 つのアニメーションはどちらも TranslateTransition です。

        // ページ裏のアニメーション
        TranslateTransition flipAnim = new TranslateTransition(Duration.millis(DURATION), flip);
        flipAnim.setFromX(width);
        flipAnim.setToX(-width);
        flipAnim.setInterpolator(Interpolator.EASE_IN);

        // ページ裏のクリップのアニメーション
        TranslateTransition clipAnim = new TranslateTransition(Duration.millis(DURATION), flipClip);
        clipAnim.setFromX(-width);
        clipAnim.setToX(0);
        clipAnim.setInterpolator(Interpolator.EASE_IN);

ページ裏のアニメーションは特に問題ないはずです。右側から左側に抜けるので、fromX が width で、toX が -width になります。

問題はクリップ領域のアニメーションです。

fromX が -width で、toX が 0 なのがくせものです。どういうことかというと、クリップする対象のページ裏からの相対位置になるからです。

このアニメーションだけだと右方向に移動しますが、ページ裏が左に移動しているので、クリッピング領域も左に移動します。

さて、ここまででとりあえずページをめくったアニメーションはできました。

もちろん、これで終わりにしてしまってもいいのですが、よりリアリティを持たせるために影を作りましょう。ページとページの間にできる影です。

影は端が透明、真ん中がグレーになっている四角を用意して、それをページの境目に描画することで実現しています。

これは Rectangle と LinearGradient で描画します。

        // ページの間にできる影
        Rectangle shadow = new Rectangle(width - 30, 0, 60, height);
        LinearGradient gradient = new LinearGradient(0, 0, 1, 0, true, CycleMethod.NO_CYCLE,
                new Stop(0.0, Color.rgb(0, 0, 0, 0.0)),
                new Stop(0.5, Color.rgb(0, 0, 0, 0.5)),
                new Stop(1.0, Color.rgb(0, 0, 0, 0.0)));
        shadow.setFill(gradient);
        group.getChildren().add(shadow);

もちろん、ページが動くのと同期して影も動かします。

また、ページのめくり始めは影はないはずです。なので、影を細くしておいて徐々に太くし、さらにページめくりが終わりそうになると再び補足するというアニメーションも行っています。

        // 影が太るアニメーション
        ScaleTransition shadowAnim1 = new ScaleTransition(Duration.millis(DURATION / 5), shadow);
        shadowAnim1.setFromX(0.0);
        shadowAnim1.setToX(1.0);
        shadowAnim1.setInterpolator(Interpolator.EASE_IN);

        // 影の移動アニメーション
        TranslateTransition shadowAnim2 = new TranslateTransition(Duration.millis(DURATION), shadow);
        shadowAnim2.setToX(-width);
        shadowAnim2.setInterpolator(Interpolator.EASE_IN);

        // 影が痩せるアニメーション
        ScaleTransition shadowAnim3 = new ScaleTransition(Duration.millis(DURATION / 5), shadow);
        shadowAnim3.setFromX(1.0);
        shadowAnim3.setToX(0.0);
        shadowAnim3.setInterpolator(Interpolator.EASE_OUT);
        SequentialTransition seqAnim
                = new SequentialTransition(new PauseTransition(Duration.millis(DURATION * 4 / 5)),
                        shadowAnim3);

移動は、今までと同じように TranslateTransition で行います。

影を太らせたり、細くするのは ScaleTransition を使います。ScaleTransition は拡大・縮小を行うアニメーションですが、軸ごとに拡大率・縮小率を設定できます。そのため、ここでは x 軸方向だけ拡大・縮小するということをやっています。

また、影を細くするのはページ送りの最後の方なので、はじめにポースしておいて、その後に細くなるアニメーションを行います。これは指定したアニメーションをシーケンシャルに行う SequentialTransition を使います。

アニメーションの制御

最後にこれらのアニメーションを同時に行うように設定し、アニメーションを開始します。

        ParallelTransition animation = new ParallelTransition(
                presentClipAnim,
                flipAnim,
                clipAnim,
                shadowAnim1,
                shadowAnim2,
                seqAnim);
        
        // アニメーションが終了した時の処理
        animation.setOnFinished(e -> {
            group.getChildren().remove(flip);
            group.getChildren().remove(shadow);

            present.setTranslateX(0);
            present.setClip(null);
            group.getChildren().remove(present);

            group.getChildren().add(0, present);
        });

        animation.play();

アニメーションを同時に行わせるのは ParallelTransition を使用します。コンストラクタで指定したアニメーションを同時に行います。

また、setOnFinished メソッドでは、アニメーションの終了時にコールされるコールバックメソッドをセットできます。

ここでは、ページ裏と影を削除します。

また、現ページも削除します。その際に、移動量を 0 にクリアしておき、またクリップ領域もクリアしておきます。これは削除したページを再利用するためです。再利用しないのであれば、移動量やクリップ領域をクリアする必要はありません。

最後に play メソッドでアニメーションを開始します。

これでページがめくれるわけです。

コードは gist で公開してあります。

https://gist.github.com/skrb/1c62b77ef7ddb3c7adf4


Page Flipping Demonstration

8 True Stories about JavaFX

福岡JavaFX のプレゼンをしてきました。

いまさらですが、8 個は多かった。時間オーバーしてしまいました ><

でも、8 はゴロがいいんですよね。Java SE 8 勉強会だし。

はじめは JavaFX を知らない人向けでだんだんとコード多くする方向で説明しました。でも、最後の Lambda Ready はとってつけたような感じでしたね。

そこそこ楽しんでいただけたようでよかったです。

さて、次は大阪

JavaFX 8 で 3D その 1

このエントリーは、2013 年の JavaFX Advent Calendar の 24 日目のエントリーです。

昨日は id:tikemin さんの iOS7 + RoboVM + JavaFX のイロイロ でした。

明日のクリスマスは小出先生です。

12/13 に久しぶりに JavaFX 勉強会を開催しました。今回は櫻庭は 3D の話をしました。

JavaFX の 3D は Windows, Mac, Linux のいずれでも動作し、2D と 3D を同様にシームレスに扱うことも可能です。しかも、 Java 3D で弱かった、Maya などのツールのファイルの読み込みをサポートしています。

JavaFX の 3D は 2.x でも限定的に使えたのですが、本格的に使えるようになるのは JavaFX 8 からです。すでに 3D が動作する Java SE 8 の Early Access が公開されているので、すぐに試すことができます。

セッションでは 30 分しかなかったので、かなり話題を絞って以下の 3 つの話題にしました。

  • 座標
  • Model
  • Light

とりあえず、ここらへんを押さえておけば、なんとかなるでしょう。

座標

3D を行うのに欠かせないのが、座標です。もちろん、JavaFX でも 3 次元の座標を扱うのですが、ちょっとだけ注意が必要です。

というのも、通常の 3D の CG Tool は y 軸が数学と同じで上方向を向いています。ところが、JavaFX では 2D と 3D を区別なく使えるようにしているためなのか、y 軸が下を向いています。

ツールで作ったモデルをインポートしたら、なぜかモデルが下を向いていたということもあるので、注意が必要です。

もし、下向きの y 軸がいやなのであれば、z 軸を中心にして 180 度回転させます。

2D の座標は Point2D クラスで表しますが、3D は Point3D クラスで表します。

Node クラスのたとえば、移動の API には setTranslateX/setTranslateY/setTranslateZ というように 3 つが組になっているので、移動や回転、スケーリングなどは 2D と同じように行うことができます。

そして、もう 1 つ座標に関連して、3D で重要なのがカメラです。

カメラは、どこから空間に配置しているモデルを見るかということを表しています。

カメラには 2 種類あります。

光が広がっていかないカメラと、広がっていくカメラです。前者を直投影、後者を透視投影と呼びます。

直答英は 2D の世界です。奥行きがあっても、同じ大きさの物体は奥行き方向の位置が違っていても、同じ大きさになります。こちらは ParallelCamera クラスで表します。

一方の透視投影は、いわゆる一転透視法で遠いものは 1 点に修練していきます。こちらは PerspectiveCamera クラスで表します。

もちろん、デフォルトは ParallelCamera クラスです。

カメラをセットするには Scene クラスに対して setCamera メソッドで行います。たとえば、立方体を PerspectiveCamera クラスで見るには、次のようになります。

public class PerspectiveCameraDemo extends Application {

    @Override
    public void start(Stage stage) {
        Group root = new Group();
        
        // 辺の長さが20の立方体
        Box box = new Box(20, 20, 20);
        // 立方体を y 軸を中心に 30 度回転させる
        box.setRotationAxis(new Point3D(0.0, 1.0, 0.0));
        box.setRotate(30.0);
        root.getChildren().add(box);
        
        Scene scene = new Scene(root, 400, 400);
        scene.setFill(Color.BLACK);
        
        // 透視投影カメラを設定する
        PerspectiveCamera camera = new PerspectiveCamera(true);
        scene.setCamera(camera);

        // カメラの位置を (0, 0, -100) にする
        camera.setTranslateZ(-100.0);
        
        stage.setScene(scene);
        stage.setTitle("Perspective Camera Demo");
        stage.show();
    }

    public static void main(String... args) {
        launch(args);
    }
}

PerspectiveCamera クラスのコンストラクタの引数に true を設定すると、カメラの位置が原点に配置されます。この時、カメラの向きは z 軸の正の方向になります。

カメラもノードのサブクラスなので、setTranslateX/setTranslateY/setTranslateZ メソッドで任意の位置に移動することができます。ここでは、z 軸方向に -100 移動させているので、カメラの位置は (0, 0, -100) になります。

実行すると以下のようになります。

一点透視になるので、手前が大きく表示されます。

これに対し、ParallelCame クラスで立方体を表示させてみましょう。

public class ParallelCameraDemo extends Application {

    @Override
    public void start(Stage stage) {
        StackPane root = new StackPane();
        
        // 辺の長さが20の立方体
        Box box = new Box(150, 150, 150);
        // 立方体を y 軸を中心に 30 度回転させる
        box.setRotationAxis(new Point3D(0.0, 1.0, 0.0));
        box.setRotate(30.0);
        root.getChildren().add(box);
        
        Scene scene = new Scene(root, 400, 400);
        scene.setFill(Color.BLACK);
        
        // 直投影カメラを設定する
        ParallelCamera camera = new ParallelCamera();
        scene.setCamera(camera);
        
        stage.setScene(scene);
        stage.setTitle("Parallel Camera Demo");
        stage.show();
    }

    public static void main(String... args) {
        launch(args);
    }
}

同じようなサイズになるように、立方体のサイズなどを若干調整しています。

直投影だと全然立方体に見えません。というより、真ん中がくびれているように見えてしまうのが不思議なところです。

長くなってしまったので、続きはまた今度。


つづく

曲線のアニメーション

このエントリーは、2013 年の JavaFX Advent Calendar の初日のエントリーです。

明日は tomo_taka01 さんです。

JavaFX で曲線というと CubicCurve か、QuadCurve です。これらのクラスをアニメーションさせることを考えてみます。

もちろん、トランジションでもいいのですが、PathTransition 以外はあまりおもしろくないので (おもしろくない = 簡単に使えるです)、今日は扱いません。

となると、Timeline か AnimationTimer でアニメーションさせます。せっかくだからグネグネ動くようなアニメーションがいいですね ^ ^;;

Timeline でも AnimationTimer でも、クラスのプロパティが定義してあれば、その値を時間で変化させるということが可能です。

CubicCurve などのクラスのプロパティといえば、端点と制御点です。これらのプロパティを変化させてあげれば、グネグネ動かすことも可能です!

ということで、ここでは制御点をアニメーションさせてみましょう。

ちなみに、制御点というのは端点の接線上にある点で、端点から離れていればいるほど曲線は接線に近づいていきます。

ITpro で以前、端点と制御点の関係を図に描いたので、それを参照してみてください。

まず、アニメーションさせる 2 本の CubicCurve を作っておきます。

        // ベジェ曲線 1本目
        final CubicCurve curve1 = new CubicCurve();
        curve1.setStartX(40.0); curve1.setStartY(100.0);
        curve1.setEndX(360.0); curve1.setEndY(100.0);
        curve1.setStroke(Color.BLACK);
        curve1.setStrokeWidth(10.0);
        curve1.setStrokeLineCap(StrokeLineCap.ROUND);
        curve1.setFill(null);
        parent.getChildren().add(curve1);
        
        // ベジェ曲線 2本目
        final CubicCurve curve2 = new CubicCurve();
        curve2.setStartX(40.0); curve2.setStartY(100.0);
        curve2.setEndX(360.0); curve2.setEndY(100.0);
        curve2.setStroke(Color.BLACK);
        curve2.setStrokeWidth(10.0);
        curve2.setStrokeLineCap(StrokeLineCap.ROUND);
        curve2.setFill(null);
        parent.getChildren().add(curve2);

アニメーションで制御点の値を変化させるので、この時点では設定していません。

では、制御点をアニメーションさせてみましょう!!

ここでは、AnimationTimer を使用して、制御点を円運動させてみます。まず、円運動させるためのサインとコサインを決めておきます。

        // ベジェ曲線の制御点を円運動させるアニメーション
        AnimationTimer timer = new AnimationTimer() {
            @Override
            public void handle(long t) {
                double sin1 = 150 * Math.sin(t/400_000_000.0);
                double cos1 = 150 * Math.cos(t/400_000_000.0);

                // もう一方の制御点は180度ずらす
                double sin2 = 150 * Math.sin(t/400_000_000.0 + Math.PI);
                double cos2 = 150 * Math.cos(t/400_000_000.0 + Math.PI);

端点の一方を sin1/cos1 にしたら、もう一方の端点を sin2/cos2 にすると 8 の字になります。2 つの端点をどちらも sin1/cos1 にすると楕円になります。もちろん、この場合、もう一方の CubicCurve を sin2/cos2 にします。

ここでは 8 の字にしてみます。

                double controlX11 = 40.0 + sin1; 
                double controlY11 = 100.0 + cos1;
                curve1.setControlX1(controlX11);
                curve1.setControlY1(controlY11);
                
                double controlX12 = 360.0 + sin2; 
                double controlY12 = 100.0 + cos2; 
                curve1.setControlX2(controlX12);
                curve1.setControlY2(controlY12);

(40, 100) と (360, 100) は端点の座標です。

もう一方の CubicCurve はこれと逆にします (同じだとくっついてしまうので)。

                double controlX21 = 40.0 + sin2; 
                double controlY21 = 100.0 + cos2;
                curve2.setControlX1(controlX21);
                curve2.setControlY1(controlY21);
                
                double controlX22 = 360.0 + sin1; 
                double controlY22 = 100.0 + cos1; 
                curve2.setControlX2(controlX22);
                curve2.setControlY2(controlY22);
            }
        };

後は AnimationTimer をスタートさせるだけです。

        timer.start();

これでグニグニ動きます。

これと同じ動きをトランジションでも作ることができます。それは円運動のアニメーションをするダミーのノードを作ります (このノードは表示させません)。円運動をさせるには PathAnimation で path に Circle を指定します。

そして、CubicCurve の端点の座標をこのノードの座標にバインドします。

こうすることで、トランジションでも同じ動きを実現できます。そこら辺も ITpro の連載で書いたので、参照してみてください。

ソースは gist にアップしてあります。


gist7729009

JavaFX Hands on Lab

8/24 に JJUGJavaFX ユーザグループの共催で、JavaFX のハンズオンを行いました。

私がメインで説明をして、 id:aoe-tk さんと fukai_yas さんにヘルプしていただきました。

当日はなんと歩留まり 90% 以上!!

人数では隣でやっていた Java EE 7 のハンズオンに負けてしまうのですが、歩留まりや懇親会の参加率ではこちらの方が勝っていましたよww

さて、ハンズオンでは 2 つのアプリケーションを作成しました。一方が Hello, World! と、そのちょっとした拡張。もう一方が表とブラウザを使ったブックマーク的なアプリケーションです。

テキスト

当日はテキストには書いてないのですが、ちょっとした応用をやりました。また、ちょっと補足したいこともあるので、ここに書いておきます。

ラベルとテキストフィールドのバインド

ここで作ったサンプルはテキストフィールドとボタンとラベルから構成されていて、テキストフィールドに文字を入力して、リターンを入力するか、ボタンをクリックすると、ラベルにその入力した文字が反映されるというものでした。

でも、エンターキーやボタンをクリックしなくても、文字を入力したらすぐにラベルに反映させたくなることもありますよね。

そういう場合、Swing/AWT であれば、KeyEvent を拾って、それをもとにラベルに文字を追加するということを行ってきました。でも、これがまた、結構めんどうくさい。

JavaFX であれば、もっと簡単にリアルタイムの反映ができます。

どうやって? バインドです。

バインドは 2 つのプロパティを自動的に同期させる機構です。片方向のバインドもありしますし、双方向のバインドもあります。

たとえば、以下のコードでは、整数のプロパティの y が x にバインドしています。

        IntegerProperty x = new SimpleIntegerProperty();
        IntegerProperty y = new SimpleIntegerProperty();
        
        y.bind(x); // y を x にバインドする

        // x に 10 を代入                
        x.set(10);
        System.out.println(y.get()); // 10 が出力
        // x に 20 を代入                
        x.set(20);
        System.out.println(y.get()); // 20 が出力

ここでは片方向のバインドなので、y に値を設定することはできません。

さて、これをテキストフィールドとラベルにもおこなってしまおうというわけです。

Label クラスで表示する文字列のプロパティは textProperty() メソッドで取得できます。同じように、TextField クラスも textProperty() メソッドで取得できます。

そこで、コントローラクラスの initialize メソッドに次のように記述します。

        label.textProperty().bind(textField.textProperty());

これでテキストフィールドに何か入力すれば、すぐにラベルに反映されます。

KeyEvent を使うのに比べると、すごい簡単ですね。

しかし、本来やりたかったこととはちょっと違います。ここでは、Hello, X! の X の部分だけをテキストフィールドに入力させるということをやっていました。

しかし、先ほどのコードだとテキストフィールドの文字列とラベルの文字列が同一になってしまいます。

ではどうするかというと、プロパティ同士の演算をやってしまえばいいのです。

プロパティ同士の演算は、ユーティリティクラスの Bindings で定義されています。文字列の連結には、concat メソッドが使えます。

そこで、上のコードを下のように書き換えます。

        label.textProperty().bind(Bindings.concat("Hello, ", textField.textProperty(), "!"));

これで OK!

ChangeListener と InvalidationListener

後半のテーブルとブラウザを使用したサンプルでは、テーブルの選択行が変更されると、ブラウザで表示するというものでした。

この時、テーブルの選択行が変わったことを検知する部分を次のように記述していました。

        TableView.TableViewSelectionModel<Bookmark> selectionModel = table.getSelectionModel();
        selectionModel.selectedItemProperty().addListener(new ChangeListener<Bookmark>() {
            @Override
            public void changed(ObservableValue<? extends Bookmark> value, Bookmark old, Bookmark next) {
                String url = next.getUrl();
                engine.load(url);
            }
        });

しかし、これではまれに NullPointerException 例外が発生してしまうことがあります。といのも、chaged メソッドの第 2, 第 3 引数に null が渡ってくることがあるからです。

そのため、ここには null check が必要です。

            public void changed(ObservableValue<? extends Bookmark> value, Bookmark old, Bookmark next) {
                if (next != null) {
                    String url = next.getUrl();
                    engine.load(url);
                }
            }

ところで、ここでは ChangeListener インタフェースを使用しましたが、InvalidationListener インタフェースを使用することもできます。

両者の違いは、ChangeListener インタフェースがプロパティの値が変化したことを検知することに対し、InvalidationListener インタフェースがプロパティが使用されるときに値をチェックするということです。

このため、ChangeListener インタフェースではイベントが発生しても、InvalidationListener インタフェースではイベントが発生しないということがあります。

逆にいうと、InvalidationListener インタフェースを使用すれば、無駄なイベントを処理せずにすみます。

ただ、InvalidationListener インタフェースはちょっと使いにくい。といのも、InvalidationListener インタフェースの invalidated メソッドの引数の方が Observable インタフェースだからです。

Observable インタフェースを実装したクラスがプロパティになるのですが、Observable インタフェースにはリスナー登録のメソッドしか定義していないため、結局プロパティにキャストしてあげなくてはいけません。その前に instanceof で型のチェックをするわけですけど、instanceof にはジェネリクスの型が指定できません。

結局、めんどくさいので引数は使用しないということになりがち。

で、InvalidationListener インタフェースで書くとしたら、次のようになります。

        final TableView.TableViewSelectionModel<Bookmark> selectionModel = table.getSelectionModel();

        selectionModel.selectedItemProperty().addListener(new InvalidationListener() {
            @Override
            public void invalidated(Observable observable) {
                Bookmark bookmark = selectionModel.getSelectedItem();
                if (bookmark != null) {
                    engine.load(bookmark.getUrl());
                }
            }
        });

さて、ここでの例は ChangeListener インタフェースで書いても、InvalidationListener インタフェースで書いても、差はありません。

でも、ほんとに画面を更新する場合だけ値をチェックすればいい場合も多く、こういう場合は InvalidationListener インタフェースが有効になります。どちらも使えるようにしておけるといいですね。

ページのロード完了時のアニメーション

GitHub にあげてあるサンプルでは、Web ページのロード完了時にフェードインするアニメーションが書いてあります。でも、ハンズオンではもうちょっと派手なアニメーションを書きました。

派手といっても、複数のアニメーションを同時に行うことで派手に見せているだけです。

ここではフェードインと回転とスケーリングを一緒にやってみます。

もともとのコードはこれ。

        // ページのローディングが完了したら、フェードイン
        FadeTransition fadeIn = new FadeTransition(Duration.millis(1_000), webView);
        fadeIn.setToValue(1.0);
        fadeIn.play();

これを次のように変えました。

        FadeTransition fadeIn = new FadeTransition(Duration.millis(1_000), webView);
        fadeIn.setToValue(1.0);
                    
        RotateTransition rotate = new RotateTransition(Duration.millis(1_000), webView);
        rotate.setFromAngle(-360.0);
        rotate.setToAngle(0.0);
                    
        ScaleTransition scale = new ScaleTransition(Duration.millis(1_000), webView);
        scale.setFromX(0.1); scale.setFromY(0.1);
        scale.setToX(1.0); scale.setToY(1.0);
                    
        new ParallelTransition(fadeIn, rotate, scale).play();

RotateTransition クラスが回転、ScaleTransition クラスがスケーリングです。

そして、最後の ParallelTransition クラスで、複数のアニメーションを同時に行います。

まぁ、派手にしても意味はないんですけどね ^ ^;;

さて、ハンズオンですが、アンケートでも好評だというご意見が多かったです。その一方、Java EE と同時開催はやめて欲しいという意見も。

今回は、たまたま大きい会場がとれてしまったものの、そんな人数でハンズオンするのも大変なので、半分に分けましょうということで、こうなったのでした。次回はもうちょっと考えます。

とりあえず、ハンズオンの企画はこれからも続けようと思っているので、もしこういうハンズオンをやって欲しいという意見があれば、JJUG の ML や Twitter の @JJUG へお願いします!!

巻物プレゼン

7/22 に JJUG のナイトセミナ Inside Lambda で「Project Lambda の基礎」というタイトルでプレゼンをしてきました。

内容は SlideShare で見てもらうとして、今回は私は前座で、とりは宮川さんの Lambda の内部構造。なので、今回はちょっと遊ばせてもらいました。

何を遊んだかというと、プレゼンの資料です ^ ^;;

この講演の前に、映画の Short Peace で大友克洋が巻物風のアニメーションをやっているということをテレビで見たのです。絵コンテも巻物ということで、すごい横長。これはおもしろいなぁと思ったわけです。

で、プレゼンでもやってみたくなってしまったわけです、巻物を。

でも、さすがに下の絵のように左から右へと動くわけにはいきません。というのも、そうすると縦書きにしなくては行けないからです。

かといって、上から下に動かすと巻物というより、掛け軸かすだれみたいになってしまいます。

ということで、下から上に動かすようにしてみました。

動きが決まったら、それをどうやって JavaFX で実現させるかです。

いちおう、3D でほんとに巻物を実現することも考えたのですが、すぐに止めました >< だって、そんなアニメーション作るの大変なんだもん。

では、どうやったかというと、簡単です。

手前に円筒を配置して、紙送りするときには、円筒を回転させるアニメーションをさせます。そして、円筒の後ろに平面のノードを配置して、円筒の回転と一緒に上に移動させるアニメーションを行います。

ただし、それだけだと円筒よりも下の部分が見えてしまって、円筒とノードが別々だということが分かってしまうので、円筒の中心より下は見えないようにクリッピングしてしまいます。

絵で描くと、こんな感じ。

ちなみに、下に配置するノードはほんとの巻物ではないので、長くする必要はないです。その代わりに、紙送りをするときに、今表示しているノードと次に表示するノードをピッタリくっつけて、移動させます。

では、これを JavaFX で書いてみます。

まず、円筒。JavaFX 8 だと、円筒やキューブのようなプリミティブな 3D のオブジェクトを表すクラスが追加されています。

円筒は javafx.scene.shape.Cylinder クラスです。

Cylinder クラスをそのまま描画させると円筒が立って表示されます。そのため、回転を行って水平方向に表示させたいのです。ところが、アニメーションで RotateTransition クラスを使いたいため、回転に rotate プロパティは使えません。というのも RotateTransition クラスが行うアニメーションは rotate プロパティを使用して回転を行わせているからです。

そのため、javafx.scene.transfom.Rotate クラスを使用して、回転を表現し、それをノードにセットするようにしました。

また、画面下方に表示させるために移動も行うのですが、アニメーションでは回転のみなので、こちらは setTranslateX/setTranslateY メソッドで行っています。

なお、ここでは表示サイズを 1024x768 と想定して記述しています。

        cylinder = new Cylinder(40, 1000.0);
        Rotate rotate = Transform.rotate(90, 0, 0);
        cylinder.getTransforms().add(rotate);
        cylinder.setTranslateX(510);
        cylinder.setTranslateY(740);

これをシーングラフに追加すればいいのですが、これだけだと円筒のようには見えません。というのも、JavaFX のデフォルトでは平行投影されているため、円筒が傾いていない限り、単なる四角に見えてしまうためです。

ちゃんと円筒のように膨らみを持たせるには、一点透視にする必要があります。これを行っているのが javafx.scene.Camera クラスです。

Camera クラスのサブクラスには平行投影する ParallelCamera クラスと、一点透視を行う PerspectiveCamera クラスがあります。デフォルトでは ParallelCamera クラスが使われているので、PerspectiveCamera クラスに切り替えます。

        Scene scene = new Scene(root, constants.getWidth(), constants.getHeight());
        scene.setCamera(new PerspectiveCamera());

さて、ここまでで実行してみます。

ちゃんと円筒と分かりますね。しかし、白い円筒ではちょっとあじけない。

そこで、テクスチャーマッピングという手法を使用します。簡単にいえば、イメージを描画オブジェクトの表面に貼ってしまうと手法です。

適当にフリーのイメージで巻物っぽい絵を探してきて、それを貼ってみました。

        PhongMaterial mat = new PhongMaterial();
        Image diffuseMap = new Image(getClass().getResource("images.jpg").toString());
        mat.setDiffuseMap(diffuseMap);
        cylinder.setMaterial(mat);

テクスチャーマッピングで貼るものは javafx.scene.paint.PhongMaterial クラスで表します。そこにイメージをセットして、Cylinder オブジェクトに setMatrial メソッドでセットします。

これでテクスチャーマッピングのできあがりです。実行してみると、こんな感じ。

それっぽくなってきました。

後は後ろにノードを配置させて動かすだけです。これは今までのプレゼンツールの機能を使っています。

ナイトセミナでは SVG を使ったのですが、ここでは FXML で書いてます。単に上に動かすアニメーションです。

そして、それと同時に円筒もアニメーションさせます。

でも、ただ円筒を回転させるのもつまらないし、いくらたっても減らない魔法の円筒になってしまいます。そこで、回転するアニメーションと一緒に、円筒の半径を減らしていくというアニメーションを同時に行っています。

また、半径が減れば、同じ量の紙送りするにはより回転させる必要があるので、回転量もだんだんと増えるようにしてみました。

    public void roll() {
        angle *= 1.1;
        
        RotateTransition trans = new RotateTransition(Duration.millis(2_000));
        trans.setAxis(new Point3D(1.0, 0.0, 0.0));
        trans.setNode(cylinder);
        trans.setByAngle(angle);
        trans.setInterpolator(Interpolator.LINEAR);
        trans.play();
        
        Timeline timeline = new Timeline(
            new KeyFrame(Duration.millis(2_000),
                         new KeyValue(cylinder.radiusProperty(), cylinder.getRadius()*.95))
        );
        timeline.play();
    }

ParallelTransition クラスは使っていないのですが、一緒に動かせばだいたい同時にアニメーションしてくれます。そんなに厳密なものではないので、これで十分。

忘れてましたけど、デフォルトだと回転軸は z 軸方向になっているので、これを x 軸方向にしておいてやる必要があります。

という部分をプレゼンツールにくっつけて、プレゼンしたのでした。

ただ、今までは 1 枚のページの手前になにか表示するという発想がなかったので、やっつけ感あふれる実装になっています。

もし、今後もこういうようにページの手前に表示する必要が出てきたら、ちゃんとした実装にします ^ ^;;;

ここで使ったものは GitHub にアップしてあります。いつも使っているプレゼンツールのサブセットだと思ってください。

makimono
https://github.com/skrb/makimono

Duke 007

もうずいぶん前のことなのですが、Java Day Tokyo 2013Java the Night でデモをしてきました。

何をデモしたかというと、いつもプレゼンテーションで使用している JavaFX のプレゼンテーションツール Caribe。

Caraibe 自体は自分がプレゼンでやりたいことができるように、自由度をかなり上げていて、普通の人が使うのはかなりつらいと思うので、単体では公開していないのです。でも、プレゼンごと GitHub にアップ してあったりするので、そちらを見ていただければと思います。

で、今日はそのオープニングで作ったアニメーションについて。

去年は Star Wars のアニメーション作ったので、今年は 007 です。

Java the Night の音なしバージョンは YouTube にアップしてあるので、そのはじめの部分を見ていただければ分かるはず。

この 007 風 Duke のアニメーションについてどうやって作ったか、解説していきます。

なお、今回のアニメーションだけとりだしたバージョンを GitHub で公開しているので、そちらも参照していただければと思います。

Duke007

なお、今回は NetBeans ではなく、IntelliJ IDEA を使用しています。NetBeansJava SE 8 対応は 7.4 からになると思うのですが、現在は Nightly Build しか公開されていません。

この Nightly Build がほんとうにダメダメ。ビルドが進めば進むほど安定度が悪くなるってどういうこと? 6/30 の段階では、Java SE 8 はなんとか使えても、JavaFX 8 は全然使えなくなってしまいました ><

ということで、はじめての IntelliJ IDEA のプロジェクトでした。

全体構成

いつものように絵は Illustrator で描いて、SVG に変換してあります。

アニメーションしたいパーツごとに、レイヤーを分けてあります。こんな感じ。

それを自作の SVGLoader で読み込んでいます。たとえば、背景の黒を読み込んでいる部分はこんな感じ。

        svgContent = SVGLoader.load(getClass().getResource(SVG_FILE).toString());

        Node background = svgContent.getNode("background");
        root.getChildren().add(background);

ただし、一番始めの円だけは単純なので、コードで書いています。

アニメーションさせるノード群がそろったら、アニメーションのコードを書いていきます。

ここでは全部 Timeline を使って書いてます。メインのタイムラインを SequentialTransition で書いてもいいのですが、ちょっと複雑なことをやろうとすると、SequentialTransition は結構めんどうくさいんですよね。

Java the Night のためにこれを作るときは、時間があまりなかったので、Timeline に直書きしています。より柔軟にやるには以前 複雑なアニメーション で書いたように、メインのタイムラインと、子アニメーションの構成で書く方が柔軟性が高いです。

さて、全体の流れはこんな感じです。

        Timeline timeline = new Timeline(
                new KeyFrame(Duration.ZERO,
                        new KeyValue(propertyX, initX),
                        new KeyValue(propertyY, initY)),
                new KeyFrame(Duration.millis(  500),
                        new KeyValue(propertyX, nextX),
                        new KeyValue(propertyY, nextY)),

            <<省略>>

        );

        timeline.play();

では、部分のアニメーションについて見ていきます。

円のアニメーション

一番始めは、2 つの円がアニメーションする部分です。1 つの円が等速運動していて、もう 1 つの円が等速運動している円に追いついていくような感じ。

2 つ目の円は実際には一定期間同じ場所にいて、一瞬で移動、再び一定期間同じ場所というのを繰り返しています。

普通に Timeline - KeyFrame - KeyValue で書いてしまうとずっと移動してしまうので、工夫が必要です。たとえば...

        Circle circle1 = new Circle(-100.0, 384.0, 50.0);
        circle1.setFill(Color.WHITE);
        root.getChildren().add(circle1);

        Circle circle2 = new Circle(-100.0, 384.0, 50.0);
        circle2.setFill(Color.WHITE);
        root.getChildren().add(circle2);

        Timeline timeline = new Timeline(
                new KeyFrame(Duration.ZERO,
                        new KeyValue(circle1.translateXProperty(), 0.0),
                        new KeyValue(circle2.translateXProperty(), 0.0)),
                // 490ms まで同じ場所に留めて、500ms までの 10ms で移動
                new KeyFrame(Duration.millis(  490),
                        new KeyValue(circle2.translateXProperty(), 0.0)),
                new KeyFrame(Duration.millis(  500),
                        new KeyValue(circle1.translateXProperty(), 200.0),
                        new KeyValue(circle2.translateXProperty(), 200.0)),
                // 990ms まで同じ場所に留めて、1000ms までの 10ms で移動
                new KeyFrame(Duration.millis(  990),
                        new KeyValue(circle2.translateXProperty(), 200.0)),
                new KeyFrame(Duration.millis(1_000),
                        new KeyValue(circle1.translateXProperty(), 400.0),
                        new KeyValue(circle2.translateXProperty(), 400.0)),

何をやっているかというと、500ms ごとに移動をさせているのですが、その 10ms 前まで同じ位置にいるということをわざと書いています。

一方は 500 ミリ秒までの間等速で移動していますが、一方は 0 から 490ms まで同じ位置、490ms から 500ms で移動ということを繰り返しているわけです。

これでもうまくいくのですが、10ms 前の場所を書かなくてはいけないのがちょっと...

そこで、使うのが Interpolator です。

えっ、Interpolator はイージングの時使うんだけじゃないの、と思われるかもしれません。でも、Interpolator には DISCRETE というのがあるのです。

DISCRETE は離散という意味です。つまりパラパラマンガを作るときに使います。ぎりぎりまで同じ場所で、次に違う場所というのは、パラパラマンガと同じなわけですね。


で上の Timeline がこうなりました。

        Timeline timeline = new Timeline(
            new KeyFrame(Duration.ZERO,
                         new KeyValue(circle1.translateXProperty(), 0.0),
                        new KeyValue(circle2.translateXProperty(), 0.0)),
            new KeyFrame(Duration.millis(  500),
                        new KeyValue(circle1.translateXProperty(), 200.0),
                        new KeyValue(circle2.translateXProperty(), 200.0, 
                                     Interpolator.DISCRETE)),
            new KeyFrame(Duration.millis(1_000),
                        new KeyValue(circle1.translateXProperty(), 400.0),
                        new KeyValue(circle2.translateXProperty(), 400.0, 
                                     Interpolator.DISCRETE)),

Transition の場合は setInterpolator メソッドを使用しますが、Timeline の場合は KeyValue で Interpolator を指定します。

つまり KeyFrame ごとに補間方法を変化させられるわけです。

これで、追いかけっこする円ができました。

歩くDuke

次は銃身の穴と一緒に歩く Duke です。実をいうと、この部分は Java the Night に間に合わなくて、やらなかったんです ^ ^;;

歩くアニメーションは足踏みをするパラパラマンガと、移動のアニメーションを組み合わせて行ないます。足踏みの方は繰り返しアニメーションしておきます。

Duke は手が短いので、足だけ動いている絵をまず用意しました。ここでは、5 枚の絵でアニメーションさせています。

移動は、全体の Timeline で一緒に行なっているのですが、足踏みだけは別の Timeline で表してます。

    private Animation prepareWalking(Group root) {
        walkingDuke = new Group();

        // 足踏みしているイメージを読み込み、
        // 透明にしておく
        for (int index = 0; index < 5; index++) {
            Node duke = svgContent.getNode(String.format("walk%02d", index));
            duke.setOpacity(0.0);
            walkingDuke.getChildren().add(duke);
        }

        Timeline walkingAnimation = new Timeline();

        // 一定時間ごとに、透明度を変化させて、表示させるイメージを切り替える
        KeyFrame keyFrame0 = new KeyFrame(Duration.millis(0),
                new KeyValue(walkingDuke.getChildren().get(0).opacityProperty(), 
                             1.0,
                             Interpolator.DISCRETE),
                new KeyValue(walkingDuke.getChildren().get(4).opacityProperty(), 
                             0.0, 
                             Interpolator.DISCRETE));
        walkingAnimation.getKeyFrames().add(keyFrame0);

        for (int i = 1; i < 5; i++) {
            KeyFrame keyFrame = new KeyFrame(Duration.millis(200*i),
                new KeyValue(walkingDuke.getChildren().get(i).opacityProperty(), 
                             1.0, 
                             Interpolator.DISCRETE),
                new KeyValue(walkingDuke.getChildren().get(i-1).opacityProperty(), 
                             0.0, 
                             Interpolator.DISCRETE));
            walkingAnimation.getKeyFrames().add(keyFrame);
        }

        // 無限に繰り返し
        walkingAnimation.setCycleCount(Timeline.INDEFINITE);

        return walkingAnimation;
    }


足踏みのパラパラマンガは、一定時間ごとにイメージを切り替えることで実現します。ここではイメージの切り替えに透明度を変化させることで行ないました。

もちろん、Interpolator は DISCRETE です。

そして、setCycleCount メソッドの引数に INDEFINITE を指定することで、無限回アニメーションさせています。

さて、移動の方です。そちらは前述したように、メインの Timeline でやってます。なので、先ほどの続きから。

                new KeyFrame(Duration.millis(3_000),
                        e -> {
                            // コンテナにこれから移動させるノードを追加
                            root.getChildren().add(barrelHole);
                            root.getChildren().add(walkingDuke);
                            root.getChildren().add(duke);
                            root.getChildren().add(riffle);
                            root.getChildren().add(blood);

                            // 足踏みアニメーションをスタート
                            walkingAnimation.play();
                        },
                        new KeyValue(circle1.translateXProperty(), 1200.0),
                        new KeyValue(circle2.translateXProperty(), 1200.0, Interpolator.DISCRETE),
                        new KeyValue(barrelHole.translateXProperty(), 1400.0),
                        new KeyValue(walkingDuke.translateXProperty(), 1400.0),
                        new KeyValue(riffle.translateXProperty(), 1400.0)),
                new KeyFrame(Duration.millis(7_000),
                        e -> {
                            // 足踏みアニメーションをストップ
                            walkingAnimation.stop();
                        },
                        // 足踏みしている横向きのDukeを非表示にする
                        new KeyValue(walkingDuke.opacityProperty(), 0.0, Interpolator.DISCRETE),
                        new KeyValue(duke.opacityProperty(), 1.0, Interpolator.DISCRETE),
                        new KeyValue(barrelHole.translateXProperty(), 0.0),
                        new KeyValue(walkingDuke.translateXProperty(), 0.0),
                        new KeyValue(riffle.translateXProperty(), 0.0)),

e -> { ... } の部分は Lambda 式で、もともとは EventHandler を表してます。KeyFrame が指定している時間に行なう処理を記述します。

ここでは、3 秒の時にこれから移動する要素をコンテナに追加し、7 秒の時に無限に続く足踏みアニメーションを停止させています。

さて、残りは移動のアニメーションだけで構成されているので、たいしたことないです。

最後にとりあえず、コード載せておきます。なんか、Timeline すごいことになってますねww

package net.javainthebox.dukeanimation;

import com.sun.scenario.animation.shared.ClipInterpolator;
import javafx.animation.Animation;
import javafx.animation.Interpolator;
import javafx.animation.KeyFrame;
import javafx.animation.KeyValue;
import javafx.animation.Timeline;
import javafx.application.Application;
import javafx.event.ActionEvent;
import javafx.event.EventHandler;
import javafx.scene.Group;
import javafx.scene.Node;
import javafx.scene.Scene;
import javafx.scene.media.AudioClip;
import javafx.scene.paint.Color;
import javafx.scene.shape.Circle;
import javafx.stage.Stage;
import javafx.util.Duration;
import net.javainthebox.caraibe.svg.SVGContent;
import net.javainthebox.caraibe.svg.SVGLoader;

public class Duke007 extends Application {
    private final static String SVG_FILE = "duke007.svg";

    private SVGContent svgContent;
    private Group walkingDuke;

    @Override
    public void start(Stage stage) {
        Group root = new Group();

        svgContent = SVGLoader.load(getClass().getResource(SVG_FILE).toString());

        Node background = svgContent.getNode("background");
        root.getChildren().add(background);

        starAnimation(root);

        Scene scene = new Scene(root, 1024, 768);
        stage.setScene(scene);
        stage.show();
    }

    private void starAnimation(Group root) {
        Circle circle1 = new Circle(-100.0, 384.0, 50.0);
        circle1.setFill(Color.WHITE);
        root.getChildren().add(circle1);

        Circle circle2 = new Circle(-100.0, 384.0, 50.0);
        circle2.setFill(Color.WHITE);
        root.getChildren().add(circle2);

        Node barrelHole = svgContent.getNode("barrelhole");
        Node duke = svgContent.getNode("dukestand");
        duke.setOpacity(0.0);
        Node riffle = svgContent.getNode("riffle");
        Node blood = svgContent.getNode("blood");

        Animation walkingAnimation = prepareWalking(root);

        Timeline timeline = new Timeline(
                new KeyFrame(Duration.millis(  500),
                        new KeyValue(circle1.translateXProperty(), 200.0),
                        new KeyValue(circle2.translateXProperty(), 200.0, Interpolator.DISCRETE)),
                new KeyFrame(Duration.millis(1_000),
                        new KeyValue(circle1.translateXProperty(), 400.0),
                        new KeyValue(circle2.translateXProperty(), 400.0, Interpolator.DISCRETE)),
                new KeyFrame(Duration.millis(1_500),
                        new KeyValue(circle1.translateXProperty(), 600.0),
                        new KeyValue(circle2.translateXProperty(), 600.0, Interpolator.DISCRETE)),
                new KeyFrame(Duration.millis(2_000),
                        new KeyValue(circle1.translateXProperty(), 800.0),
                        new KeyValue(circle2.translateXProperty(), 800.0, Interpolator.DISCRETE)),
                new KeyFrame(Duration.millis(2_500),
                        new KeyValue(circle1.translateXProperty(), 1000.0),
                        new KeyValue(circle2.translateXProperty(), 1000.0, Interpolator.DISCRETE)),
                new KeyFrame(Duration.millis(3_000),
                        e -> {
                            root.getChildren().add(barrelHole);
                            root.getChildren().add(walkingDuke);
                            root.getChildren().add(duke);
                            root.getChildren().add(riffle);
                            root.getChildren().add(blood);

                            walkingAnimation.play();
                        },
                        new KeyValue(circle1.translateXProperty(), 1200.0),
                        new KeyValue(circle2.translateXProperty(), 1200.0, Interpolator.DISCRETE),
                        new KeyValue(barrelHole.translateXProperty(), 1400.0),
                        new KeyValue(walkingDuke.translateXProperty(), 1400.0),
                        new KeyValue(riffle.translateXProperty(), 1400.0)),
                new KeyFrame(Duration.millis(7_000),
                        e -> {
                            walkingAnimation.stop();
                        },
                        new KeyValue(walkingDuke.opacityProperty(), 0.0, Interpolator.DISCRETE),
                        new KeyValue(duke.opacityProperty(), 1.0, Interpolator.DISCRETE),
                        new KeyValue(barrelHole.translateXProperty(), 0.0),
                        new KeyValue(walkingDuke.translateXProperty(), 0.0),
                        new KeyValue(riffle.translateXProperty(), 0.0)),
                new KeyFrame(Duration.millis(7_500),
                        e -> {
                            AudioClip clip = new AudioClip(getClass().getResource("OMT004_02S005.wav").toString());
                            clip.play();
                        }),
                new KeyFrame(Duration.millis(8_000),
                        new KeyValue(barrelHole.translateXProperty(), 0.0),
                        new KeyValue(barrelHole.translateYProperty(), 0.0),
                        new KeyValue(riffle.translateXProperty(), 0.0),
                        new KeyValue(riffle.translateYProperty(), 0.0),
                        new KeyValue(blood.translateYProperty(), 0.0)),
                new KeyFrame(Duration.millis(9_000),
                        new KeyValue(barrelHole.translateXProperty(), -200.0),
                        new KeyValue(barrelHole.translateYProperty(), 100.0),
                        new KeyValue(riffle.translateXProperty(), -200.0),
                        new KeyValue(riffle.translateYProperty(), 100.0)),
                new KeyFrame(Duration.millis(10_000),
                        new KeyValue(barrelHole.translateXProperty(), -100.0),
                        new KeyValue(barrelHole.translateYProperty(), 200.0),
                        new KeyValue(riffle.translateXProperty(), -100.0),
                        new KeyValue(riffle.translateYProperty(), 200.0)),
                new KeyFrame(Duration.millis(11_000),
                        new KeyValue(barrelHole.translateXProperty(), 100.0),
                        new KeyValue(barrelHole.translateYProperty(), 400.0),
                        new KeyValue(riffle.translateXProperty(), 100.0),
                        new KeyValue(riffle.translateYProperty(), 400.0)),
                new KeyFrame(Duration.millis(12_000),
                        new KeyValue(barrelHole.translateXProperty(), 0.0),
                        new KeyValue(barrelHole.translateYProperty(), 900.0),
                        new KeyValue(riffle.translateXProperty(), 0.0),
                        new KeyValue(riffle.translateYProperty(), 900.0)),
                new KeyFrame(Duration.millis(15_000),
                        new KeyValue(blood.translateYProperty(), 1700.0))
        );

        timeline.play();
    }

    private Animation prepareWalking(Group root) {
        walkingDuke = new Group();

        // 足踏みしているイメージを読み込み、
        // 透明にしておく
        for (int index = 0; index < 5; index++) {
            Node duke = svgContent.getNode(String.format("walk%02d", index));
            duke.setOpacity(0.0);
            walkingDuke.getChildren().add(duke);
        }

        Timeline walkingAnimation = new Timeline();

        // 一定時間ごとに、透明度を変化させて、表示させるイメージを切り替える
        KeyFrame keyFrame0 = new KeyFrame(Duration.millis(0),
                new KeyValue(walkingDuke.getChildren().get(0).opacityProperty(), 1.0, Interpolator.DISCRETE),
                new KeyValue(walkingDuke.getChildren().get(4).opacityProperty(), 0.0, Interpolator.DISCRETE));
        walkingAnimation.getKeyFrames().add(keyFrame0);

        for (int i = 1; i < 5; i++) {
            KeyFrame keyFrame = new KeyFrame(Duration.millis(200*i),
                    new KeyValue(walkingDuke.getChildren().get(i).opacityProperty(), 1.0, Interpolator.DISCRETE),
                    new KeyValue(walkingDuke.getChildren().get(i-1).opacityProperty(), 0.0, Interpolator.DISCRETE));
            walkingAnimation.getKeyFrames().add(keyFrame);
        }

        // 無限に繰り返し
        walkingAnimation.setCycleCount(Timeline.INDEFINITE);

        return walkingAnimation;
    }

    public static void main(String... args) {
        launch(args);
    }
}