【Html&Cssで作る四択クイズゲーム開発記録①】Sassのリストと@forループを使ったコード省力化の話


※ 当ページには【広告/PR】を含む場合があります。
2021/12/15
【Html&Cssで作る四択クイズゲーム開発記録②】擬似要素のcontentプロパティでクイズの正誤判定に使う
合同会社タコスキングダム|TacosKingdom,LLC.

HTML(Javascript)とCSSを組み合わせながらミニゲームを開発していこうとすると、2つの言語でコードの役割分担を考えながらバランスよく進めて行く必要があります。

かといってCSSはもともとWebページデザインのための言語であり、あまり大規模なプロジェクトで用いるとコードが肥大化して非常に読みにくいメンテナンスに乏しいのが難点です。

もしもCSSでミニゲームを作りたい場合は、プロジェクト開発の早い段階から
Sassで作っておくと後々ひどいソースコードになって触る度に泣かなくても済むでしょう。

今回は作成途中の
四択クイズミニゲームのリファクタリングを兼ねて、Sassの@forループとリストのキーや値の取り出しの使い方を考えてみましょう。


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

Sassのリストと@forの使い方

Sassの配列の基本は別ブログの以下の記事に色々と書いていましたので、詳しくはそちらをご確認ください。

参考|Sassで扱う配列でのイテレーションを理解する ~ cssだけでチャートを描画するための前知識

そちらの記事でも説明はしましたが、Sassでは
「配列」=「リスト」です。

より正確にいうとSassに配列(Array)はないのですが、この記事では極力配列ではなく「リスト」と呼ぶことにします。

Sassのリストはイテレーション(反復処理)に便利で、
@each <アイテム> in <リスト>構文で

            
            // 簡単なリスト
$list: [a, b, c, d];
$i: 0;
@each $item in $list {
    .hoge-#{$item} {
        piyo: '#{$item}';
        index: $i;
    }
    $i: $i+1;
}
        
これをSassmeisterなどのオンラインコンパイラーで試すと

合同会社タコスキングダム|タコキンのPスクール

というようにリストからCSSスタイルのブロックが展開されていることが分かります。

リストによる簡単な繰り返しなら
@each ~ in構文でOKなのですが、ループ中に次の要素を一つ先読みしたいときなど、微妙に手の痒いところに届かない場合があります。

そこで首題の通り、
@for構文を使ったイテレーションを利用することになります。

            
            // 簡単なリスト②
$list: [a, b, c, d];

@for $i from 1 through length($list) {
    .hoge-#{nth($list,$i)} {
        piyo: '#{nth($list,$i)}';
        index: $i - 1;
    }
}
        
この@for構文は、@for <インデックス変数> from <初期値> through <終了値>で利用します。

リストを@forでループさせる場合、初期値は1から始める必要があります(0ではないことに注意)。

これを試すと先程の@eachの場合と同じ結果を得ます。

合同会社タコスキングダム|タコキンのPスクール

さらにリストの面白い特徴がリストを使って多重配列的に入れ子にすることが出来ることです。

            
            // 簡単なリスト③
$list: [
    [a, [1, 2]],
    [b, [5, 6]],
    [c, [9, 10]],
    [d, [13, 14]]
];

@for $i from 1 through length($list) {
    .hoge-#{nth(nth($list,$i),1)} {
        piyo: '#{nth(nth(nth($list,$i),2),1) + nth(nth(nth($list,$i),2),2)}';
        index: $i - 1;
    }
}
        

合同会社タコスキングダム|タコキンのPスクール

釘括弧
[]だけでリストを作ると括弧の数が多くなるので、より見やすいリストを作るなら連想配列として特別な意味を持つ丸括弧()でリストを作ると良いでしょう。

先程のコードは以下のように置き換えできます。

            
            // 簡単なリスト④
$list: (
    a: [1, 2],
    b: [5, 6],
    c: [9, 10],
    d: [13, 14]
);

@for $i from 1 through length($list) {
    .hoge-#{nth(nth($list,$i),1)} {
        piyo: '#{nth(nth(nth($list,$i),2),1) + nth(nth(nth($list,$i),2),2)}';
        index: $i - 1;
    }
}
        

合同会社タコスキングダム|タコキンのPスクール

ここでは紹介しませんが、この丸括弧でのリストは
@each ~ in構文でKey-Valueパターンを作るときに利用できます。

またSass組込モジュールの
sass:mapも併用することができますが、今回はnth関数で直にアクセスしています。


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

Scssコードの改造

では先程の説明までで、@for構文の使い方のウォーミングアップを終えたところで、前回までの四択問題ミニゲームのソースコードから修正していきましょう。

改善前

まずは修正を加える前のソースコードを全載せしておきます。

index.htmlの内容は以下のコード:

            
            <!DOCTYPE html>
<html lang="ja">
    <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">
                    問題1:タコの足は何本?
                    <ul class="flexlist">
                        <li>5本</li>
                        <li id="correct-option-1">8本</li>
                        <li>10本</li>
                        <li>足はない</li>
                    </ul>
                </label>
            </div>
            <div id="stage---2" class="stage stage--main stage--main--2">
                <label for="stage2">
                    問題2:タコは何動物?
                    <ul class="flexlist">
                        <li>地球外生物</li>
                        <li>緩歩動物</li>
                        <li>実は植物</li>
                        <li id="correct-option-2">軟体動物</li>
                    </ul>
                </label>
            </div>
            <div id="stage---3" class="stage stage--main stage--main--3">
                <label for="stage3">
                    問題3:タコの水揚げ量が世界一の国?
                    <ul class="flexlist">
                        <li id="correct-option-3">中国</li>
                        <li>カナダ</li>
                        <li>モーリタニア</li>
                        <li>日本</li>
                    </ul>
                </label>
            </div>
            <div id="stage---reset" class="stage stage--end">
                <p class="score-board"><span id="score"></span></p>
                <label for="reset">もう一度トライ</label>
            </div>
        </form>
        <script>
            document.getElementById("start").addEventListener("change", (e) => {
                document.getElementById("gamestate").textContent = '成績: ';
            });
            document.getElementById("correct-option-1").addEventListener("click", (e) => {
                document.getElementById("checkmark1").checked = true;
            });
            document.getElementById("stage1").addEventListener("change", (e) => {
                const r_ = document.getElementById("checkmark1").checked;
                if (r_) {
                    document.getElementById("gamestate").textContent += '⭕ ';
                } else {
                    document.getElementById("gamestate").textContent += '❌ ';
                }
            });
            document.getElementById("correct-option-2").addEventListener("click", (e) => {
                document.getElementById("checkmark2").checked = true;
            });
            document.getElementById("stage2").addEventListener("change", (e) => {
                const r_ = document.getElementById("checkmark2").checked;
                if (r_) {
                    document.getElementById("gamestate").textContent += '⭕ ';
                } else {
                    document.getElementById("gamestate").textContent += '❌ ';
                }
            });
            document.getElementById("correct-option-3").addEventListener("click", (e) => {
                document.getElementById("checkmark3").checked = true;
            });
            document.getElementById("stage3").addEventListener("change", (e) => {
                const r_ = document.getElementById("checkmark3").checked;
                if (r_) {
                    document.getElementById("gamestate").textContent += '⭕ ';
                } else {
                    document.getElementById("gamestate").textContent += '❌ ';
                }
                let result = 0;
                document.getElementById("checkmark1").checked && result++;
                document.getElementById("checkmark2").checked && result++;
                document.getElementById("checkmark3").checked && result++;
                document.getElementById("score").textContent = `正解率は ${result}/3 でした。`;
            });
            document.getElementById("reset").addEventListener("click", (e) => {
                document.getElementById("gamestate").textContent = 'さあ4択クイズを始めましょう';
            });
        </script>
    </body>
</html>
        
以下はstyle.cssの元になる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;
            }
            &--1 {
                color: blue;
                font-size: 22px;
            }
            &--2 {
                color: rgb(99, 231, 169);
                font-size: 22px;
            }
            &--3 {
                color: rgb(235, 212, 85);
                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;
                }
            }
        }
    }
    #stage {
        &---op {display: flex}
        &---1,&---2,&---3,&---reset {display: none}
    }
    #start:checked ~ {
        #stage---op {display: none}
        #stage---1 {
            display: flex;
            opacity: 1;
            animation: fadeIn 0.3s ease-in 0s forwards;
        }
    }
    #stage1:checked ~ {
        #stage---1 {display: none}
        #stage---2 {
            display: flex;
            opacity: 1;
            animation: fadeIn 0.3s ease-in 0s forwards;
        }
    }
    #stage2:checked ~ {
        #stage---2 {display: none}
        #stage---3 {
            display: flex;
            opacity: 1;
            animation: fadeIn 0.3s ease-in 0s forwards;
        }
    }
    #stage3:checked ~ {
        #stage---3 {display: none}
        #stage---reset {
            display: flex;
            opacity: 1;
            animation: fadeIn 0.6s ease-in 0s forwards;
        }
    }
    @keyframes fadeIn {
        0% {
            display: none;
            opacity: 0;
        }
        1% {
            display: flex;
            opacity: 0;
        }
        100% {
            display: flex;
            opacity: 1;
        }
    }
}
        

@forを使って改善する!

では@for構文使って無駄なコードを改善していきましょう。

先に改善後の
style.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;
                }
            }
        }
    }
    #stage {
        &---op {display: flex}
        //👇修正ポイント②
        @each $item in (1, 2, 3, reset) {
            &---#{$item} { display: none }
        }
    }
    //👇修正ポイント③
    $stages: (
        'start' : 'op',
        'stage1': '1',
        'stage2': '2',
        'stage3': '3'
    );
    @for $i from 1 through length($stages) {
        ##{nth(nth($stages, $i),1)}:checked ~ {
            #stage---#{nth(nth($stages, $i),2)} {display: none}
            @if $i == length($stages) {
                #stage---reset {
                    display: flex;
                    opacity: 1;
                    animation: fadeIn 0.3s ease-in 0s forwards;
                }
            } @else {
                #stage---#{nth(nth($stages, $i+1),2)} {
                    display: flex;
                    opacity: 1;
                    animation: fadeIn 0.3s ease-in 0s forwards;
                    label::before {
                        content: '問題#{$i}:';
                    }
                }
            }
        }
    }
    @keyframes fadeIn {
        0% {
            display: none;
            opacity: 0;
        }
        1% {
            display: flex;
            opacity: 0;
        }
        100% {
            display: flex;
            opacity: 1;
        }
    }
}
        
まずはソースコード内にコメントを挿入している箇所のうち、修正ポイント①に着目しましょう。

@forループでリストから生成したcssのレンダリング結果は以下のようになります。

合同会社タコスキングダム|タコキンのPスクール

これでリストを追加していくだけで、いちいち一つのステージずつスタイルを追加していかなくても自動でスタイルが定義されていきます。

修正ポイント②も同様で、

合同会社タコスキングダム|タコキンのPスクール

共通したcssスタイルのテンプレートさえ見極められれば、リストと@forから効率的にスタイルを生成できています。

今回もっとも難易度の高い@forとリストの使いこなしが
修正ポイント③にあたる部分です。

合同会社タコスキングダム|タコキンのPスクール

まずよく理解していただきたいのは、
nth関数のリストの要素取り出し方です。

nth関数は深いネストをもつリストから要素を取り出す際に、
nth(nth(nth(nth(...を重ねて呼び出さないといけないので少し扱いにくいのが難点です。

            
            $stages: (
    'start' : 'op',
    'stage1': '1',
    'stage2': '2',
    'stage3': '3'
);

//以下はnth関数の使い方の例
nth($stages,1) //👉'start' : 'op'
nth($stages,4) //👉'stage3': '3'
nth(nth($stages,2),1) //👉'stage1'
nth(nth($stages,4),2) //👉'3'
        
nth関数の第2引数で取り出したい要素の位置を指定しますが、この指定番号のルールにも気をつけましょう。

他にちょっとした修正点ですが、
問題...の冒頭の部分をlabelのbefore擬似要素としてcssから付け足しました。

            
            //....
label::before {
    content: '問題#{$i}:';
}
//....
        
これでindex.htmlの部分の修正はほんの少しですが、問題...の文を削るだけにします。

            
            ...
<div id="stage---1" class="stage stage--main stage--main--1">
    <label for="stage1">
        タコの足は何本?
        <ul class="flexlist">
            <li>5本</li>
            <li id="correct-option-1">8本</li>
            <li>10本</li>
            <li>足はない</li>
        </ul>
    </label>
</div>
<div id="stage---2" class="stage stage--main stage--main--2">
    <label for="stage2">
        タコは何動物?
        <ul class="flexlist">
            <li>地球外生物</li>
            <li>緩歩動物</li>
            <li>実は植物</li>
            <li id="correct-option-2">軟体動物</li>
        </ul>
    </label>
</div>
<div id="stage---3" class="stage stage--main stage--main--3">
    <label for="stage3">
        タコの水揚げ量が世界一の国?
        <ul class="flexlist">
            <li id="correct-option-3">中国</li>
            <li>カナダ</li>
            <li>モーリタニア</li>
            <li>日本</li>
        </ul>
    </label>
</div>
...
        
ということで今回の修正は以上です。

Sassコードのリファクタリングには積極的にリストと@forループを利用することを考えてください。


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

参考サイト

Sass meister | 外部ツール