【Html&Cssで作る四択クイズゲーム開発記録⑤】Sassリストでクイズの問題を動的に管理する


2022/01/11
【Html&Cssで作る四択クイズゲーム開発記録④】HTMLのスクリプト部分(Javascript)をfunctionでまとめる
【Html&Cssで作る四択クイズゲーム開発記録⑥】ネイティブなJavascriptスクリプトをフル活用してHTMLタグ要素(DOM)を自動生成する
合同会社タコスキングダム|TacosKingdom,LLC.

HTML&CSSで四択クイズミニゲームを作るシリーズ記事の第5段です。

前回まででJavascriptスクリプト部分の小技の方をメインで紹介してきましたが、今回はそろそろSassの方で四択クイズの中身を修正していこうと思います。

合同会社タコスキングダム|タコキンのPスクール
【Html&Cssで作る四択クイズゲーム開発記録④】HTMLのスクリプト部分(Javascript)をfunctionでまとめる

HTMLのスクリプトタグにあるJavascriptコードの見直しを図ります。


合同会社タコスキングダム|タコキンのPスクール【Html&CSSアプリ】Html/Javascript/CSSを使った四択クイズゲームの作り方

Sassリストで四択クイズを簡単に拡張する

前回のソースコードまででは、四択クイズの設問がまだ3つしかありませんでしたので、今回は全6つまでクイズを増やすことを考えてみましょう。

首題のように本HTML&CSSミニゲームは
できるだけCSS(Sass)を使ってアプリを作成することを念頭においておりますので、クイズの設問の管理にもSassを使うようにします。

※当然Sassで出来ることはJavascripでも出来ます...が敢えてSassにこだわるのがこのブログ記事のテーマでもあります。

復習として、Sassのソースコードを中で四択クイズの選択肢をどのように設定していたかというと、以下のようになっていました。

            
            $stages: (
    'start' : ('op', ''),
    'stage1': ('1', 'タコの足は何本?', ('5本', '8本', '10本', '足はない'), 2),
    'stage2': ('2', 'タコは何動物?', ('地球外生物', '緩歩動物', '実は植物', '軟体動物'), 4),
    'stage3': ('3', 'タコの水揚げ量が世界一の国?', ('中国','カナダ','モーリタニア','日本'), 1)
);
        
よって新たに四択クイズの設問を増やす場合には、このSassリストに形式を考慮して、新しく追加していけば良いように前々回の記事の修正で設計しておりました。

ということで、このSassリスト・
$stagesを以下のようにちょっとだけ問題を増量してみます。

            
            $stages: (
    'start' : ('op', ''),
    'stage1': ('1', 'タコの足は何本?', ('5本', '8本', '10本', '足はない'), 2, blue),
    'stage2': ('2', 'タコは何動物?', ('地球外生物', '緩歩動物', '実は植物', '軟体動物'), 4, rgb(231, 185, 99)),
    'stage3': ('3', 'タコの水揚げ量が世界一の国?', ('中国','カナダ','モーリタニア','日本'), 1, rgb(211, 51, 203)),
    'stage4': ('4', 'タコの心臓は何個?', ('1個','2個','3個','8個'), 3, rgb(68, 67, 8)),
    'stage5': ('5', 'タコの弱点は?', ('イソギンチャク','直射日光','真水','ウミウシ'), 3, rgb(179, 53, 15)),
    'stage6': ('6', 'タコの脳は何個?', ('3個','9個','12個','実は存在しない'), 2, rgb(79, 100, 4))
);
        
ちなみに設問の文字の色もステージ毎に設定できるように、

            
            <リスト名>: (
    <ステージ名>: (
        <ステージ番号>,
        <問題のタイトル>,
        (<4つの選択肢>),
        <正解の選択肢の番号>,
        <色(色コード)>
    ),
    ...
);
        
というようにマイナーチェンジしています。

設問のテキストの内容だけではなく、色コードのような各設問ステージの属性値もSassリストに集約することで、あとは自由にSassコードの中で設定値を呼び出すようにできます。

例えば以下は、ステージ毎のテキストの色をSassリストから設定している修正箇所です。

            
            //...中略
&--main {
    display: flex;
    align-items: center;
    justify-content: center;
    label {
        flex: 0 0 auto;
        margin: 0 auto;
    }
    @for $i from 2 through length($stages) {
        $item: nth($stages,$i); // 👈$stagesリストの子要素 'stage*': (...) を順番に取得
        $item: nth($item,2); // 👈取得した$stagesリストの子要素の中身 (...) を再度取得
        &--#{nth($item,1)} { // 👈nth($item,1)はステージ番号
            color: nth($item,5); // 👈nth($item,5)は色コード
            font-size: 22px;
        }
    }
}
//...中略
        
今回の修正では上のようなSassリストを使った細かい修正が何箇所かあります。

リストから値を取得する
nth関数の扱いを理解できていれば対応できると思いますので、ここでは全ての修正箇所を説明するのは割愛いたします。

結論としては、
Sassを使うことで、従来のCSSには不向きだったコード側からのデータ管理も簡単に行えるようになったことが本ブログ記事の一連で説明してきた内容の中でももっとも重要なポイントになります。


合同会社タコスキングダム|タコキンのPスクール【Html&CSSアプリ】Html/Javascript/CSSを使った四択クイズゲームの作り方

Javascript部分をショートハンド(小技)でちょっとだけスリムにする

この記事の主題は先程の内容の説明で終わりですが、記事のボリュームとしては少し物足りなかったので、ちょっと便利で知っておくと得をする(かも知れない)ショートハンドをいくつか紹介しておきます。

配列のrange関数的な何か

pythonやrustの配列の組込関数で良く目にするrange関数的なものは、javascriptでは無いので、そんなときに使えるショートハンドです。

            
            [...Array(N).keys()]
        
Nには欲しい配列数で、0からN-1までの整数を配列化します。

スプレッド演算子はES6以降のJavascriptで動作する比較的最近の構文ですが、今やほとんどのブラウザ上で動くのでさほど気を遣うこともないでしょう。

ちなみにES5以前のバージョンだと、スプレッド演算子無しでこの手のショートハンドをするのは、Array.prototype.applyを使う方法がありましたが、既に骨董品なテクニックとなったようです(覚えておいて損はないのでしょうが)。

            
            Object.keys(Array.apply(null, {length: N}))
        
今回で言えば以下のように利用しています。

            
            [...Array(6).keys()].map(m => {
    return {stageName: `stage${m+1}`, checkbox: `checkmark${m+1}`};
}).forEach(g => {
    document.getElementById(g.stageName).addEventListener("change", (e) => {
        document.getElementById("gamestate").textContent += document.getElementById(g.checkbox).checked ? '⭕ ' : '❌ ';
        checkGameOver() && checkSum();
    });
});
        
ついでに言うと、前回登場させたshiftGamgeState関数もforEachのアロー関数の中に組み込んで今回の修正でお役御免としています。

三項演算子を上手く活用する

基本はif ~ else if ~ elseの条件分岐構文を使うと読みやすいのですが、こなれてくるともう少しコンパクトにJavascript部分を圧縮したい時があります。

条件三項演算子を使うと、条件分岐がほんのちょっと短く書ける可能性があります。

今回では折角全6問になったので、全問正解の場合に表示されるメッセージを、以下のようにこの三項演算子で分岐してみました。

            
            //...中略
function checkSum() {
    let result = 0, total = 0;
    document.querySelectorAll("input[id^='checkmark']").forEach((checkItem) => {
        checkItem.checked && result++;
        total++;
    });
    //👇ここを追加
    document.getElementById("score").textContent = result == total ?
        `全問正解🎉あなたは「タコマスター」の称号を得ました!!` : `正解率は ${result}/${total} でした。`;
}
//...中略
        
もう少し設問数が多くなって、正答率ごとにメッセージを切り分けたいときには、

            
            クリアメッセージ = 条件1 ? "もっと頑張りましょう"
          : 条件2 ? "なかなかの成績です"
          : 条件3 ? "かなり優秀です"
          : "完全制覇!"
        
などのようにswitch ~ case構文のような使い道もできます。

三項演算子は便利ですので、使い慣れておかれると少しだけ快適なJavascriptコーディングが可能になるでしょう。


合同会社タコスキングダム|タコキンのPスクール【Html&CSSアプリ】Html/Javascript/CSSを使った四択クイズゲームの作り方

まとめ

今回はSassリストにスタイルのみならず、ミニゲームのデータを一つに集約させて管理しやすくする方法を検討してみました。

やはり一般の大規模なゲームならばゲームデータはデータベースとして別に切り離して使う方が圧倒的に理に適っていると思いますが、ミニゲーム程度でなら静的ソースコードにハードコーディングで埋め込んでみても手間がかからず楽に作業できておすすめです。

今回で大方の四択クイズミニゲームの機能としては十分完成したような気がしますが、次回はもう少し高度にHTMLのDOM操作のリファクタリングをやってみたいと思います。

付録〜改良前のソースコード(バックアップ)

今回も恒例の古くなったソースコード(バージョン0.5)のバックアップを付録として掲載しておきます。

index.html

            
            <!DOCTYPE html>
<html lang="ja">
<html>
    <meta charset="utf-8"/>
    <head>
        <link rel="stylesheet" type="text/css" href="style.css">
    </head>
    <body>
        <form id="game-wrapper">
            <p class="game-header"><span id="gamestate">さあ4択クイズを始めましょう</span></p>
            <input type="reset" id="reset"/>
            <input type="checkbox" id="start"/>
            <input type="checkbox" id="stage1"/>
            <input type="checkbox" id="stage2"/>
            <input type="checkbox" id="stage3"/>
            <input type="checkbox" id="checkmark1"/>
            <input type="checkbox" id="checkmark2"/>
            <input type="checkbox" id="checkmark3"/>
            <div id="stage---op" class="stage stage--op">
                <label for="start">クリックしてスタート</label>
            </div>
            <div id="stage---1" class="stage stage--main stage--main--1">
                <label for="stage1"><ul class="flexlist"><li><li><li><li>
            </div>
            <div id="stage---2" class="stage stage--main stage--main--2">
                <label for="stage2"><ul class="flexlist"><li><li><li><li>
            </div>
            <div id="stage---3" class="stage stage--main stage--main--3">
                <label for="stage3"><ul class="flexlist"><li><li><li><li>
            </div>
            <div id="stage---reset" class="stage stage--end">
                <p class="score-board"><span id="score"></p>
                <label for="reset">もう一度トライ</label>
            </div>
        </form>
        <script>
        (function() {
            document.getElementById("start").addEventListener("change", (e) => {
                document.getElementById("gamestate").textContent = '成績: ';
            });
            function checkGameOver() {
                let isGameOver = true;
                document.querySelectorAll("input[id^='stage']").forEach((checkItem) => {
                    isGameOver = isGameOver && checkItem.checked;
                });
                return isGameOver;
            }
            function checkSum() {
                let result = 0;
                let total = 0;
                document.querySelectorAll("input[id^='checkmark']").forEach((checkItem) => {
                    checkItem.checked && result++;
                    total++;
                });
                document.getElementById("score").textContent = `正解率は ${result}/${total} でした。`;
            }
            function shiftGamgeState({stageName, checkbox}) {
                document.getElementById(stageName).addEventListener("change", (e) => {
                    document.getElementById("gamestate").textContent += document.getElementById(checkbox).checked ? '⭕ ' : '❌ ';
                    checkGameOver() && checkSum();
                });
            }
            [
                {stageName: "stage1", checkbox: "checkmark1"},
                {stageName: "stage2", checkbox: "checkmark2"},
                {stageName: "stage3", checkbox: "checkmark3"},
            ].forEach(g => shiftGamgeState(g));
            document.getElementById("reset").addEventListener("click", (e) => {
                document.getElementById("gamestate").textContent = 'さあ4択クイズを始めましょう';
            });
            document.querySelectorAll("ul.flexlist > li").forEach((userItem) => {
                userItem.addEventListener("click", (e) => {
                    const styles = window.getComputedStyle(e.target,'::after')
                    const content = styles['content'];
                    const m_ = content.match(/(\d+)\/correct/);
                    if (m_) {
                        document.getElementById("checkmark" + m_[1]).checked = true;
                    };
                });
            });
        }());
        </script>
    </body>
</html>
        

styles.scss

            
            #game-wrapper {
    width: 100%;
    height: 300px;
    background-color: darkgray;
    box-sizing: border-box;
    position:relative;
    input { display: none; }
    .game-header {
        position: absolute;
        top: 0;
        left: 0;
        background: #220c0c;
        color: #ceeece;
        z-index: 1;
        margin: 0;
        font-size: 22px;
        padding: 6px 0 6px 0;
        width: 100%;
    }
    .stage {
        position: absolute;
        display: block;
        width: 100%;
        height: 100%;
        font-size: 24px;
        &--op {
            display: flex;
            align-items: center;
            justify-content: center;
            label {
                display: block;
                flex: 0 0 auto;
                margin: 0 auto;
                animation: flash 1s linear infinite;
            }
        }
        &--main {
            display: flex;
            align-items: center;
            justify-content: center;
            label {
                flex: 0 0 auto;
                margin: 0 auto;
            }
            @each $key, $val in (1:blue, 2:rgb(231, 185, 99), 3:rgb(211, 51, 203)) {
                &--#{$key} {
                    color: $val;
                    font-size: 22px;
                }
            }
        }
        &--end {
            display: flex;
            align-items: center;
            justify-content: center;
            flex-direction: column;
            background: darkgray;
            label {
                flex: 0 0 auto;
                display: inline-block;
                font-weight: bold;
                padding: 0 0 0 0;
                text-decoration: none;
                color: #00BCD4;
                background: #ECECEC;
                margin: 0 auto;
                animation: flash 1s linear infinite;
            }
        }
        @keyframes flash {
            0%,100% {
                opacity: 1;
            }
            50% {
                opacity: 0;
            }
        }
    }
    .stage--main {
        ul.flexlist {
            border: 1px solid #666;
            display: flex;
            flex-wrap: wrap;
            justify-content: center;
            list-style-type: none;
            margin: 10px auto 2px;
            padding: 0;
            width: 300px;
            li {
                border: 1px solid #aaa;
                line-height: 110%;
                margin: 5px 2px 5px 2px;
                padding: 8px;
                text-align: center;
                width: 35%;
                &:hover {
                    color: chartreuse;
                }
                &::after {
                    content: "wrong";
                    visibility: hidden;
                    display: block;
                    width: 0;
                    height: 0;
                }
            }
        }
    }
    #stage {
        &---op {display: flex}
        @each $item in (1, 2, 3, reset) {
            &---#{$item} { display: none; }
        }
    }
    $stages: (
        'start' : ('op', ''),
        'stage1': ('1', 'タコの足は何本?', ('5本', '8本', '10本', '足はない'), 2),
        'stage2': ('2', 'タコは何動物?', ('地球外生物', '緩歩動物', '実は植物', '軟体動物'), 4),
        'stage3': ('3', 'タコの水揚げ量が世界一の国?', ('中国','カナダ','モーリタニア','日本'), 1)
    );
    @for $i from 1 through length($stages) {
        $item: nth($stages, $i);
        $item: nth($item,1);
        ##{$item}:checked ~ {
            $item: nth($stages, $i);
            $item: nth($item, 2);
            $item: nth($item,1);
            #stage---#{$item} {display: none}
            @if $i == length($stages) {
                #stage---reset {
                    display: flex;
                    opacity: 1;
                    animation: fadeIn 0.3s ease-in 0s forwards;
                }
            } @else {
                $item: nth($stages, $i + 1);
                $item: nth($item,2);
                $item: nth($item,1);
                #stage---#{$item} {
                    display: flex;
                    opacity: 1;
                    animation: fadeIn 0.3s ease-in 0s forwards;
                    $item: nth($stages, $i + 1);
                    $item: nth($item,2);
                    $item: nth($item,2);
                    label::before {
                        content: '問題#{$i}:#{$item}';
                    }
                    $item: nth($stages, $i + 1);
                    $item: nth($item,2);
                    $quiz_option: nth($item,3);
                    $answer: nth($item,4);
                    @for $j from 1 through length($quiz_option) {
                        ul.flexlist {
                            li:nth-child(#{$j}) {
                                &::before {content: '#{nth($quiz_option,$j)}';}
                                @if $j == $answer {
                                    &::after { content: "#{nth($item,1)}/correct"; }
                                }
                            }
                        }
                    }
                }
            }
        }
    }
    @keyframes fadeIn {
        0% {
            display: none;
            opacity: 0;
        }
        1% {
            display: flex;
            opacity: 0;
        }
        100% {
            display: flex;
            opacity: 1;
        }
    }
}