Next2Dでゲームを作ってみる(後半)

# ロジック
`src/model`に`game`ディレクトリを作り、そこに`Logic.js`を作成します。

## ロジックの役割
- マウス/タップでの移動処理
- 同じ色のブロックか判定
- 数字の加算と得点の加算処理
- タイムバーの回復

## Logic.js

```javascript:src/model/game/Logic.js
import { RedButtonContent } from "../../content/RedButtonContent";
import { GreenButtonContent } from "../../content/GreenButtonContent";
import { BlueButtonContent } from "../../content/BlueButtonContent";
import { YellowButtonContent } from "../../content/YellowButtonContent";

/**
 * @class
 * @extends {next2d.fw.Model}
 */
export class Logic extends next2d.fw.Model
{
    /**
     * @param {number} number
     * @constructor
     * @public
     */
    constructor (number)
    {
        super();

        /**
         * @description ランダムにボタンを生成する為の配列
         * @type {array}
         * @public
         */
        this.pieceList = [
            RedButtonContent,
            GreenButtonContent,
            BlueButtonContent,
            YellowButtonContent
        ];

        /**
         * @description スコア
         * @type {number}
         * @default 0
         * @public
         */
        this.score = 0;

        /**
         * @description 加点対象の倍数値
         * @type {number}
         * @public
         */
        this.number = number | 0;

        /**
         * @description タイムバーの減算数値
         * @type {number}
         * @default 0
         * @public
         */
        this.subNumber = 0;

        /**
         * @description タイムバーの固定x座標
         * @type {number}
         * @default 0
         * @public
         */
        this.maxX = 0;

        /**
         * @description 移動の対象となるDisplayObject
         * @type {MovieClip}
         * @default null
         * @public
         */
        this.moveDisplayObject = null;

        /**
         * @description ピースの移動を制御
         * @type {boolean}
         * @default false
         * @public
         */
        this.lock = false;
    }

    /**
     * @param  {next2d.display.MovieClip} piece
     * @return {void}
     * @public
     */
    addButtonEvent (piece)
    {
        const { MouseEvent } = next2d.events;

        piece.buttonMode    = true;
        piece.mouseChildren = false;
        piece.addEventListener(MouseEvent.MOUSE_DOWN, (event) =>
        {
            const target = event.currentTarget;

            this.moveDisplayObject = target;
            this.lock = false;

            const parent = target.parent;
            parent.setChildIndex(target, parent.numChildren - 1);
        });

        piece.addEventListener(MouseEvent.MOUSE_UP, () =>
        {
            this.lock = false;
            this.moveDisplayObject = null;
        });

        piece.addEventListener(MouseEvent.ROLL_OVER, (event) =>
        {
            const target = event.currentTarget;

            if (this.moveDisplayObject
                && !this.lock
                && (!target.alpha
                    || this.moveDisplayObject.constructor === target.constructor)
            ) {

                switch (true) {

                    case this.moveDisplayObject.x === target.x && this.moveDisplayObject.y !== target.y:
                    case this.moveDisplayObject.y === target.y && this.moveDisplayObject.x !== target.x:
                        {
                            const { Tween } = next2d.ui;
                            const { Event } = next2d.events;

                            this.lock = true;
                            if (target.alpha) {

                                const job = Tween.add(this.moveDisplayObject,
                                    { "x": this.moveDisplayObject.x, "y": this.moveDisplayObject.y },
                                    { "x": target.x, "y": target.y },
                                    0, 0.05
                                );

                                job.addEventListener(Event.COMPLETE, this.movePiece.bind({
                                    "scope": this,
                                    "moveDisplayObject": this.moveDisplayObject,
                                    "target": target
                                }));

                                // 新しいピースを追加
                                const parent = this.moveDisplayObject.parent;
                                const piece = parent
                                    .addChildAt(this.createPiece(), parent.numChildren);

                                piece.x = this.moveDisplayObject.x;
                                piece.y = this.moveDisplayObject.y;

                                job.start();

                            } else {

                                const job = Tween.add(this.moveDisplayObject,
                                    { "x": this.moveDisplayObject.x, "y": this.moveDisplayObject.y },
                                    { "x": target.x, "y": target.y },
                                    0, 0.05
                                );

                                job.addEventListener(Event.COMPLETE, () =>
                                {
                                    this.lock = false;
                                });

                                target.x = this.moveDisplayObject.x;
                                target.y = this.moveDisplayObject.y;

                                job.start();

                            }

                            // reset
                            this.moveDisplayObject = null;
                        }
                        break;

                    default:
                        break;
                }

            } else {

                this.lock = true;

            }
        });
    }

    /**
     * @return {void}
     * @public
     */
    movePiece ()
    {
        const scope  = this.scope;
        const target = this.target;

        const baseNumber   = this.moveDisplayObject.number.text | 0;
        const targetNumber = target.number.text | 0;
        const afterNumber  = baseNumber + targetNumber;

        const parent = this.moveDisplayObject.parent;
        this.moveDisplayObject.number.text = `${afterNumber}`;

        if (afterNumber % scope.number === 0) {

            const { Tween } = next2d.ui;
            const { Point } = next2d.geom;
            const { Event } = next2d.events;

            const view = scope.context.view;

            // タイマーの回復
            scope.recoveryTimeBar(afterNumber / scope.number);

            const point = parent.globalToLocal(new Point(
                view.score.x,
                view.score.y
            ));

            const job = Tween.add(
                this.moveDisplayObject,
                {
                    "x": this.moveDisplayObject.x,
                    "y": this.moveDisplayObject.y,
                    "alpha": 1
                },
                {
                    "x": point.x,
                    "y": point.y,
                    "alpha": 0
                },
                0, 0.25
            );

            job.addEventListener(Event.COMPLETE, scope.pointUp.bind({
                "scope": scope,
                "afterNumber": afterNumber,
                "target": target,
                "moveDisplayObject": this.moveDisplayObject
            }));

            job.start();

        } else {

            this.lock = false;

        }

        parent.removeChild(target);
    }

    /**
     * @return {void}
     * @public
     */
    pointUp ()
    {
        const scope       = this.scope;
        const afterNumber = this.afterNumber;
        const target      = this.target;
        const parent      = this.moveDisplayObject.parent;

        const piece = parent
            .addChildAt(scope.createPiece(), parent.numChildren);

        piece.x = target.x;
        piece.y = target.y;

        scope.score += afterNumber + afterNumber / scope.number;
        scope.context.view.score.text = `${scope.score}`;

        parent.removeChild(this.moveDisplayObject);

        this.lock = false;
    }

    /**
     * @return {next2d.fw.Content}
     * @public
     */
    createPiece ()
    {
        // ランダムに選択
        const PieceClass = this.pieceList[Math.floor(Math.random() * 4)];

        const piece = new PieceClass();

        // ボタンとしてのイベントを追加
        this.addButtonEvent(piece);

        // パズルの数値をセット
        this.setNumber(piece);

        return piece;
    }

    /**
     * @param  {next2d.display.MovieClip} piece
     * @return {void}
     * @public
     */
    setNumber (piece)
    {
        piece.number.text = Math.floor(Math.random() * 8) + 1;
    }

    /**
     * @param  {TimeBarContent} content
     * @return {void}
     * @public
     */
    startTimer (content)
    {
        const width = content.bar.width;
        this.maxX   = content.bar.x;

        // 幅をタイムアウト時間で分割する
        this.subNumber = width
            / (this.config.game.timeLimit
            * (this.query.get("number") | 0));

        // タイマースタート
        const timerId = setInterval(function ()
        {
            content.bar.x -= this.subNumber;
            if (Math.abs(content.bar.x) > width) {

                // ゲーム終了
                clearInterval(timerId);

                // 結果画面にシーン移動
                this.app.gotoView(
                    `game/result?score=${this.context.view.score.text}`
                );

            }

        }.bind(this), 1000);
    }

    /**
     * @param  {Number} number
     * @return {void}
     * @public
     */
    recoveryTimeBar (number)
    {
        const bar = this.context.view.timer.bar;

        bar.x += this.subNumber * number;
        bar.x = Math.min(bar.x, this.maxX);
    }

    /**
     * @param  {next2d.display.MovieClip} piece
     * @return {void}
     * @public
     */
    setLastPiece (piece)
    {
        piece.alpha      = 0;
        piece.buttonMode = false;

        // remove event
        piece.removeAllEventListener(MouseEvent.MOUSE_DOWN);
        piece.removeAllEventListener(MouseEvent.MOUSE_UP);
    }

}
```

# GamePlayViewModel
昨日の記事で、`GamePlayViewModel`にいくつかロジックが入っていましたが、`Logic.js`に移行できるものは移行します。

```javascript:src/view/game/GamePlayViewModel
import { TimeBarContent } from "../../content/TimeBarContent";
import { Logic } from "../../model/game/Logic";

/**
 * @class
 * @extends {next2d.fw.ViewModel}
 */
export class GamePlayViewModel extends next2d.fw.ViewModel
{
    /**
     * @param {next2d.fw.View} view
     * @constructor
     * @public
     */
    constructor (view)
    {
        super(view);

        // ロジッククラスを生成
        this.logic = new Logic(this.query.get("number"));
    }

    /**
     * @param  {next2d.fw.View} view
     * @return {Promise|void}
     * @public
     */
    bind (view)
    {
        return new Promise((resolve) =>
        {
            const { Sprite } = next2d.display;

            const sprite = view.addChild(new Sprite());

            let positionX = 0;
            let positionY = 0;

            // パズルのピースを生成
            const pieceCount = this.config.game.piece;
            for (let idx = 0; idx < pieceCount; ++idx) {

                for (let idx = 0; idx < pieceCount; ++idx) {

                    const piece = sprite.addChild(this.logic.createPiece());

                    piece.x = positionX;
                    piece.y = positionY;
                    positionX += piece.width + 10;

                }

                positionX  = 0;
                positionY += 125;
            }

            // 最後のピースは移動スペースなので非表示
            this.logic.setLastPiece(
                sprite.getChildAt(sprite.numChildren - 1)
            );

            // 枠に収まるサイズに縮小して中心にセット
            sprite.scaleX = sprite.scaleY = 0.4;
            sprite.x = (this.config.stage.width  - sprite.width)  / 2;
            sprite.y = (this.config.stage.height - sprite.height) / 2;

            resolve();
        })
            .then(() =>
            {
                return new Promise((resolve) =>
                {
                    // スコア数値
                    const {
                        TextField,
                        TextFieldAutoSize,
                        TextFormat
                    } = next2d.text;

                    const textFormat = new TextFormat();
                    textFormat.font  = "Arial";
                    textFormat.size  = 40;
                    textFormat.bold  = true;
                    textFormat.color = "#ffffff";

                    const textField = new TextField();
                    textField.defaultTextFormat = textFormat;

                    textField.name      = "score";
                    textField.autoSize  = TextFieldAutoSize.CENTER;
                    textField.thickness = 3;
                    textField.text      = "0";

                    textField.x = (this.config.stage.width - textField.width) / 2;
                    textField.y = 40;

                    view.addChild(textField);

                    resolve();
                });
            })
            .then(() =>
            {
                return new Promise((resolve) =>
                {
                    // タイムバー
                    const content = view.addChild(new TimeBarContent());
                    content.name  = "timer";

                    content.scaleX = content.scaleY = 0.8;
                    content.x = (this.config.stage.width - content.width) / 2;
                    content.y = 110;

                    this.logic.startTimer(content);

                    resolve();
                });
            });
    }
}
```
少しだけ、スッキリしたかと思います。

# テスト
jestを利用して、テストケースを実装していきます。

## jest.config.js
`jest.config.js`を追加します。

```javascript:jest.config.js
module.exports = {
    "setupFilesAfterEnv": ["./jest.setup.js"]
};
```

## jest.setup.js
configファイルで読み込む、モック用の`jest.setup.js`を作成します。
テストケースで必要なモックをここで作成しておきます。

```javascript:jest.setup.js
global.next2d = {
    "fw": {
        "Content": class Content
        {
            get app ()
            {
                return null;
            }
        },
        "Model": class Model
        {
            get app ()
            {
                return null;
            }
        }
    }
};

global.location = {
    "pathname": "/"
};
```

## LogicTest.js
今回はデフォルトの設定値が正しいかだけのテストケースを記載します。
(後日、テストケースを増やせればと思います。)

```javascript:__test__/model/game/LogicTest.js
import { Logic } from "../../../src/model/game/Logic";

describe("LogicTest initialize test", () =>
{
    test("params default value", () => {
        const logic = new Logic(10);
        expect(logic.pieceList.length).toBe(4);
        expect(logic.score).toBe(0);
        expect(logic.number).toBe(10);
        expect(logic.subNumber).toBe(0);
        expect(logic.maxX).toBe(0);
        expect(logic.lock).toBe(false);
        expect(logic.moveDisplayObject).toBe(null);
    });
});

```

# 見た目を整える
今回はデザインがない状態からのゲーム制作だったので、ある程度、色味を整えるだけに納めたいと思います。
(デザインのセンスはないので、悪しからず・・・:pray_tone1:)

# GitHub
今回作成したゲームはGitHubに公開してありますので、興味があれば是非ご覧下さい。
[GitHub](https://github.com/Next2D/np.next2d.app)

ゲームはこちらからプレイできます。
[Number Puzzle](https://np.next2d.app)

# 最後に
Next2Dは素材やアニメーション制作に特化した[Next2D NoCode Tool](https://tool.next2d.app/)、プログラミングに特化した`Next2D Framework`、そして、それらをWebで公開する為の`Next2D Player`で構成されています。
また、Flashで制作したSWF資産を再利用する事が可能です。ActionScriptで作り込んだロジックはJavaScriptに置き換える必要がありますが、将来性を考えるとJavaScriptに置き換える事への投資は無駄にならないと思っています。

`Amplify`や`Firebase`などのBaasサービスを利用する事で、インフラやバックエンドの専門知識がなくても、通信ゲームなどの複雑なゲーム制作も可能になると思うので、今後チャレンジしていきたいと思います。

最後まで記事を読んで頂き、ありがとうございました。