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