[phina.js]クイズゲームの作り方

今回の記事は、クイズゲームの作り方について書いていきます。公開していませんが、実は、「ほぼ完成!」という段階に来てから、問題が浮上して(実は最初によく調べなかった自分自身のせい)お蔵入りしてしまった自作ゲームがあるんです。その中の一部のコードを抜粋、少し改変したものをサンプルとして公開しつつ少し解説をしてみようかと思います。私自身だけで留めておくと、このまま使わずに世に出ない可能性があるので、どなたかに使っていただけたら…!という思惑ありきでの公開ですので、ご自由に改変やら改造やらコピペなど自由にお使いくださって構いません。(むしろ使ってください)

目次

最初に完成したサンプルとソースコード載せちゃいます。それから、今回部品が多いのもあって、めちゃくちゃコードが長いです。そして解説も長いです。ご了承ください。

サンプル

ソースコード

htmlファイル

<!DOCTYPE html>

<html lang="ja">
<head>
    <meta charset="utf-8" />
    <title></title>
</head>
<body>    
    <script src='https://cdn.jsdelivr.net/gh/phi-jp/phina.js@0.2.2/build/phina.js'></script>
    <script src='./q_data.js'></script>
    <script src='./script.js'></script>
</body>
</html>

問題文と答えのデータ用のjsファイル(q_data.js)

/********************************************
 * 問題とその答えのデータ
 * id…番号
 * text…問題文
 * answer…答え
 * incorrect…不正解した場合に表示するテキスト
 * q_type…答えの入力タイプ
 *      "t1"…テキスト入力タイプ(入力場所が1つだけの場合)
 *      "t2"…テキスト入力タイプ(入力場所が2つの場合)
 *      "b"…ボタン入力タイプ
 *******************************/
const Q_DATA = [
    {
        "id": 0,
        "text": ["Q1. \n貴重なものを与えても、本人にはその値打ちが\n分からないという意味の諺。\n平仮名6文字でお答えください。"],
        "answer": "ねこにこばん",
        //不正解した時に表示するテキスト
        "incorrect": ["違います。\n\n(ヒント…ねこに○○○。\n類義語:馬に念仏、豚に真珠。)"],
        "q_type": "t1"
    },
    {
        "id": 1,
        "text": ["Q2.\n四大河の○○○○、ムーア人の○○○○、\nネプチューンの○○○○\n\n共通して○○○○に入る言葉を\n平仮名4文字でお答えください。"],
        "answer": "ふんすい",
        //不正解した時に表示するテキスト
        "incorrect": ["違います。\n\n(ヒント…トレビの泉も有名ですね。\nどうしてもわからない場合は、\nナヴォーナ広場で検索してください。)"],
        "q_type": "t1"
    },
    {
        "id": 2,
        "text": ["Q3. \n夏目漱石の著作、\n『草枕』冒頭より抜粋した一文です。\n『智に○○○○○△△が立つ』。\n○部分を5文字、△部分を2文字で\nどちらも平仮名でお答えください。"],
        "answer": ["はたらけば", "かど"],
        //不正解した時に表示するテキスト
        "incorrect": ["違います。\n\n(『草枕』オススメですよ。\n青空文庫様にもあるようなので、\nテキストデータで見たい方は\nとりあえずそちらがオススメ。)"],
        "q_type": "t2"
    },
    {
        "id": 3,
        "text": ["Q4. \n酸化アルミニウム(Al2O3)の鉱石で、\n青いものはサファイア。では赤いものは?\n平仮名3文字でお答えください。"],
        "answer": "るびー",
        //不正解した時に表示するテキスト
        "incorrect": ["違います。\n\n(ヒント…7月の誕生石。\n石言葉は「熱情・情熱・純愛・仁愛\n・勇気・仁徳」など。(諸説あり))"],
        "q_type": "t1"
    },
    {
        "id": 4,
        "text": ["Q5. \n美しい桜を見るより、\n美味しいお団子を食べる方が喜ばしい、\nという諺。\n『○○より△△△』\n○部分を2文字、△部分を3文字で、\nどちらも平仮名でお答えください。"],
        "answer": ["はな", "だんご"],
        //不正解した時に表示するテキスト
        "incorrect": ["違います。\n\n(ヒント…割とそのまま。\n類義語:色気より食い気。)"],
        "q_type": "t2"
    },
    {
        "id": 5,
        "text": ["Q6. \n2019年4月30日までの約30年間続いた元号を、\n選択肢からお選びください。"],
        "answer": "平成",
        //不正解した時に表示するテキスト
        "incorrect": ["違います。\n\n(ヒント…\n現在の明仁上皇陛下の治世。\n1989年1月8日から始まりました。) "],
        "q_type": "b"
    },
    {
        "id": 6,
        "text": ["Q7. \nでは、2019年5月1日からの元号を、\n選択肢からお選びください。"],
        "answer": "令和",
        //不正解した時に表示するテキスト
        "incorrect": ["違います。\n\n(ヒント…\n典拠は万葉集だそうですね。\n日本の古典から選定されたのは\n初めてだそうです。)"],
        "q_type": "b"
    }
];

メインのjsファイル(script.js)

phina.globalize();

// 定数
const FONT_SIZE = 28;
const FONT_FAMILY = "'KaiTi','Yu Mincho','Monaco','HG行書体'";

/*****************************
* メインシーン
*/
phina.define("MainScene", {
    // 継承
    superClass: "DisplayScene",
    // コンストラクタ
    init: function () {
        // 親クラスの初期化
        this.superInit();

        // 背景色
        this.backgroundColor = "#000";
        // 何問目かカウント
        this.qNumber = 0;
        //回答文途中の文字グループ
        this.answerLabels = DisplayElement().addChildTo(this);
        // テキストエリア
        this.labelRect = LabelRect().addChildTo(this)
            .setPosition(this.gridX.center(), this.gridY.center(5.5));
        // 問題文設置
        this.setLabel(Q_DATA[this.qNumber].text);
        // 選択肢ボタンのグループ
        this.choicesGroup = DisplayElement().addChildTo(this);

        // 出題・回答・判定の内の、どのフェーズか
        /*******
         * 出題・・・"q"
         * 回答・・・"a"
         * 判定・・・"c"
         */
        this.phase = "q";
    },
    // 更新
    update: function (app) {
        // フェーズによって動作分岐
        // 出題・判定フェーズでクリックかEnterキーの入力があった場合
        if (this.phase === "q" || this.phase === "c") {
            if (app.pointer.getPointingStart() || app.keyboard.getKeyDown("enter")) {
                if (this.labelRect.textAll) {//テキスト全部表示済み
                    // フェーズ管理
                    this.setPhase();
                } else {
                    this.labelRect.showAllText();
                }
            } else {
                this.labelRect.addChar();
            }
            if (this.labelRect.textAll) {
                this.labelRect.nextTriangle.show();
            } else {
                this.labelRect.nextTriangle.hide();
            }
        } else {
            // テキストでinputする回答フェーズでEnterキー入力があった場合
            if (app.keyboard.getKeyDown("enter") && Q_DATA[this.qNumber].q_type !== "b") {
                // フェーズ管理
                this.setPhase();
            }
        }
    },
    // 段階(場面)の管理 
    setPhase: function () {
        let q = Q_DATA[this.qNumber];
        switch (this.phase) {
            // 出題
            case "q":
                //回答フェーズへ移行
                this.phase = "a";
                // input要素・ボタンの設置
                if (q.q_type !== "b") {
                    this.setInput();
                } else {
                    //Q6・Q7の場合
                    this.setChoicesButton();
                }
                break;
            // 回答
            case "a":
                this.phase = "c";
                let text;
                //正誤によって、表示するテキストを変更
                if (q.q_type === "t2") {//input要素その2がある時
                    //正解
                    if (q.answer[0] === this.input.value
                        && q.answer[1] === this.input2.value) {
                        text = ["正解!"];
                        //問題の番号を更新
                        this.qNumber++;
                    } else {//不正解
                        text = q.incorrect;
                    }
                } else if (q.q_type === "t1") {//input要素が1つの時
                    //正解
                    if (q.answer === this.input.value) {
                        text = ["正解!"];
                        //問題の番号を更新
                        this.qNumber++;
                    } else {//不正解
                        text = q.incorrect;
                    }
                }
                this.setLabel(text);

                //回答文途中の文字グループ消去
                this.answerLabels.children.clear();
                //input要素消去
                let dom = this.baseDom;
                while (dom.firstChild) {
                    dom.removeChild(dom.firstChild);
                }
                //回答ボタン消去
                this.enterButton.remove();
                break;
            // 判定
            case "c":
                //出題フェーズへ移行
                this.phase = "q";
                if (this.qNumber < Q_DATA.length) {
                    // 最後の問題でなければ、テキストエリアの文章をセット
                    this.setLabel(q.text);
                } else {
                    //最後の問題を解いたら、ResultSceneへ
                    this.exit();
                }
                break;
        }
    },
    // 文の設置
    setLabel: function (text) {
        this.labelRect.clearText();
        // 回答テキストをセット
        this.labelRect.texts = text;
        // テキストエリアの初期化
        this.labelRect.textIndex = 0;
        this.labelRect.charIndex = 0;
        // テキストエリア表示
        this.labelRect.show();
    },
    // DOMやらボタンやらを設置する
    setInput: function () {
        //問題とその答えのデータ
        let q = Q_DATA[this.qNumber];

        //回答用input要素
        if (q.q_type === "t2") {
            if (q.id === 2) {
                // 回答用input要素その1生成
                this.input = this.createInput(240, 150, 160);
                // 回答用input要素その2生成
                this.input2 = this.createInput(180, 160, 240);
            } else if (q.id === 4) {
                // 回答用input要素その1生成
                this.input = this.createInput(180, 150, 160);
                // 回答用input要素その2生成
                this.input2 = this.createInput(180, 240, 240);
            }
        } else {
            //input要素生成
            this.input = this.createInput(300, 140, 240);
        }
        //回答文途中に挟む文字
        if (q.id === 2) {
            Label({
                text: "智に",
                fontSize: 40,
                stroke: null,
                fill: "#fff",
                fontFamily: FONT_FAMILY
            }).addChildTo(this.answerLabels)
                .setPosition(80, 205);

            Label({
                text: "が立つ",
                fontSize: 40,
                stroke: null,
                fill: "#fff",
                fontFamily: FONT_FAMILY
            }).addChildTo(this.answerLabels)
                .setPosition(420, 280);
        } else if (q.id === 4) {
            Label({
                text: "より",
                fontSize: 40,
                stroke: null,
                fill: "#fff",
                fontFamily: FONT_FAMILY
            }).addChildTo(this.answerLabels)
                .setPosition(450, 205);
        }
        //回答ボタン
        this.enterButton = LabelButton("回答", 28)
            .addChildTo(this).setPosition(535, 285);
        //クリック有効化
        this.enterButton.setInteractive(true);
        // クリックされた時の反応
        this.enterButton.onpointend = () => {
            //フェーズ管理
            this.setPhase();
        };
    },
    //input要素を生成
    createInput: function (w, l, t) {
        // DOM操作
        let dom = this.baseDom;
        // 回答用input要素生成
        let input = document.createElement('input');
        // input要素にtext属性付与
        input.getAttribute('text');
        // スタイルを設定
        let s = input.style;
        s.width = `${w}px`;
        s.height = '60px';
        s.position = 'absolute';
        s.margin = '8px';
        s.left = l + 'px';
        s.top = t + 'px';
        s.fontSize = '42px';
        s.fontFamily = FONT_FAMILY;
        s.backgroundColor = '#000000';
        s.color = '#ffffff';
        s.border = '2px solid #ffffff';
        dom.appendChild(input);
        s.overflowY = 'hidden';

        // 参照のために返す
        return input;
    },
    // 選択肢ボタンの設置
    setChoicesButton: function () {
        let q = Q_DATA[this.qNumber];

        if (this.qNumber === 5) {//Q6
            this.createQuestionLabelButton("平成", this.gridY.center(-4), ["正解!"]);
            this.createQuestionLabelButton("天平感宝", this.gridY.center(-2), q.incorrect);
            this.createQuestionLabelButton("昭和", this.gridY.center(), q.incorrect);
        } else { // Q7
            this.createQuestionLabelButton("平成", this.gridY.center(-4), q.incorrect);
            this.createQuestionLabelButton("令和", this.gridY.center(-2), ["正解!"]);
            this.createQuestionLabelButton("大化", this.gridY.center(), q.incorrect);
        }
    },
    //選択肢ボタンを生成
    createQuestionLabelButton: function (text, y, a_text) {
        /***********
         * text…ボタン自体のテキスト
         * y…ボタンのY座標
         * a_text…クリックした時のアンサーテキスト
        ***********/
        let b = QuestionLabelButton(text, 37).addChildTo(this.choicesGroup)
            .setPosition(this.gridX.center(), y);
        //クリック有効化
        b.setInteractive(true);
        b.onpointend = () => {
            // 選択肢ボタンのグループ全削除
            this.choicesGroup.children.clear();
            this.phase = "c";
            //正解の場合、問題の番号を更新
            if (a_text[0] === "正解!") this.qNumber++;
            //正解
            this.setLabel(a_text);
        };
    }
});

/*************************
 * リザルトシーン
 */
phina.define("ResultScene", {
    superClass: "DisplayScene",
    init: function () {
        this.superInit();
        this.backgroundColor = 'aliceBlue';
        const title = "クイズゲームサンプル";
        const message = 'Thank you for playing!';
        const hashtags = 'phina_js,game,javascript';

        this.fromJSON({
            children: {
                titleText: {
                    className: 'phina.display.Label',
                    arguments: {
                        text: title,
                        fill: "#888",
                        stroke: null,
                        fontSize: 48,
                    },
                    x: this.gridX.span(8),
                    y: this.gridY.span(4),
                },

                messageLabel: {
                    className: 'phina.display.Label',
                    arguments: {
                        text: message,
                        fill: "#888",
                        stroke: null,
                        fontSize: 32,
                    },
                    x: this.gridX.center(),
                    y: this.gridY.span(9),
                },

                shareButton: {
                    className: 'phina.ui.Button',
                    arguments: [{
                        text: '★',
                        width: 128,
                        height: 128,
                        fontColor: "#888",
                        fontSize: 50,
                        cornerRadius: 64,
                        fill: 'rgba(240, 240, 240, 0.5)',
                        // stroke: '#aaa',
                        // strokeWidth: 2,
                    }],
                    x: this.gridX.center(-3),
                    y: this.gridY.span(12),
                },
                playButton: {
                    className: 'phina.ui.Button',
                    arguments: [{
                        text: '▶',
                        width: 128,
                        height: 128,
                        fontColor: "#888",
                        fontSize: 50,
                        cornerRadius: 64,
                        fill: 'rgba(240, 240, 240, 0.5)',
                        // stroke: '#aaa',
                        // strokeWidth: 2,
                    }],
                    x: this.gridX.center(3),
                    y: this.gridY.span(12),

                    interactive: true,
                    onpush: function () {
                        this.exit();
                    }.bind(this),
                },
            }
        });

        this.shareButton.onclick = function () {
            var text = `${title}\n${message}`;
            var url = phina.social.Twitter.createURL({
                text: text,
                hashtags: hashtags,
                url: phina.global.location && phina.global.location.href
            });
            window.open(url, 'share window', 'width=480, height=320');
        };
    }
});
/****
 * テキスト表示&文字送り
 */
phina.define("LabelRect", {
    superClass: "RectangleShape",
    init: function () {
        this.superInit({
            width: 640,
            height: 280,
            stroke: null,
            fill: "#000"
        });

        // テキスト表示用LabelAreaクラス
        this.labelArea = LabelArea({
            text: "",
            width: 600,
            height: 240,
            fill: "#fff",
            stroke: null,
            fontSize: FONT_SIZE,
            fontFamily: FONT_FAMILY
        }).addChildTo(this);

        this.texts = [];
        this.textIndex = 0;
        this.charIndex = 0;

        // 次のテキスト合図の▽
        this.nextTriangle = TriangleShape({
            fill: "#fff",
            stroke: false,
            radius: FONT_SIZE / 2
        }).addChildTo(this)
            .setPosition(280, 100);
        this.nextTriangle.rotation = 180;
        this.nextTriangle.hide();

    },
    showAllText: function () {
        let text = this.texts[this.textIndex];
        this.labelArea.text = text;
        this.textAll = true;
        this.charIndex = text.length;
    },

    clearText: function () {
        this.labelArea.text = "";
    },

    nextText: function () {
        this.clearText();
        ++this.textIndex;
        this.charIndex = 0;
        this.addChar();
    },
    addChar: function () {
        this.labelArea.text += this.getChar();
    },
    getChar: function () {
        let text = this.texts[this.textIndex];
        if (text.length <= this.charIndex) {
            this.textAll = true;
            return "";
        } else {
            this.textAll = false;
            return text[this.charIndex++];
        }
    }
});
/****
 * ボタン代わりのLabel拡張クラス
 */
phina.define("LabelButton", {
    superClass: "Label",
    init: function (text, font_size) {
        this.superInit({
            text: text,
            fill: "#fff",
            stroke: null,
            height: font_size,
            fontFamily: FONT_FAMILY,
            fontSize: font_size
        });

        //textの横幅を算出
        this.width = this.checkWidth(text);

        // 子要素として下線を用意
        let underLine = RectangleShape({
            width: 1,
            height: 2,
            stroke: null,
            fill: "#fff"
        }).addChildTo(this).setPosition(0, Math.floor(font_size * 0.5) + 2);

        underLine.alpha = 0;

        // カーソルが上に来た瞬間orタッチした瞬間
        this.onpointover = () => {
            // 下線を表示
            underLine.alpha = 1;
            // 最大横幅
            let maxW = this.width;
            // カウンタ
            let count = 0;
            // 下線が横に伸びるアニメーション
            underLine.update = () => {
                count++;
                if (count < 10) {
                    underLine.width += (maxW - underLine.width) * 0.4;
                }
            };
        };
        // カーソルが上から外れた瞬間orタッチが外れた瞬間
        this.onpointout = () => {
            // 下線の透明化
            underLine.alpha = 0;
            // アニメーションストップ
            underLine.update = () => { };
            // 下線の長さリセット
            underLine.width = 1;
        }
    },
    // 文章の幅を算出
    checkWidth: function (text) {
        let half = 0;
        for (let i = 0; i < text.length; i++) {
            // 半角文字か判定
            if (this.checkHalfWidth(text[i])) half++;
        }
        return (text.length - half / 2) * this.fontSize;
    },
    //文字の半角判定
    checkHalfWidth: function (value) {
        return !value.match(/[^\x01-\x7E]/) || !value.match(/[^\uFF65-\uFF9F]/);
    }
});

/****
* ボタン代わりのRectangleShape拡張クラス
*/
phina.define("QuestionLabelButton", {
    superClass: "RectangleShape",
    init: function (text, font_size) {
        this.superInit({
            fill: null,
            stroke: null,
            width: 260
        });

        //行数
        let lineCount = Math.ceil(text.length / Math.floor(240 / font_size));
        //子要素としてLabelArea
        let label = LabelArea({
            text: text,
            fill: "#fff",
            stroke: null,
            width: 240,
            height: lineCount * (font_size + 4),
            fontFamily: FONT_FAMILY,
            fontSize: font_size,
        }).addChildTo(this);
        this.height = label.height + lineCount * 4;

        //参照用
        this.label = label;
        this.text = text;
        this.fontSize = font_size;

        let w = this.width;
        let h = this.height;
        // カーソルが上に来た瞬間orタッチした瞬間
        this.onpointover = () => {
            let count = 0;
            this.update = () => {
                count += (1 - count) * 0.05;

                if (count >= 0.9) {
                    count = 0.0;
                }

                let start_p = count;
                let center_p = Math.min(count + 0.1, 1.00);
                let end_p = Math.min(count + 0.2, 1.00);
                let grad = this.canvas.context.createLinearGradient(-w, -h, w / 2, h / 2);
                grad.addColorStop(start_p, "hsla(0,100%,100%,0");
                grad.addColorStop(center_p, "hsla(300,100%,100%,0.5)");
                grad.addColorStop(end_p, "hsla(240,100%,100%,0.0)");
                this.fill = grad;
            };
        };
        // カーソルが上から外れた瞬間orタッチが外れた瞬間
        this.onpointout = () => {
            // アニメーションストップ
            this.update = () => { };
            this.fill = null;
        };
    }
});
/***********************************
 * メイン処理
 */
phina.main(function (app) {
    let dom = document.createElement('div');
    let resize = () => {
        let e = app.domElement;
        let c = app.canvas;
        let s = e.style;
        let ds = dom.style;
        let rect = e.getBoundingClientRect();
        ds.width = 0;
        ds.height = 0;
        ds.position = 'absolute';
        ds.left = rect.left + 'px';
        ds.top = rect.top + 'px';
        let rate = parseInt(s.width) / c.width;
        ds.transform = 'scale(' + rate + ',' + rate + ')';
    };

    window.addEventListener('resize', resize);
    Scene.prototype.baseDom = dom;
    // アプリケーションを生成
    app = GameApp({
        // TitleSceneから開始
        startLabel: "title"
    });
    // fps表示
    //app.enableStats();

    app.baseDom = dom;
    // 実行
    app.run();

    resize();
    app.domElement.parentNode.insertBefore(dom, app.domElement.nextSibling);
});

解説

問題文と答えのデータ用のjsファイル(q_data.js)について

まずはクイズには、問題と答えが必要ですので、こういったものをご用意ください。それぞれの出題数の分だけ、↓のようなキーと値を保持させたデータをまとめて格納しています。

キー
id番号
text問題文
answer答え
incorrect不正解時に表示する文
q_type回答の入力タイプ

『q_type』は今回、↓のような3つのタイプを考えて作っています。

  • “t1″…「1か所のテキスト入力での回答タイプ」
  • “t2″…「2か所のテキスト入力での回答タイプ」
  • “b”…「選択肢ボタンでの回答タイプ」

『text』や『incorrect』には、わざわざ[]で囲われ、配列にされた文章が1つだけ入っているわけなのですが、「要素が1つしか入っていないのに、配列にする意味あるの?」と思われるかもしれませんが、「仕様」と思って受け入れて下さるとありがたいです(^_^;) 気になる方は、自由に改良しちゃってください。

メインシーンについて

まず、クイズの流れとして、出題(q)→回答(a)→判定(c)→出題(q)→回答(a)…といった3つのフェーズを繰り返すために、 メインシーンにthis.phaseというプロパティを作っておき、その中に入っている値で、現在のフェーズと、次のフェーズへの移行を管理しています。

プロパティ

プロパティ名内容初期値
qNumber何問目かのカウンタ0
phase現在のフェーズ(出題or回答or判定)“q”

「phase」に入る値は、”q”なら出題を意味し、”a”なら回答、”c”なら判定を意味します。

テキスト表示・文字送りのためのLabelRectオブジェクト

/****
 * テキスト表示&文字送り
 */
phina.define("LabelRect", {
    superClass: "RectangleShape",
    init: function () {
        this.superInit({
            width: 640,
            height: 280,
            stroke: null,
            fill: "#000"
        });

        // テキスト表示用LabelAreaクラス
        this.labelArea = LabelArea({
            text: "",
            width: 600,
            height: 240,
            fill: "#fff",
            stroke: null,
            fontSize: FONT_SIZE,
            fontFamily: FONT_FAMILY
        }).addChildTo(this);

        this.texts = [];
        this.textIndex = 0;
        this.charIndex = 0;

        // 次のテキスト合図の▽
        this.nextTriangle = TriangleShape({
            fill: "#fff",
            stroke: false,
            radius: FONT_SIZE / 2
        }).addChildTo(this)
            .setPosition(280, 100);
        this.nextTriangle.rotation = 180;
        this.nextTriangle.hide();

    },
    showAllText: function () {
        let text = this.texts[this.textIndex];
        this.labelArea.text = text;
        this.textAll = true;
        this.charIndex = text.length;
    },

    clearText: function () {
        this.labelArea.text = "";
    },

    nextText: function () {
        this.clearText();
        ++this.textIndex;
        this.charIndex = 0;
        this.addChar();
    },
    addChar: function () {
        this.labelArea.text += this.getChar();
    },
    getChar: function () {
        let text = this.texts[this.textIndex];
        if (text.length <= this.charIndex) {
            this.textAll = true;
            return "";
        } else {
            this.textAll = false;
            return text[this.charIndex++];
        }
    }
});

問題文や正解・不正解判定の文章など、一文字一文字テキストを表示させる関数については、以前私が書きましたノベルゲームの作り方の方か、あるいはもともとのsimiraaaa氏制作のサンプルの方をご覧ください。

本当にこのサンプルにお世話になってるので、改めましてsimiraaaa氏にはここで密かに御礼申し上げさせていただきます。

2種類のオリジナルボタンオブジェクト

/****
 * ボタン代わりのLabel拡張クラス
 */
phina.define("LabelButton", {
    superClass: "Label",
    init: function (text, font_size) {
        this.superInit({
            text: text,
            fill: "#fff",
            stroke: null,
            height: font_size,
            fontFamily: FONT_FAMILY,
            fontSize: font_size
        });

        //textの横幅を算出
        this.width = this.checkWidth(text);

        // 子要素として下線を用意
        let underLine = RectangleShape({
            width: 1,
            height: 2,
            stroke: null,
            fill: "#fff"
        }).addChildTo(this).setPosition(0, Math.floor(font_size * 0.5) + 2);

        underLine.alpha = 0;

        // カーソルが上に来た瞬間orタッチした瞬間
        this.onpointover = () => {
            // 下線を表示
            underLine.alpha = 1;
            // 最大横幅
            let maxW = this.width;
            // カウンタ
            let count = 0;
            // 下線が横に伸びるアニメーション
            underLine.update = () => {
                count++;
                if (count < 10) {
                    underLine.width += (maxW - underLine.width) * 0.4;
                }
            };
        };
        // カーソルが上から外れた瞬間orタッチが外れた瞬間
        this.onpointout = () => {
            // 下線の透明化
            underLine.alpha = 0;
            // アニメーションストップ
            underLine.update = () => { };
            // 下線の長さリセット
            underLine.width = 1;
        }
    },
    // 文章の幅を算出
    checkWidth: function (text) {
        let half = 0;
        for (let i = 0; i < text.length; i++) {
            // 半角文字か判定
            if (this.checkHalfWidth(text[i])) half++;
        }
        return (text.length - half / 2) * this.fontSize;
    },
    //文字の半角判定
    checkHalfWidth: function (value) {
        return !value.match(/[^\x01-\x7E]/) || !value.match(/[^\uFF65-\uFF9F]/);
    }
});

/****
* ボタン代わりのRectangleShape拡張クラス
*/
phina.define("QuestionLabelButton", {
    superClass: "RectangleShape",
    init: function (text, font_size) {
        this.superInit({
            fill: null,
            stroke: null,
            width: 260
        });

        //行数
        let lineCount = Math.ceil(text.length / Math.floor(240 / font_size));
        //子要素としてLabelArea
        let label = LabelArea({
            text: text,
            fill: "#fff",
            stroke: null,
            width: 240,
            height: lineCount * (font_size + 4),
            fontFamily: FONT_FAMILY,
            fontSize: font_size,
        }).addChildTo(this);
        this.height = label.height + lineCount * 4;

        //参照用
        this.label = label;
        this.text = text;
        this.fontSize = font_size;

        let w = this.width;
        let h = this.height;
        // カーソルが上に来た瞬間orタッチした瞬間
        this.onpointover = () => {
            let count = 0;
            this.update = () => {
                count += (1 - count) * 0.05;

                if (count >= 0.9) {
                    count = 0.0;
                }

                let start_p = count;
                let center_p = Math.min(count + 0.1, 1.00);
                let end_p = Math.min(count + 0.2, 1.00);
                let grad = this.canvas.context.createLinearGradient(-w, -h, w / 2, h / 2);
                grad.addColorStop(start_p, "hsla(0,100%,100%,0");
                grad.addColorStop(center_p, "hsla(300,100%,100%,0.5)");
                grad.addColorStop(end_p, "hsla(240,100%,100%,0.0)");
                this.fill = grad;
            };
        };
        // カーソルが上から外れた瞬間orタッチが外れた瞬間
        this.onpointout = () => {
            // アニメーションストップ
            this.update = () => { };
            this.fill = null;
        };
    }
});

回答ボタンや、選択肢ボタンに使っている2種類のボタンについてです。多分こういったサンプルでは、出来るだけシンプルにするのが正解で、これは無駄にコードの行数長くしてしまう要因の一つなのでしょうが、自作のボタンの宣伝も兼ねて、ご紹介させていただくことをご容赦ください。(これも良ければご自由にお使いください)

これに関しましては、(名前が違っていたりしますが)下のリンクをご覧ください。

外部リンク:http://runstant.com/horo_horori/projects/37dbbc23

グループ

グループ名グループ化させる対象オブジェクト
answerLabels回答文途中の文字を管理するためのグループ。
3問目の『草枕』の問題では、
“智に”Labelと”が立つ”Labelがグループに入りました。
choicesGroup選択肢で回答する場合の、
選択肢ボタンを管理するためのグループ。

update—更新処理

/*
* 略
*/
// 更新
update: function (app) {
    // フェーズによって動作分岐
    // 出題・判定フェーズでクリックかEnterキーの入力があった場合
    if (this.phase === "q" || this.phase === "c") {
        if (app.pointer.getPointingStart() || app.keyboard.getKeyDown("enter")) {
            if (this.labelRect.textAll) {//テキスト全部表示済み
                // フェーズ管理
                this.setPhase();
            } else {
                this.labelRect.showAllText();
            }
         } else {
            this.labelRect.addChar();
         }
         if (this.labelRect.textAll) {
            this.labelRect.nextTriangle.show();
         } else {
            this.labelRect.nextTriangle.hide();
         }
    } else {
        // テキストでinputする回答フェーズでEnterキー入力があった場合
        if (app.keyboard.getKeyDown("enter") && Q_DATA[this.qNumber].q_type !== "b") {
            // フェーズ管理
            this.setPhase();
        }
    }
},
/*
* 略
*/

updateでの処理についてです。

出題と判定フェーズでは、labelRectオブジェクトのメソッドを呼び出してテキスト一文字一文字表示させたり、エンターキーやクリック入力を感知した場合に、テキスト全文を表示させたり、setPhaseメソッドを呼び出してフェーズや問題を移行させています。

回答フェーズでは、ボタン押さずに進まれたら困るので、選択肢のボタンによる回答タイプの問題ではない時だけ、エンターキーの入力を感知してsetPhaseメソッドを呼び出しています。そしてsetPhaseメソッド内で、回答フェーズだけ、フェーズを進ませるかどうかを回答の正解・不正解で決め、不正解の場合、回答フェーズのままループする処理をしています。

setPhaseメソッド—フェーズの移行・管理

/*
* 略
*/
// 段階(場面)の管理
setPhase: function () {
    let q = Q_DATA[this.qNumber];
    switch (this.phase) {
        // 出題
        case "q":
            //回答フェーズへ移行
            this.phase = "a";
            // input要素・ボタンの設置
            if (q.q_type !== "b") {
                this.setInput();
            } else {
                //Q6・Q7の場合
                this.setChoicesButton();
            }
            break;
        // 回答
        case "a":
            this.phase = "c";
            let text;
            //正誤によって、表示するテキストを変更
            if (q.q_type === "t2") {//input要素その2がある時
                //正解
                if (q.answer[0] === this.input.value
                    && q.answer[1] === this.input2.value) {
                    text = ["正解!"];
                    //問題の番号を更新
                    this.qNumber++;
                } else {//不正解
                    text = q.incorrect;
                }
            } else if (q.q_type === "t1") {//input要素が1つの時
                //正解
                if (q.answer === this.input.value) {
                    text = ["正解!"];
                    //問題の番号を更新
                    this.qNumber++;
                } else {//不正解
                    text = q.incorrect;
                }
            }
            this.setLabel(text);

            //回答文途中の文字グループ消去
            this.answerLabels.children.clear();
            //input要素消去
            let dom = this.baseDom;
            while (dom.firstChild) {
                dom.removeChild(dom.firstChild);
            }
            //回答ボタン消去
            this.enterButton.remove();
            break;
        // 判定
        case "c":
            //出題フェーズへ移行
            this.phase = "q";
            if (this.qNumber < Q_DATA.length) {
                // 最後の問題でなければ、テキストエリアの文章をセット
                this.setLabel(q.text);
            } else {
                //最後の問題を解いたら、ResultSceneへ
                this.exit();
            }
            break;
    }
},
/*
* 略
*/

このフェーズで、次のフェーズへの移行するためのメソッド呼び出しや、オブジェクトのセッティングをしています。このクイズゲームの根幹となるメソッドだと思います。

出題フェーズでは、phaseプロパティの値を”a”に変更し、更に、回答のタイプによって設置するinput要素選択肢ボタンの分岐を行っています。

回答フェーズでは、まずはphaseプロパティの値を”c”に変更します。
次に、問題の解答タイプによって処理を分岐し、input要素に入力された値か押されたボタンの値を調べます。問題の正解の値と一致するかをQ_DATAからデータを引っ張り出して照合します。正解・不正解によって、表示するテキストを変更し、そのテキストを後述のsetLabelメソッドを呼び出して、labelRectオブジェクトに表示してもらう次の文章をセットします。
正解であればここでqNumberプロパティの数値をインクリメントしておきます。
回答フェーズの分岐の最後で、回答文途中の文字グループや、input要素、回答ボタンを全て一度消去して、回答エリアをまっさらな状態に戻しています。

判定フェーズでは、まずphaseプロパティの値を”q”へ変更します。上の回答フェーズで、正解であればインクリメントされたqNumberプロパティの値で、次の出題フェーズで呼び出す問題が次の問題文に。不正解であれば変わらないので、同じ問題の出題フェーズを繰り返すことになります。
最後の問題であれば、シーン遷移しリザルトシーンへ移行しています。

setLabelメソッド—表示される文章を初期化・設定

/*
* 略
*/
// 文の設置
setLabel: function (text) {
    this.labelRect.clearText();
    // 回答テキストをセット
    this.labelRect.texts = text;
    // テキストエリアの初期化
    this.labelRect.textIndex = 0;
    this.labelRect.charIndex = 0;
    // テキストエリア表示
    this.labelRect.show();
},
/*
* 略
*/

このメソッドで、labelRectオブジェクトにセットされている文章のリセットと、問題文や正解・不正解判定文のセッティングをし、文章を表示しています。

setInputメソッド—問題によって回答に必要なinput要素、途中の文字、回答ボタンを生成・設置

// DOMやらボタンやらを設置する
setInput: function () {
    //問題とその答えのデータ
    let q = Q_DATA[this.qNumber];

    //回答用input要素
    if (q.q_type === "t2") {
        if (q.id === 2) {
            // 回答用input要素その1生成
            this.input = this.createInput(240, 150, 160);
            // 回答用input要素その2生成
            this.input2 = this.createInput(180, 160, 240);
        } else if (q.id === 4) {
            // 回答用input要素その1生成
            this.input = this.createInput(180, 150, 160);
            // 回答用input要素その2生成
            this.input2 = this.createInput(180, 240, 240);
        }
    } else {
        //input要素生成
        this.input = this.createInput(300, 140, 240);
    }
    //回答文途中に挟む文字
    if (q.id === 2) {
        Label({
            text: "智に",
            fontSize: 40,
            stroke: null,
            fill: "#fff",
            fontFamily: FONT_FAMILY
        }).addChildTo(this.answerLabels)
            .setPosition(80, 205);

        Label({
            text: "が立つ",
            fontSize: 40,
            stroke: null,
            fill: "#fff",
            fontFamily: FONT_FAMILY
        }).addChildTo(this.answerLabels)
            .setPosition(420, 280);
    } else if (q.id === 4) {
        Label({
            text: "より",
            fontSize: 40,
            stroke: null,
            fill: "#fff",
            fontFamily: FONT_FAMILY
        }).addChildTo(this.answerLabels)
            .setPosition(450, 205);
    }
    //回答ボタン
    this.enterButton = LabelButton("回答", 28)
        .addChildTo(this).setPosition(535, 285);
    //クリック有効化
    this.enterButton.setInteractive(true);
    // クリックされた時の反応
    this.enterButton.onpointend = () => {
        //フェーズ管理
        this.setPhase();
    };
},

Q_DATAから読み込んだデータによって、後述のcreateInputメソッドを呼び出してinput要素を生成する数や、input要素の配置、回答文途中のLabel生成を分岐させています。

最後に、回答ボタンの設置を行っています。テキストをinputする回答タイプの場合、このボタンを押すか、Enterキーを押すことで判定フェーズに移る仕様にしてみました。

createInputメソッド—input要素を生成

/*
*略
*/
//input要素を生成
createInput: function (w, l, t) {
    // DOM操作
    let dom = this.baseDom;
    // 回答用input要素生成
    let input = document.createElement('input');
    // input要素にtext属性付与
    input.getAttribute('text');
    // スタイルを設定
    let s = input.style;
    s.width = `${w}px`;
    s.height = '60px';
    s.position = 'absolute';
    s.margin = '8px';
    s.left = `${l}px`;
    s.top = `${t}px`;
    s.fontSize = '42px';
    s.fontFamily = FONT_FAMILY;
    s.backgroundColor = '#000000';
    s.color = '#ffffff';
    s.border = '2px solid #ffffff';
    dom.appendChild(input);
    s.overflowY = 'hidden';

    // 参照のために返す
    return input;
},
/*
*中略
*/
/***********************************
 * メイン処理
 */
phina.main(function (app) {
    let dom = document.createElement('div');
    let resize = () => {
        let e = app.domElement;
        let c = app.canvas;
        let s = e.style;
        let ds = dom.style;
        let rect = e.getBoundingClientRect();
        ds.width = 0;
        ds.height = 0;
        ds.position = 'absolute';
        ds.left = rect.left + 'px';
        ds.top = rect.top + 'px';
        let rate = parseInt(s.width) / c.width;
        ds.transform = 'scale(' + rate + ',' + rate + ')';
    };

    window.addEventListener('resize', resize);
    Scene.prototype.baseDom = dom;
    // アプリケーションを生成
    app = GameApp({
        // MainSceneから開始
        startLabel: "main"
    });
    // fps表示
    //app.enableStats();

    app.baseDom = dom;
    // 実行
    app.run();

    resize();
    app.domElement.parentNode.insertBefore(dom, app.domElement.nextSibling);
});

DOM操作してテキスト入力用のinput要素の生成をしています。

まず、この回答用input要素なのですが、またまたsimiraaaa氏のサンプルをほぼ丸パクリで使わせて頂いてます。深く感謝(謝罪)申し上げますm(_ _)m

引数は、wがinput要素の横幅、lがinput要素を設置するleftの位置、tがinput要素を設置するtopの位置だけで、他のは固定にしてしまいました。

これを扱うには、メイン処理部分であらかじめDOM操作してdiv要素を作っておく必要があるようです。(作ってから後で知ったのですが、textタイプのinput要素を生成して入力した文章を表示させるのに、他の方法もあるようなので、スマートにやりたい方は、下にリンクを貼っておきますので、そちらをどうぞ。)

input系の要素との連携のテスト | Runstant

setChoicesButtonメソッド、createQuestionLabelButtonメソッド—選択肢ボタンの生成・設置

/*
*略
*/
// 選択肢ボタンの設置
setChoicesButton: function () {
    let q = Q_DATA[this.qNumber];

    if (this.qNumber === 5) {//Q6
        this.createQuestionLabelButton("平成", this.gridY.center(-4), ["正解!"]);
        this.createQuestionLabelButton("天平感宝", this.gridY.center(-2), q.incorrect);
        this.createQuestionLabelButton("昭和", this.gridY.center(), q.incorrect);
    } else { // Q7
        this.createQuestionLabelButton("平成", this.gridY.center(-4), q.incorrect);
        this.createQuestionLabelButton("令和", this.gridY.center(-2), ["正解!"]);
        this.createQuestionLabelButton("大化", this.gridY.center(), q.incorrect);
    }
},
//選択肢ボタンを生成
createQuestionLabelButton: function (text, y, a_text) {
    /***********
     * text…ボタン自体のテキスト
     * y…ボタンのY座標
     * a_text…クリックした時のアンサーテキスト
    ***********/
    let b = QuestionLabelButton(text, 37).addChildTo(this.choicesGroup)
        .setPosition(this.gridX.center(), y);
    //クリック有効化
    b.setInteractive(true);
    b.onpointend = () => {
        // 選択肢ボタンのグループ全削除
        this.choicesGroup.children.clear();
        this.phase = "c";
        //正解の場合、問題の番号を更新
        if (a_text[0] === "正解!") this.qNumber++;
        //正解
        this.setLabel(a_text);
    };
}
/*
*略
*/

回答タイプが選択肢ボタンである場合、選択肢ボタンをsetChoicesButtonメソッドから、createQuestionLabelButtonメソッドを呼び出して、選択肢ボタンの生成と設置を行っています。

createQuestionLabelButtonメソッドでは、先ほどご紹介しました「2種類のオリジナルボタンオブジェクト」の内の一つテカる方のボタンを生成しています。
このメソッドでは3つ引数を受け取り、第1引数でボタン自体のテキスト、第2引数でY座標の位置、第3引数でクリックされた後に判定フェーズで表示する正解・不正解判定の文章をセットしています。

各ボタンがクリックされた時に発火するイベントでは、setPhaseメソッドの呼び出しの代わりに、次のフェーズへの移行の処理を設定しています。
具体的には、選択肢ボタンの全削除と、判定フェーズへの移行、正解であった場合qNumberプロパティの値のインクリメント、setLabelメソッドを呼び出しての文章のセットです。

参考にさせていただいたサイト・ページ一覧

見なくても一向に構わないあとがき

Resultシーンについてはあんまり既定のものから弄ってないので、解説省きます。

長いコードに、冗長な解説をここまで読んで下さり、ありがとうございました!

自分語り入りますが、この部分を作るに至った元のゲームは、結構力入れて作ってたのに公開できないのが少し悔しかったゲームなので、どなたかにこの一部のコードだけでも使っていただけないかな…(チラッ)という思惑ありきで今回の記事を書いてみました。この記事で書いているコードや、部品のボタンなど、お好きにご活用くださると大変嬉しく思います。

ご意見・ご感想・ご質問、また、ここ間違ってるよとか、もっといい方法あるよといったご指摘などございましたら、お手数ですがコメント欄やtwitterアカウントほろほろり(@_horo_horori)へお願いしますm(_ _)m

他にもphina.jsに関する記事を書いておりますので、よろしければどうぞ!

Pocket

コメントを残す

メールアドレスが公開されることはありません。 * が付いている欄は必須項目です

このサイトはスパムを低減するために Akismet を使っています。コメントデータの処理方法の詳細はこちらをご覧ください