JavaFX で作ったミニゲームを GWT で Web ブラウザに移植してみる

  • 4
    いいね
  • 0
    コメント

本記事は、「アプレッソ Advent Calendar 2016」8日目の記事です。
私は DataSpider Servista の開発メンバーで、スクラムによる次バージョン開発に取り組んでいます。
私にとっては初めてのプロジェクトということもあり、とても刺激的な毎日を送っています。

Java といえばブラウザゲーム、そんな時代もありました

  • スーパー正男
  • WWA (World Wide Adventure)
  • ポン太の冒険

……どれか一つでもご存知の方はいらっしゃいませんか?
これらは Java アプレット でできた、インターネット黎明期に流行ったブラウザゲームです。

上に挙げたような 2D ゲームならば、今は HTML5 と JavaScript で作れるでしょう。
とはいえ、素の JavaScript で書くのは結構つらくありませんか?私の場合は……

  • prototype でクラス(のようなもの)を作るのが面倒だったり(特に継承とか……)
  • 動的型付けなので実行するまで単純なコーディングミスに気づかなかったり
  • IDE やエディタの補完が微妙だったり

つまり、クラスベースの静的型付けで入力補完の賢い言語 で書きたいのです!
例えば仕事でも使っている Java で、アプレットで遊んだあの頃の思い出に浸りながら。
Java で書いたコードから JavaScript を生成といえば、ピッタリのものが昔ありましたよね?

最近の GWT について

そこで登場するのが、 2006 年の 1.0 リリースから 10 年を迎える GWT (Google Web Toolkit) です。
検索でヒットする日本語情報は古いものが目立ちますが、現在でも採用されているプロダクトはあります。

UI ツールキットというよりは、 AltJS のようなトランスパイラとしての利用が多いですね。
10 月に正式リリースされた 2.8 では一部の Java 8 の API もサポートされました。
さらに 11 月には GWT CON 2016 も開催されており、まだまだ現役の技術と言えるでしょう。

実際にやってみた

というわけで、サンプルとしてブロック崩し(のようなもの……)を作ってみました。
詳しくは GitHub のリポジトリをご覧いただくとして、ここではポイントに絞ってご説明します。

まずは JavaFX で動かしてみる

移植を見越して、入力と描画はプラットフォームごとの実装に依存しないようにしました。
ここでは JavaFX の API を使ったそれぞれの Delegate を実装して渡しています。

JFXPlayer.java
package com.example.breakout.client;

import com.example.breakout.client.lib.GameColor;
import com.example.breakout.client.lib.GameInput;
import com.example.breakout.client.lib.GameLoop;
import com.example.breakout.client.lib.GameScreen;
import com.example.breakout.client.scenes.TitleScene;
import javafx.animation.AnimationTimer;
import javafx.application.Application;
import javafx.geometry.VPos;
import javafx.scene.Scene;
import javafx.scene.canvas.Canvas;
import javafx.scene.canvas.GraphicsContext;
import javafx.scene.input.KeyCode;
import javafx.scene.layout.StackPane;
import javafx.scene.paint.Color;
import javafx.scene.text.Font;
import javafx.scene.text.TextAlignment;
import javafx.stage.Stage;

public class JFXPlayer extends Application {

    public static void main(final String[] args) {
        launch(args);
    }

    @Override
    public void start(final Stage stage) {
        final JFXScreen screen = new JFXScreen();
        GameScreen.setDelegate(screen);

        final StackPane pane = new StackPane(screen.getCanvas());
        final Scene scene = new Scene(pane, GameScreen.WIDTH, GameScreen.HEIGHT);
        GameInput.setDelegate(new JFXInput(scene));
        stage.setScene(scene);
        stage.show();

        GameLoop.setScene(new TitleScene());

        new AnimationTimer() {
            @Override
            public void handle(final long now) {
                GameLoop.update();
            }
        }.start();
    }

    public static class JFXInput extends GameInput.Delegate {
        JFXInput(final Scene scene) {
            scene.setOnKeyPressed(key -> {
                final KeyCode keyCode = key.getCode();
                if (keyCode.equals(KeyCode.LEFT)) {
                    x = -1;
                } else if (keyCode.equals(KeyCode.RIGHT)) {
                    x = 1;
                } else if (keyCode.equals(KeyCode.SPACE)) {
                    spacePressed = true;
                }
            });
            scene.setOnKeyReleased(key -> reset());
        }
    }

    private static class JFXScreen implements GameScreen.Delegate {
        private final Canvas canvas;
        private final GraphicsContext context;

        JFXScreen() {
            canvas = new Canvas(GameScreen.WIDTH, GameScreen.HEIGHT);
            context = canvas.getGraphicsContext2D();
            context.setTextAlign(TextAlignment.LEFT);
            context.setTextBaseline(VPos.TOP);
        }

        Canvas getCanvas() {
            return canvas;
        }

        @Override
        public void drawRect(
                final GameColor color, final double x, final double y, final double width, final double height) {
            context.setFill(toColor(color));
            context.fillRect(x, y, width, height);
        }

        @Override
        public void drawCircle(final GameColor color, final double x, final double y, final double radius) {
            context.setFill(toColor(color));
            context.fillOval(x, y, radius * 2, radius * 2);
        }

        @Override
        public void drawText(
                final GameColor color, final double x, final double y, final double size, final String text) {
            context.setFont(Font.font(size));
            context.setFill(toColor(color));
            context.fillText(text, x, y);
        }

        private Color toColor(final GameColor color) {
            return Color.rgb(color.getRed(), color.getGreen(), color.getBlue());
        }
    }
}

screenshot.png

ちゃんと動いてくれて安心しました。

GWT で Web ブラウザに移植する

いよいよ GWT の出番がやってきました。GWT SDK を用意して準備はバッチリです。

プロジェクトの作成

まずは webAppCreator で Maven プロジェクトのテンプレートを生成します。

webAppCreator -templates maven,sample -out HtmlPlayer com.example.breakout

サーバー側の実装は不要なので、client パッケージ以外は削除してかまいません。
あわせて webapp/WEB-INF/web.xml も welcome-file だけにしておきます。

Web ブラウザ版の実装

JavaFX 版と同じように、HTML5 API を使った Delegate を実装すれば動くはず!

HtmlPlayer.java
package com.example.breakout.client;

import com.example.breakout.client.lib.GameColor;
import com.example.breakout.client.lib.GameInput;
import com.example.breakout.client.lib.GameLoop;
import com.example.breakout.client.lib.GameScreen;
import com.example.breakout.client.scenes.TitleScene;
import com.google.gwt.animation.client.AnimationScheduler;
import com.google.gwt.canvas.client.Canvas;
import com.google.gwt.canvas.dom.client.Context2d;
import com.google.gwt.core.client.EntryPoint;
import com.google.gwt.event.dom.client.KeyCodes;
import com.google.gwt.event.dom.client.KeyDownEvent;
import com.google.gwt.event.dom.client.KeyDownHandler;
import com.google.gwt.event.dom.client.KeyUpEvent;
import com.google.gwt.event.dom.client.KeyUpHandler;
import com.google.gwt.user.client.ui.RootPanel;

public class HtmlPlayer implements EntryPoint {
    private AnimationScheduler scheduler;

    public void onModuleLoad() {
        final RootPanel root = RootPanel.get();
        final HtmlScreen screen = new HtmlScreen();
        GameScreen.setDelegate(screen);
        GameInput.setDelegate(new HtmlInput(root));
        GameLoop.setScene(new TitleScene());

        root.add(screen.canvas);
        scheduler = AnimationScheduler.get();
        scheduler.requestAnimationFrame(new AnimationScheduler.AnimationCallback() {
            @Override
            public void execute(final double timestamp) {
                GameLoop.update();
                scheduler.requestAnimationFrame(this);
            }
        });
    }

    private static class HtmlInput extends GameInput.Delegate {
        HtmlInput(final RootPanel root) {
            final Handler handler = new Handler();
            root.addDomHandler(handler, KeyDownEvent.getType());
            root.addDomHandler(handler, KeyUpEvent.getType());
        }

        private class Handler implements KeyDownHandler, KeyUpHandler {
            @Override
            public void onKeyDown(final KeyDownEvent event) {
                final int keyCode = event.getNativeKeyCode();
                if (keyCode == KeyCodes.KEY_LEFT) {
                    x = -1;
                } else if (keyCode == KeyCodes.KEY_RIGHT) {
                    x = 1;
                } else if (keyCode == KeyCodes.KEY_SPACE) {
                    spacePressed = true;
                }
            }

            @Override
            public void onKeyUp(final KeyUpEvent event) {
                reset();
            }
        }
    }

    private static class HtmlScreen implements GameScreen.Delegate {
        private static final String PIXEL = "px";
        private static final String FONT_NAME = "sans-serif";
        private final Context2d context;
        private final Canvas canvas;

        HtmlScreen() {
            canvas = Canvas.createIfSupported();
            canvas.setWidth(GameScreen.WIDTH + PIXEL);
            canvas.setCoordinateSpaceWidth(GameScreen.WIDTH);
            canvas.setHeight(GameScreen.HEIGHT + PIXEL);
            canvas.setCoordinateSpaceHeight(GameScreen.HEIGHT);
            context = canvas.getContext2d();
            context.setTextAlign("left");
            context.setTextBaseline("top");
        }

        @Override
        public void drawRect(
                final GameColor color, final double x, final double y, final double width, final double height) {
            context.beginPath();
            context.setFillStyle(toRgb(color));
            context.fillRect(x, y, width, height);
            context.closePath();
        }

        @Override
        public void drawCircle(final GameColor color, final double x, final double y, final double radius) {
            context.beginPath();
            context.arc(x + radius, y + radius, radius, 0, Math.PI * 2, false);
            context.setFillStyle(toRgb(color));
            context.fill();
            context.closePath();
        }

        @Override
        public void drawText(
                final GameColor color, final double x, final double y, final double size, final String text) {
            context.setFillStyle(toRgb(color));
            context.setFont(size + PIXEL + " " + FONT_NAME);
            context.fillText(text, x, y);
        }

        private static String toRgb(final GameColor color) {
            return "rgb(" + color.getRed() + ", " + color.getGreen() + ", " + color.getBlue() + ")";
        }
    }
}

デバッグ実行してみる

デバッグ実行には SuperDevMode 機能を使います。

mvn gwt:devmode

起動したサーバーにアクセスすると、その場でソースが JavaScript に変換されてページが表示されます。
デバッグは各ブラウザの開発者ツールから行います。
出力された Java のソースマップ上にブレークポイントを張ると、対応する場所で停止してくれます。

移植成功!

少々長い道のりでしたが、無事 Web ブラウザへの移植に成功しました!
出来上がったものは GitHub Pages に置いてありますので、よろしければ動かしてみてください。

操作方法

  • スペースキー: ゲームの開始
  • 矢印キー: パドルの移動

終わりに

ある程度複雑なものは、やはり静的型付けの方がミスなく効率的に書けるなあと実感しました。
JavaScript のビルドツール周りを覚える必要もないため、Java の知識があれば簡単だと思います。
しかし以下に該当するような場合はやめておきましょう。

  • 最新の日本語情報がほしい
  • 将来的には次世代の JavaScript への移行を検討している
    • GWT の生成する JavaScript は人間が読むことを想定していません
  • 既存の JavaScript ライブラリとの連携を考えている
  • 最新のブラウザ API を使いたい

逆に GWT 内ですべてが完結できる場合は採用を検討してもいいかもしれません。
例えばそう、かつてはアプレッで作られていたようなゲームとか……
Canvas や WebAudio など、必要な API のラッパーは大体揃っているのではないでしょうか。
あ、ちなみに弊社はアプレッといいます。間違えやすいのでご注意ください!

はい、これが言いたかっただけです……
全く締まりのない終わり方で大変恐縮ですが、Advent Calendar はまだまだ続きます。
明日以降の記事もお楽しみに!

この投稿は アプレッソ Advent Calendar 20168日目の記事です。