Vue 3 + YouTube で「早押しイントロクイズ」(6人用)をつくる

こんにちは。フリーランス・コンサルタント&エンジニアの 九保すこひ です。

さてさて、この記事を書いているのが 2023年年末なのですが、やはり年末年始といえば久しぶりに友人と会ったりする機会も多くなりますよね。

そして、そういう人たちと会えば、お酒や美味しいものを食べたりゲームしたりすると思うんですが、だいたい同じパターンになってしまったりしないでしょうか。

そんなことを考えていると開発してみたいアイデアが1つ浮かびました。

それは・・・・・・

イントロクイズ♪

です。

つまり「曲がかかり始めたら、早押しでその曲名を答える」というシンプルなクイズですが、ハラハラドキドキするシステムです。

そこで❗

今回は「Vue 3 + YouTube」で(今回は)5曲ピックアップしてイントロクイズをつくってみましょう。

ぜひ何かの参考になりましたら嬉しいです。😄✨

※ ちなみにコードを見ると答えがイッパツでわかります。もし答え無しで試してみたい人は デモページ へいきなり行ってみてください。

「私の好きな曲を
ピックアップしてみました。
おすすめです👍」

開発環境: Vue 3(Options API)、Laravel 10.x(といってもメインはVue 3 のみ)、YouTube Player API

やりたいこと

最初は複数台のスマホを使って早押しができればいいなと考えていたのですが、あまりにもコードが長くなりすぎてしまいそうなので、今回はとてもシンプルに1台のスマホだけで完結 できるように考えてみました。

まずスマホを横に表示します。
すると、以下のような表示になります。

そして、以下がクイズの流れです。

  1. 6人がそれぞれ青いボタンの上に指をセットする(6人より少なくてもOK)
  2. 代表者が「第○問スタート」ボタンをタップ
  3. YouTube の曲が流れる
  4. 曲名がわかったらボタンをタップ
  5. YouTube 動画が一時停止するので曲名を答える
  6. 正解 or 不正解を確認
  7. (これの繰り返し)

なお、スマホのブラウザでも使いやすいように フルスクリーン・モードにも対応 させますし、早押しボタンをタップしたら「ピンポン♪」と音がなるようにしてみましょう。

また、通常の早押しだけでは面白くないので、早押ししても10回に1回は「残念!あなたには解答権がありません」と表示されるようにしてみます。

では、楽しんでやっていきましょう❗

サウンドエフェクトをダウンロードして設置しておく

今回は以下2つの場面で音を鳴らすようにします

  • 早押しボタンを押して解答するとき
  • 早押しボタンを押したけど、運悪く解答権がなかったとき

そこで、以下2つの音源(ogg)をダウンロードして/public/audiosへ設置しておいてください。

ビューをつくる

では、メインのビューをつくっていきます。
以下のコマンドを実行してください。

php artisan make:view intro_don

するとresources/views/にファイルが作成されるので中身を以下のようにします。

resources/views/intro_quiz.blade.php

<!DOCTYPE html>
<html>
<head>
    <title>イントロクイズ!</title>
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <style>

        .counting-modal-overlay {
            position: fixed;
            top: 0;
            left: 0;
            width: 100vw;
            height: 100vh;
            background-color: rgba(0,0,0,.6);
            z-index: 10000
        }

    </style>
</head>
<body>

    <div id="app" class="relative h-screen">

        <!-- コントローラー -->
        <div class="absolute top-0 w-full h-screen flex justify-center items-center" style="gap:3rem;">
            <div class="flex-1 flex justify-start">
                <button class="p-2 bg-gray-200 rounded transform -rotate-90" @click="toggleFullScreen">
                    フルスクリーン
                </button>
            </div>
            <div class="flex-1 flex justify-center items-center">
                <button class="p-2 bg-gray-200 rounded" @click="onVideoStart" v-if="questionStatus === 'ready'">
                    第<span v-text="videoIndex + 1"></span>問
                    スタート
                </button>
                <div v-if="questionStatus === 'answering'">
                    <button class="p-2 bg-gray-200 rounded mr-1" @click="onShowAnswer">
                        答えを見る
                    </button>
                    <button class="p-2 bg-gray-200 rounded ml-1" @click="onVideoRestart">
                        再生を続ける
                    </button>
                </div>
                <div class="text-3xl" @click="onShowAnswer" v-if="questionStatus === 'playing'">
                    この曲名は?
                </div>
            </div>
            <div class="flex-1 flex justify-end">
                <button class="p-2 bg-gray-200 rounded transform rotate-90" @click="toggleFullScreen">
                    フルスクリーン
                </button>
            </div>
        </div>

        <!-- ボタン -->
        <div class="absolute top-0 w-full h-1/4 flex px-12" style="gap:3rem;">
            <template v-for="i in 3">
                <button class="rounded-bl-2xl rounded-br-2xl" :class="getAnswerButtonClassNames(i)" @click="onPressAnswerButton(i)"></button>
            </template>
        </div>
        <div class="absolute bottom-0 w-full h-1/4 flex px-12" style="gap:3rem;">
            <template v-for="i in 3">
                <button class="rounded-tl-2xl rounded-tr-2xl" :class="getAnswerButtonClassNames(i + 3)" @click="onPressAnswerButton(i + 3)"></button>
            </template>
        </div>

        <!-- YouTube プレイヤー -->
        <div id="player" class="hidden"></div>

        <!-- カウントダウン -->
        <div
            v-show="isCounting"
            class="counting-modal-overlay flex justify-center items-center">
            <svg aria-hidden="true" class="inline w-12 h-12 text-gray-200 animate-spin dark:text-gray-600 fill-blue-600" viewBox="0 0 100 101" fill="none" xmlns="http://www.w3.org/2000/svg">
                <path d="M100 50.5908C100 78.2051 77.6142 100.591 50 100.591C22.3858 100.591 0 78.2051 0 50.5908C0 22.9766 22.3858 0.59082 50 0.59082C77.6142 0.59082 100 22.9766 100 50.5908ZM9.08144 50.5908C9.08144 73.1895 27.4013 91.5094 50 91.5094C72.5987 91.5094 90.9186 73.1895 90.9186 50.5908C90.9186 27.9921 72.5987 9.67226 50 9.67226C27.4013 9.67226 9.08144 27.9921 9.08144 50.5908Z" fill="currentColor"/>
                <path d="M93.9676 39.0409C96.393 38.4038 97.8624 35.9116 97.0079 33.5539C95.2932 28.8227 92.871 24.3692 89.8167 20.348C85.8452 15.1192 80.8826 10.7238 75.2124 7.41289C69.5422 4.10194 63.2754 1.94025 56.7698 1.05124C51.7666 0.367541 46.6976 0.446843 41.7345 1.27873C39.2613 1.69328 37.813 4.19778 38.4501 6.62326C39.0873 9.04874 41.5694 10.4717 44.0505 10.1071C47.8511 9.54855 51.7191 9.52689 55.5402 10.0491C60.8642 10.7766 65.9928 12.5457 70.6331 15.2552C75.2735 17.9648 79.3347 21.5619 82.5849 25.841C84.9175 28.9121 86.7997 32.2913 88.1811 35.8758C89.083 38.2158 91.5421 39.6781 93.9676 39.0409Z" fill="currentFill"/>
            </svg>
            <p class="ml-4 whitespace-nowrap text-white text-center text-bold" style="font-size:3rem;">
                <span class="transform rotate-90" v-text="countDown"></span>
            </p>
        </div>

    </div>

    <script src="https://unpkg.com/vue@3.3.13/dist/vue.global.prod.js"></script>
    <script src="https://www.youtube.com/iframe_api"></script>
    <script src="https://cdn.tailwindcss.com/3.4.0"></script>
    <script src="https://cdn.jsdelivr.net/npm/sweetalert2@11"></script>
    <script>

        // Creative Commons Attribution license: Sounds from Notification Sounds( https://notificationsounds.com/ )

        const app = Vue.createApp({
            data() {
                return {
                    questionStatus: 'initializing',
                    player: null,
                    videoItems: [ // 動画IDと曲名のリスト
                        {
                            videoId: 'G20WV-mLf5o',
                            title: 'ワタリドリ / Alexandros',
                        },
                        {
                            videoId: 'ktLOyOot3bY',
                            title: '完全感覚 Dreamer / ONE OK ROCK',
                        },
                        {
                            videoId: 'hfaJqINFN6w',
                            title: 'Walking with you / Novelbright',
                        },
                        {
                            videoId: 'kzZ6KXDM1RI',
                            title: 'ドライフラワー / 優里',
                        },
                        {
                            videoId: 'AbE-121K_ok',
                            title: 'うっせぇわ / Ado',
                        },
                    ],
                    videoIndex: 0,
                    isCounting: false,
                    countDown: 3,
                    answerNumber: 0, // 解答権を持っている回答ボタンの番号 1〜6
                }
            },
            methods: {
                initVideo(videoItem) {

                    if(! videoItem) {

                        Swal.fire({
                            title: '全問終了しました',
                            text: '結果はいかがでしたか?',
                            icon: 'success',
                            confirmButtonText: 'もう一回やる',
                        })
                        .then(() => {

                            location.reload();

                        });

                    } else {

                        if(app.player) {

                            app.player.stopVideo();
                            app.player.destroy();
                            app.player = null;

                        }

                        app.player = new YT.Player('player', {
                            height: '390',
                            width: '640',
                            videoId: videoItem.videoId,
                            playerVars: { 'playsinline': 1 },
                            events: {
                                onReady() {

                                    app.questionStatus = 'ready';

                                },
                                onError() {

                                    Swal.fire({
                                        title: 'エラー',
                                        text: 'この動画は埋め込み再生できないようです',
                                        icon: 'error',
                                        confirmButtonText: 'OK',
                                    });

                                },
                            }
                        });

                    }

                },
                getCurrentVideoItem() {

                    return this.videoItems[this.videoIndex];

                },
                getAnswerButtonClassNames(i) {

                    return (Number(i) === Number(this.answerNumber))
                        ? 'answer-button w-1/4 h-full bg-red-500 flex-1'
                        : 'answer-button w-1/4 h-full bg-blue-500 flex-1';

                },
                toggleFullScreen() {

                    if (! document.fullscreenElement) {

                        const methodNames = ['requestFullscreen', 'mozRequestFullScreen', 'webkitRequestFullscreen'];

                        methodNames.forEach((methodName) => {

                            if (methodName in document.documentElement) {

                                document.documentElement[methodName]();

                            }

                        });

                    } else { // フルスクリーン解除

                        const methodNames = ['cancelFullScreen', 'mozCancelFullScreen', 'webkitCancelFullScreen'];

                        methodNames.forEach((methodName) => {

                            if (methodName in document) {

                                document[methodName]();

                            }

                        });

                    }

                },
                clearAnswerButton() {

                    this.answerNumber = 0;

                },
                makeSoundEffect(url) {

                    document.querySelectorAll('.notification-iframe').forEach(el => el.remove());

                    const iFrame = document.createElement('iframe');
                    iFrame.setAttribute('src', url);
                    iFrame.setAttribute('allow', 'autoplay');
                    iFrame.style.display = 'none';
                    iFrame.className = 'notification-iframe';
                    document.body.appendChild(iFrame);

                },
                onVideoStart() {

                    if(this.questionStatus === 'ready') {

                        this.questionStatus = 'counting';
                        this.isCounting = true;

                        const timer = setInterval(() => {

                            if (this.countDown === 1) {

                                this.isCounting = false;
                                this.countDown = 3;
                                this.questionStatus = 'playing';
                                this.player.playVideo();
                                clearInterval(timer);

                            } else {

                                this.countDown--;

                            }

                        }, 1000);

                    }

                },
                onVideoRestart() {

                    if(this.questionStatus === 'answering') {

                        this.questionStatus = 'playing';
                        this.clearAnswerButton();
                        this.player.playVideo();

                    }

                },
                onPressAnswerButton(answerNumber) {

                    if(this.questionStatus === 'playing') { // 回答ボタンは、再生中のみ有効

                        this.player.pauseVideo();

                        if(Math.floor(Math.random() * 10) === 0) {

                            this.makeSoundEffect('/audios/laughing-guy-443.ogg');

                            Swal.fire({
                                title: '残念!',
                                text: 'あなたには解答権がありません',
                                icon: 'error',
                                confirmButtonText: 'OK',
                            })
                            .then(() => {

                                this.questionStatus = 'answering';

                            });

                        } else {

                            this.makeSoundEffect('/audios/appointed-529.ogg');
                            this.answerNumber = Number(answerNumber);
                            this.questionStatus = 'answering';

                        }

                    }

                },
                onShowAnswer() {

                    const videoItem = this.getCurrentVideoItem();
                    this.clearAnswerButton();

                    Swal.fire({
                        title: '答え',
                        text: videoItem.title,
                        icon: 'info',
                        confirmButtonText: '次の問題',
                    })
                    .then(() => {

                        this.videoIndex++;
                        const videoItem = app.getCurrentVideoItem();
                        this.initVideo(videoItem);

                    });

                },
            },
        }).mount('#app');

        // YouTube は Vue の外で初期化する
        window.onYouTubeIframeAPIReady = () => {

            const videoItem = app.getCurrentVideoItem();
            app.initVideo(videoItem);

        };

    </script>
</body>
</html>

この中で少し難しいのがVue 3YouTube Player APIとの統合です。

というのも、YouTube Player APIscriptタグが読み込まれたら自動的に関数onYouTubeIframeAPIReadyを呼び出す仕様になっているようなので、すべてVue内で完結させることができなかったからです。

そのため、initVideo()内では、thisは使わず、Vueを格納した変数appへアクセスするようにしています。

また、今回始めて知ったのですが実はTailwindCSSでもテキストを回転させることができるんですね。

例えば、以下は90度回転させたボタンが表示されます。

<button class="transform rotate-90">
    90度右回転!
</button>

逆に-90度(つまり270度)回転させる場合は以下のように「-」をつけてやるだけでOKです。

<button class="transform -rotate-90">
    90度左回転!
</button>

便利ですね😄

ルートをつくる

では、先ほどのビューをルートに追加します。

routes/web.php

// 省略

Route::get('intro_quiz', fn() => view('intro_quiz'));

ちょっと早いですが、今回はこれで作業は完了です。
お疲れ様でした。😄✨

ちなみに

ちなみに、YouTube動画の設定で「埋め込み再生」ができないものがありますが、残念ながら今回のコードではそういった動画は再生することができません。

ただし、今回はイントロクイズなので、例えば「ワタリドリ ドラム カバー」とかで検索すると普通にオリジナルの音源が入っているものがあるので、そういった動画を見つけるといいでしょう。(優里さんの曲だけは埋め込みできました。太っ腹👍)

テストしてみる

ホントはスクリーン録画してYouTubeにでもアップしたかったのですが、音楽は著作権がからんでいるようなので画像だけでのご紹介にします。

では、まずはスマホのブラウザで「https://******/intro_quiz」へアクセスしてください。

おそらくアドレスバーが表示されて見にくいとので、左右に配置された「フルスクリーン」ボタンをどちらでもいいのでタップします。

すると、以下のようになりました。

では、まずは「第1問スタート」をタップしてみます。

すると・・・

カウントダウンが始まって・・・・・・

はい❗

(わかりにくいですが…)動画が再生されて音が流れてきました。(動画のiFrameinvisibleにしているので表示されることはありません)

まずは成功です😄

では、画面右下の人が「早押し」をしたと想定して「回答ボタン」をタップをしてみましょう。

どうなるでしょうか・・・・・・

はい❗
ボタンの色が変わり、音声も再生されました。

しかも、この状態だと他のボタンをタップしても変化はありません。(早押しで解答権を持っている人が答えている状態)

では、今回は正解がわからなかったことにして、「再生を続ける」をタップしてみます。

すると、再度動画が再生されました。
では、次は左上の人が「早押し」してみましょう。

はい❗
今度は別のボタンの色が変わりました。しかも先ほど解答できなかった右下のボタンは灰色になってボタンが効かなくなっています。

では、今回は答えがわかったので「答えを見る」をタップしてみましょう。

うまくいくでしょうか・・・・・・

はい❗
正解が表示されました。

※ つまり、この場面で間違っている場合はペナルティ1ということになります。

では、5問最後までやってみるとどうなるでしょうか。

はい❗
最後まで行ったので終了のモーダルが表示されました。(SweetAlert2、きれいでいいですね)

すべて成功です😄👍

デモページをつくりました

せっかくなので、デモページをつくりました。
ぜひ体験してみてください

📝 デモページ

企業様へのご提案

今回はとてもシンプルな早押しクイズでしたが、これを拡張させてログイン機能などを追加すれば、ユーザー自身が動画を登録して好きなイントロクイズを作り、公開できたりもします。

もし、そういった機能をご希望でしたらいつでもお問い合わせからご相談ください。

お待ちしております。😄✨

開発のご依頼お待ちしております
開発のご依頼はこちらから: お問い合わせ
どうぞよろしくお願いいたします! by 九保すこひ

おわりに

ということで、今回はほぼVue 3だけで早押しイントロクイズをつくってみました。

正直最初は、冒頭でも書いたとおりPusherか何かを使って複数台のスマホのシステムを考えていたのですが、なかなか複雑になりそうだったのでスマホ1台で完結するようにしてみました。

また、Pusherはとても高速とは言え、やはり少しタイムラグが発生する可能性が高いことも見送った理由のひとつです。(早押しなので、回線が遅い人が不利になるというのもあるのでなかなか難しいところですね)

ただし、今回のコードも完璧かと言えばそうではなく、早押しボタンを同時に押した場合は @click が効かなくなるので、今後もし改造するならタッチされた位置情報で判別できればうまくいくのかな、というカンジでした。Life is not easy ですね…😅

ぜひ皆さんもこんなカンジで「楽しめるクイズ」をやってみてくださいね。

ではでは〜❗

「ダイナ四さんの
ドラムカバーは
エグいですね👍」

このエントリーをはてなブックマークに追加       follow us in feedly