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);
    }
}

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

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

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


つづく

Touch で Animation

今日公開された ITpro の連載 で、タッチのジェスチャについて書きました。

記事の中では、イメージをスワイプで切り替えるビューアーのサンプルを書いたのですが、分量の問題でボツにしたサンプルもあります。そのままにするのももったいなので、ここで公開しておきます。

ちなみに、このエントリーは JavaFX Advent Calendar 向けに書こうと思っていたのですが、意外なことにすべて埋まってしまったので、普通のエントリーです ^ ^;;

みなさま、ゾエトロープ (Zoetrope) ってご存じですか?

アニメーションの元祖のような器具で、円筒の内側に連続した絵を描いておいて、回転させながらスリットからのぞき込むと絵が動いているように見えるものです。日本語だと回転のぞき絵というらしいです。

Wikipedia

Zoetrope は円筒を自分の手で回してアニメーションさせるわけですが、これをタッチというかジェスチャでやってみようかなと。

というわけで、作ってみました。

アニメーションさせるのは、おなじみの手を振る Duke です。Duke の画像は duke.kenai.com からダウンロードしてきました。

さて、できあがりの GUI を下に示します。Duke が並んでいますが、この画面を右側にスワイプさせると Duke がアニメーションするようにします。また、左側にスワイプすると、アニメーションが逆転します。

このアニメーションはスワイプのスピードによって速さが決まり、慣性スクロールで指が離れてもしばらくアニメーションが続きます。

Duke の画像を表示しているのは ImageView クラスです。アニメーションさせるには ImageView オブジェクトで表示する Image オブジェクトを切り替えるということをやっています。

ところで、Image オブジェクトは順番に呼び出して、終わりまでいったら最初からということを何度も繰り返すので、先にリングバッファを作ってしまいました。

リングバッファというのはコレクションの一種で、円環状に要素を取得できるコレクションです。これを作るのは簡単で、単純にインデックスを保持しておいて、最後までいったら 0 に戻すということをやっているだけです。

通常はこれでいいのですが、逆向きにも要素を取得できるメソッドを追加しました。

public class RingBuffer<T> {
    private List<T> buffer;
    private int index = -1;
    
    public RingBuffer(Collection<T> collection) {
        buffer = new ArrayList<>(collection);
    }
    
    // 次の要素を取得する
    // 最後まで到達したら、最初に戻る
    public T next() {
        index++;
        if (index >= buffer.size()) {
            index = 0;
        }

        T element = buffer.get(index);
        
        return element;
    }

    // 前の要素を取得する
    // 最初まで到達したら、最後に戻る
    public T previous() {
        index--;
        if (index < 0) {
            index = buffer.size() - 1;
        }

        T element = buffer.get(index);

        return element;
    }
}

さて、準備はできたので、メインクラスを作っていきます。

ちなみに、Stream と Lambda 式使いまくっているので、Java SE 8 必須ですww

まずは、タッチを考えずにイメージを水平方向に並べることにします。

public class Zoetrope extends Application {
    private List<ImageView> views;
    private RingBuffer<Image> images;
    
    @Override
    public void start(Stage stage) {
        HBox root = new HBox(10.0);
        root.setAlignment(Pos.CENTER);
        
        // ImageView オブジェクトを 10 作成して、リストに格納する
        views = IntStream.range(0, 10)
                         .mapToObj(i -> new ImageView())
                         .collect(Collectors.toList());
        root.getChildren().addAll(views);

        // 9個のファイルを読み込んでリストに格納し、それをリングバッファにする
        images = new RingBuffer(
            IntStream.range(1, 10)
                     .mapToObj(i -> new Image(getClass().getResource("resources/T" + i + ".gif").toString()))
                     .collect(Collectors.toList()));

        // ImageオブジェクトをImageViewオブジェクトにセットする
        views.forEach(view -> view.setImage(images.next()));

        Scene scene = new Scene(root, 800, 200);
        stage.setTitle("Zoetrope");
        stage.setScene(scene);
        stage.show();
    }
    
    public static void main(String... args) {
        launch(args);
    }    
}

Java SE 7 までは、for 文を何回も書かなくてはいけなかったのですが、Stream を使うことでとてもスッキリしました。

これからは、for (int i = 0; i < 10; i++) { ... } という文は、IntStream.range(0, 10).forEach(i -> { ... }); というように書くのが当たり前になるでしょうね。

これで実行すると、上の図のようになります。

では、タッチの処理を追加していきましょう。

ITpro の記事でも解説しましたが、タッチはマウスなどと同じようにイベントとして扱います。タッチは TouchEvent で表せますが、ここではジェスチャのイベントで扱うことにします。

ここで行っているジェスチャはスワイプなのですが、スワイプのイベントはスワイプ中に 1 度しか発生しません。ここでは指がどの程度動いたかによって、イメージを切り替えているので、1 度しか発生しないスワイプイベントは使えません。

そこで、代わりに使うのがスクロールイベント ScrollEvent です。

ScrollEvent はスクロールの開始時、スクロール中、スクロール終了時にイベントが発生します。スクロール中は定期的にイベントが発生します。さらに、スクロール終了した (指が離れた) 後にも、慣性でスクロールイベントが発生します。

また、ScrollEvent は都合のいいことに、前回のイベントからの移動分を取得できるようになっています。この移動分を使えば、イメージを切り替えるかどうかの判断に使用することができます。

ここでは、イメージの切り替えを、スクロールを始めてから、しきい値を超えるまで指を移動させたかで判断するようにします。このため、スクロール開始時に移動量を 0 にリセットし、スクロール中には前回からの移動分を移動量に加えていきます。

その移動量がしきい値を超えたら、イメージを切り替えます。

スクロールの開始は Node#setOnScrollStart メソッド、スクロール中は Node#setOnScroll メソッドで登録します。

イベント処理の登録は initScrollEventHandler メソッドでやることにしました。

    private void initScrollEventHandler(HBox root) {
        // スクロール開始時に移動量をリセットしておく
        root.setOnScrollStarted(e -> diff = 0.0);
        
        // 移動量がしきい値 CHANGE_THRESHOLD を超えたらイメージを切り替える
        root.setOnScroll(e -> {
            // 前回からの移動量の差分を加える
            diff += e.getDeltaX();
            
            if (diff >= CHANGE_THRESHOLD) { // 右方向のスクロール
                diff = 0;
                
                // イメージの切り替え
                views.forEach(view -> view.setImage(images.next()));
            }
        });
    }

前回からの水平方向の差分は ScrollEvent クラスの getDeltaX メソッドで取得できます。

Image オブジェクトは 9 個あり、ImageView オブジェクトは 10 個あるので、そのままリングバッファで取得し続けていくと、1 つずつずれるので、アニメーションしてくれます。

ただこれだと、右方向へのスクロールになってしまうので、左方向も加えましょう。

アニメーションを戻すには前のイメージに切り替えることが必要です。そこで、ImageView オブジェクトの右側のものから戻していきます。

    private void initScrollEventHandler(HBox root) {
        // スクロール開始時に移動量をリセットしておく
        root.setOnScrollStarted(e -> diff = 0.0);
        
        // 移動量がしきい値 CHANGE_THRESHOLD を超えたらイメージを切り替える
        root.setOnScroll(e -> {
            // 前回からの移動量の差分を加える
            diff += e.getDeltaX();
            
            if (diff >= CHANGE_THRESHOLD) { // 右方向のスクロール
                diff = 0;
                
                // イメージの切り替え
                views.forEach(view -> view.setImage(images.next()));
            } else if (diff <= -CHANGE_THRESHOLD) { // 左方向のスクロール
                diff = 0;
                
                // イメージを戻すように切り替える
                IntStream.iterate(views.size() - 1, i -> i-1)
                         .limit(views.size())
                         .mapToObj(i -> views.get(i))
                         .forEach(view -> view.setImage(images.previous()));
            }
        });
    }

IntStream#range が開始と最後だけでなく、差分も記述できれば、もっと簡単に書けるのですが、ないのでしかたありません。iterate メソッドだと無限に IntStream が続くので、limit メソッドで要素数を限定し、その後に ImageView オブジェクトを取得して、イメージをセットしていきます。

これで、左方向にスクロールするとアニメーションの逆回転ができます。とはいっても、この Duke のアニメーションは逆回転が分かりにくい ><

実行して、タッチでスクロールさせれば、下の図のようにアニメーションしますよ。タッチのスピードを変えてあげれば、アニメーションのスピードも変化します。

また、慣性スクロールしている間に、再びスクロールすればシームレスにアニメーションし続けます。

いかがですか? けっこう、いい感じにアニメーションしてますよね。

最後にソースの全体を貼っておきます。


Zoetrope: Animation using touch interface

シェイプのストローク

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

昨日はるーつにゃんの JavaFXでめくるエフェクト!!

明日は sipadan2003 さんです。

JavaFX でシェイプを扱っていると、ストロークに関連したプロパティがいろいろあるのに気がつきます。

色や太さはすぐに分かりますけど、それ以外はなじみがないものが多いのではないでしょうか。Illustrator とか使っている人であれば分かるとは思いますけど。

ということで、ここで少しまとめてみましょう。

ソースは gist にあげました。

まずは、こんなパスを描いておきましょう。

    private Path drawPath() {
        Path path = new Path(
                new MoveTo(40.0, 40.0),
                new LineTo(140.0, 40.0),
                new LineTo(40.0, 80.0)
        );

        return path;
    }

カタカナのフのような形です。このパスについては特に難しいところはないはずです。

では、プロパティをいろいろと設定していきましょう。まずは、線色と線の太さから。

  • Shape#setStrokeWidth(double) 線の太さ
  • Shape#setStroke(Paint) 線の色

これは簡単ですね。setStrokeWidth メソッドは引数が double ですが、もちろん正の値だけです。

setStroke メソッドは線色を設定します。線色の方は Paint クラスなので、グラデーションもできます。

ちなみに、デフォルトでは太さは 1、色は Color.BLACK です。

        // デフォルト (線幅: 1, 線色: 黒)
        Path path = drawPath();
        parent.add(path, 0, 0);
        
        // 線幅: 10
        path = drawPath();
        path.setStrokeWidth(10.0);
        parent.add(path, 1, 0);

        // 線幅: 10, 線色: 赤
        path = drawPath();
        path.setStrokeWidth(10.0);
        path.setStroke(Color.RED);
        parent.add(path, 2, 0);

この 3 種類を実行すると次のようになります。

さて、これからがよく分からなくなるところです。

まず、setStrokeLineJoin メソッドです。ストロークのラインがジョインするメソッドです。何のこっちゃという感じですが、角の部分をどのようにするかを指定するためのメソッドです。

setStrokeLineJoin メソッドの引数は enum の StrokeLineJoin です。この enum は 3 つの値をとります。

MITER はマイター接続は線をそのまま延ばしていって交わったところまで伸ばす接続です。ようするに、とんがっている接続ですね。これがデフォルトになります。

ROUND (ラウンド接続) は MITER とは逆に接続部が丸くなっている接続方法です。

BEVEL (ベベル接続) は直線の角と角を結んだような接続です。

これらの接続は線が細い時は目立たないのですが、太いと目立ちます。太い線が集まっていたりすると、MITER だと線がはみ出てしまうこともあるので、ROUND か BEVEL にする方がいいと思います。

ということで、ここでは太さ 10 の線を引いてみます。

    private Path drawThickPath() {
        Path path = drawPath();
        path.setFill(Color.PINK);
        path.setStroke(Color.BLACK);

        // 線の太さを10にする
        path.setStrokeWidth(20.0);

        return path;
    }

で、3 種類の接続を試してみましょう。分かりやすいように細い白い線を一緒に描画します。

        // 角のデフォルト (マイター接合)
        Group group = new Group();
        path = drawThickPath();
        path.setStrokeLineJoin(StrokeLineJoin.MITER);

        path = drawPath();
        path.setStroke(Color.WHITE);
        group.getChildren().add(group);
        parent.add(group, 0, 2);

        // 角を丸くする (ラウンド接合)
        group = new Group();
        path = drawThickPath();
        path.setStrokeLineJoin(StrokeLineJoin.ROUND);

        path = drawPath();
        path.setStroke(Color.WHITE);
        group.getChildren().add(group);
        parent.add(group, 1, 2);

        // 角を削る (ベベル結合)
        group = new Group();
        path = drawThickPath();
        path.setStrokeLineJoin(StrokeLineJoin.BEVEL);

        path = drawPath();
        path.setStroke(Color.WHITE);
        group.getChildren().add(group);
        parent.add(group, 2, 2);

で、実行するとこのようになります。

つづいて、線端です。線端も 3 種類あります。

線端は setStrokeLineCap メソッドで設定します。引数は、こちらも enum の StrokeLineCap です。

  • SQUARE
  • ROUND
  • BUTT

SQUARE はもともとの線の線端から線の太さの分だけ四角が飛び出ているような感じです。ROUND は同じように線端が円になっています。

最後の BUTT は端がブチッと切れてしまった感じの線端になります。

これも試してみましょう。

        // 線端のデフォルト (スクエア線端)
        group = new Group();
        path = drawThickPath();
        path.setStrokeLineCap(StrokeLineCap.SQUARE);
        group.getChildren().add(path);

        path = drawPath();
        path.setStroke(Color.WHITE);
        group.getChildren().add(path);
        parent.add(group, 0, 4);

        // 線端を丸くする (ラウンド線端)
        group = new Group();
        path = drawThickPath();
        path.setStrokeLineCap(StrokeLineCap.ROUND);
        group.getChildren().add(path);
        
        path = drawPath();
        path.setStroke(Color.WHITE);
        group.getChildren().add(path);
        parent.add(group, 1, 4);

        // 線端を四角くする (BUTT線端)
        group = new Group();
        path = drawThickPath();
        path.setStrokeLineCap(StrokeLineCap.BUTT);
        group.getChildren().add(path);

        path = drawPath();
        path.setStroke(Color.WHITE);
        group.getChildren().add(path);
        parent.add(group, 2, 4);

ここでも分かりやすいように、太い線の上に線幅1の白い線を描画してあります。

最後は点線です。

点線はちょっと分かりにくいんですよね。

点線にするには、getStrokeDashArray メソッドを使います。あれっ、get と思いますよね。設定するのに、get を使うというと、JavaFX では ObservableList を返すことが多いのです。

ここでも、getStrokeDashArray メソッドでは ObservableList オブジェクトが帰ります。この ObservableList に要素を入れているいくと点線になるのです。

たとえば、10.0 の ObservableList だとします。この場合、線が 10 ピクセル、空白が 10 ピクセルの点線になります。

10.0 と 5.0 が要素の場合、線が 10 ピクセル、空白が 5.0 ピクセルになります。

10, 2, 5, 2 が要素の場合、線が 10、空白 2 線が 5、空白 2 の続きます。つまり、一点鎖線になるわけです。

        // 点線 (線とスペースの長さが同じ)
        path = drawPath();
        path.getStrokeDashArray().addAll(10.0);
        path.setStrokeWidth(5.0);
        parent.add(path, 0, 6);

        // 点線 (線とスペースの長さが異なる)
        path = drawPath();
        path.getStrokeDashArray().addAll(10.0, 5.0);
        path.setStrokeWidth(5.0);
        parent.add(path, 1, 6);

        // 一点鎖線
        path = drawPath();
        path.getStrokeDashArray().addAll(10.0, 2.0, 5.0, 2.0);
        path.setStrokeWidth(5.0);
        parent.add(path, 2, 6);

すると、このようになります。

要素数が偶数だと線の長さから始まって、空白の長さで終わるので分かりやすいですけど、偶数で活けないわけではありません。ただし、奇数だとちょっと分かりにくいのです。

たとえば、10, 2, 5 だと、線 10、空白 2、線 5、空白 10、線 2、空白 5 という繰り返しになります。というように、ちょっと分かりにくいので、要素が 1 つにするか、偶数という方が分かりやすいと思います。

ということで、ちょっと地味なエントリーでした。

最後にソース全体です。


gist7761966

曲線のアニメーション

このエントリーは、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);
    }
}