0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 1 year has passed since last update.

Next2DAdvent Calendar 2021

Day 25

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

Last updated at Posted at 2021-12-26

ロジック

src/modelgameディレクトリを作り、そこにLogic.jsを作成します。

ロジックの役割

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

Logic.js

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に移行できるものは移行します。

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を追加します。

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

jest.setup.js

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

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

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

__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

ゲームはこちらからプレイできます。
Number Puzzle

最後に

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

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

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

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?