🎪

【文化祭】●プリーグのトロッコアドベンチャーを作る方法

2022/09/19に公開

そろそろ夏休みも終わり、学生の皆さんは文化祭ムードになってきているのではないでしょうか。

…私も学生ですが。

どうもこんにちは!とある普通科高校に所属している、(仮) <かっこかり>と申します。

ともかく、私の学校は文化祭が夏休み前に終わったので、そこで行ったことをちょっと共有してみたいと思います。

うちのクラスでは、ネ●リーグト●ッコアドベンチャーを製作しました。実際にトロッコを作成し、リアルタイムで映像を生成して前方のスクリーンに投影します。

大まかな仕組み
大まかな構成

本来はそういう機材面を含めたすべてをお話しするべきなのかもしれませんが、ここはあくまでZenn。テックブログですから、技術面のお話をすることにいたしましょう。

今回の要件

  • クイズの問題を複数の難易度・ジャンルにそれぞれ分ける
  • 難易度の上限値を設定し、その範囲内で無作為に問題を抽出する
    (徐々に難しくしたいため、難易度の出題パターンは固定)
  • 学校で配られたChromebookで動かす
    (トロッコは複数台制作。手持ちのPCだけじゃ足りない!)
  • 問題をみんなが入力できるようにする
    (専用のツール開発が必要!)
  • 文化祭は6月

採用した技術

私はWebしかできませんから、普通にHTML/CSS/JSを採用。今回は低スペック端末(Chromebook)で動作させるので、DOM操作・CSSアニメーションだけで演出を完結させています。Canvasは使っていません。

外部ライブラリ

制作の流れ

実施項目決定~開発開始(4月中旬)

私の学校の文化祭は6月の初頭にあります。そのため、4月中ごろには、文化祭でト●ッコアドベンチャーを行うことが大まかに決定しました。

ト●ッコアドベンチャーというモノの性質上、システム開発は必須になるでしょう。開発ができそうな人がクラスに私しかいないのを考えると、その責務は私に回ってくることになりそうな予感がしたので、この時点でひそかに開発を開始していました。 後になって、この判断は大正解だったことがわかります。

本格的な会議 1回目(5月初旬)

決定からかなり日が開いて、5月初旬に再び会議が設けられました。開発を早めに進めていただけあって、ト●ッコアドベンチャーのオープニング位まではクラスの人に見せることができました。

しかし、会議が進むうちに別の問題も発覚しました。ある男子生徒が一言。

「これ、ト●ッコ作るのクソ大変じゃね?」

ト●ッコの製作に開発時間をとられる(5月中旬~)

ト●ッコは、人が数人乗れて、しかも本家同様に傾く仕様となっていました。しかもこれを3台も作るという狂気じみた計画となっていたため、開発を担当していた私もトロッコの製作に携わることに。木材をカットしたり、ビスを打ち込んだり、ホームセンターにダッシュしたりと大忙しでした。

加えて、全員で話し合って出たアイデアなどをシステムに反映するなど、開発業務も健在。ヤバイ。早めに始めたにも拘らず、限界開発期間が始まりました。

こんな状況でシステムがまだ出来ていなければ…考えただけでも恐ろしいですね。なんでも早めに動くことが大切だと実感しました。

文化祭直前!何とか間に合う

システムの拡張は当日の朝まで続きました。肝心のト●ッコも文化祭の2日前には完成し、会場となる教室に運び込むためにバラバラの状態で一夜を明かしました。

最後に実装した機能は、最終問題で担任の先生が登場する演出です。これが当日に思わぬ事故を引き起こしました。

当日!やらかす

さぁ!いよいよ当日がやってきました。万一の場合に備えて、担任の先生に、インターネットにつながるWindows PCを用意してもらいました。

シフトが入っていないとき、私は文化祭を回ることができました。しかし、万一のことを考えて、会場の近くだけをうろつくようにし、電話の着信音の音量を最大にして、即座に会場に戻れるようにしていました。

問題の入力ミス

まず、問題の入力ミスによる不具合が頻発しました。不具合とは言っても、問題の誤字程度のもので、プログラム本体には問題はありませんでした。ネットにつながるPCで随時修正を行い、事なきを得ました。

伝達ミス

安泰かと思われた矢先、緊急連絡が。

「●●(担任)先生が出る映像が不正解でも流れる!」

確認するもプログラムには異常なし。聞いてみると、どうやら先生が登場する演出は全問正解時だけだと伝わっていたようです。本当は、最終問題で不正解した場合も先生が登場し、フェイントで不正解演出を行う、という仕様になっていました。渾身のフェイントにスタッフ自身が引っかかるという、なんとも皮肉な事件が発生していました。

私が到着するまでは、現場判断により、先生の演出が出た段階でもう一度やり直しとしていたそうです(即断即決が素晴らしい)。そのため、回転率が悪くなってしまっていました。しっかりと伝達を行い、クラスのLINEグループにもお知らせをして、事なきを得ました。

細かい仕様まで伝達できていなかったのが大きなミスでした。反省。

結果発表

こうして無事(?)文化祭は終了。以下のような実績を獲得しました。

  • 来場者数:1,500人以上
  • ピーク時待ち時間:約30分

正解数によって景品を付けたのですが、2日分の景品が1日目でなくなるというハプニングも発生しました。かなり人気だったようです。うれしいね。

完成品

問題を作るやつ(問題ビルダー)

クラス全員がこれを操作して問題を作成。できた問題はGoogleフォームに投稿させて、手動で適宜マージを行います。クライアント側でのセーブにはLocalStorageを使用。

問題セットマージ用プログラム

Googleフォームに集まった問題セットを一括で読み込ませると、問題のカテゴリ毎に別々のファイルに仕分けて出力します。

ト●ッコアドベンチャー本体

この画面はゲームスタート前に係員が設定を変更するものです。問題のカテゴリごとにファイルを分けて、この画面でそれを選択させることで、ジャンルの切り替えを容易にしています。

ご覧の通り、全部Webベースです。振り返ってみれば、問題セットマージ用のプログラムくらいはスタンドアロンアプリとして作ったほうがよかったかも。

遊び方

まずは、受付で問題と難易度を指定し、各トロッコへ移動します。係員に受付用紙を手渡し、係員はそれをもとにゲームモードを選択してゲームスタート。


事前のテスト時の写真。本番はもう少し暗いです

選択肢を決める際は、どちらか片方に寄り、両端に付けられたボタンを押して決定します。このボタンはマウスの左右のボタンと対応しており、右クリックが右方向の選択に対応しています。

それを不正解になるまで(全問正解になるまで)続けて終了です。

全体的な雰囲気はこんな感じ。

制作におけるミソ

すべてを書くと大長編になってしまうので、ポイントだけ押さえておきます。

どうやってCSSアニメーションさせたの?

A. datasetを使いまくりました。

アニメーションがかかる場面に、data-*の引数を置きまくります。それをVueでとっかえひっかえして、CSSのセレクタに合わせてアニメーションさせました。

また、現在アクティブでない要素をアニメーションさせないようにするために、data-active要素がないとtransitionが効かないようになっています。

<section id="quizIndivResultSection" :data-active="phase == 3" :data-status="indivResultSection.status">
    <div class="quizIndivResultSection__text">正解</div>
    <div class="quizIndivResultSection__desc">{{currentQuestion.description}}</div>
</section>
アニメーション関係のみ抜粋
#quizIndivResultSection {
    opacity: 0;
    pointer-events: none;
}

#quizIndivResultSection[data-active="true"] {
    opacity: 1;
    pointer-events: all;
}

#quizIndivResultSection[data-active="true"][data-status="ansready"] .quizIndivResultSection__text,
#quizIndivResultSection[data-active="true"][data-status="correct"] .quizIndivResultSection__text {
    transition: opacity .05s linear, transform .2s linear;
}

#quizIndivResultSection .quizIndivResultSection__text {
    transform: translateX(-50%) translateY(50%) scale(.1);
    opacity: 0;
}

#quizIndivResultSection[data-status="correct"] .quizIndivResultSection__text {
    transform: translateX(-50%) translateY(0) scale(1);
    opacity: 1;
}

.quizIndivResultSection__desc {
    transform: translateX(-50%);
    opacity: 0;
    pointer-events: none;
    white-space: pre-wrap;
}

#quizIndivResultSection[data-active="true"] .quizIndivResultSection__desc {
    transition: opacity .25s linear;
}

#quizIndivResultSection[data-status="description"] .quizIndivResultSection__desc {
    opacity: 1;
    pointer-events: all;
}

画像の読み込みはどうしたのか?

今回利用したWebサーバーはデータベースがなく、また作る気もなかったので、「問題作成ツールを作って生成してもらったjsonファイルを統合ツールでまとめあげる」という荒業をチョイスしました。

問題作成時に画像も同時にフォームで回収したのですが、そのタイミングで既定のファイル名問題ID_{left|right})に変更してもらったうえで、PHPでディレクトリをglobして該当の画像にリダイレクトするということをやっていました。

<?php
if (empty($_SERVER["HTTPS"])) {
    $url = "http://";
} else {
    $url = "https://";
}
$url .= $_SERVER["HTTP_HOST"];

if($_SERVER["HTTP_HOST"] == "localhost") {
    $url .= "/adventure";
}

if(!isset($_GET["id"]) || empty($_GET['id']) || ($_GET["choice"] != 'left' && $_GET['choice'] != 'right')) {
    header("Location: ". $url. "/assets/img/qpics/no_image.png");
} else {
    $filenames = glob("qpics/". $_GET["id"]. "_". $_GET["choice"]. ".*");
    if(count($filenames) == 0) {
        header("Location: ". $url. "/assets/img/qpics/no_image.png");
    } else {
        header("Location: ". $url. "/assets/img/qpics/". $filenames[0]);
    }
}

映像素材を作るポイント

映像は、intro(トンネルに突入する映像)とfailure(溶岩に落ちる映像)以外を、すべて同じ絵のフレームで開始・終了するように作成(トリミング)しましょう。でないと映像を切り替える際に不自然に見えてしまいますので、しっかり編集ソフト等でそれぞれの素材の最終・最初のフレームを重ねるなどして確認してから出力してください。

また、ブラウザの<video>タグで再生する際に、ビデオに音声トラックがあると自然にループしてくれません。ちょっとつっかえてしまいます。 映像のエンコード時には、必ず音声トラックを抜くようにしてください(無音にするのではなく、トラック自体を抜いて出力)。

映像素材ははじめから全ての素材を <video> タグで置いておいて、それをCSSの opacity で切り替えると自然に見えます。

<div id="video" v-show="phase!= -1 && phase != 0 && phase != 4">
    <video :class="['bgVideos', {'is-active': (videoVisualPhase == 1)}]" playsinline preload="auto" muted src="../assets/media/trokko_intro_v2.mp4" poster="../assets/img/bg_third_start.jpg" ref="bg1"></video>
    <video :class="['bgVideos', {'is-active': (videoVisualPhase == 2)}]" playsinline preload="auto" muted src="../assets/media/trokko_loop_v2.mp4" poster="../assets/img/trokko_default.png" ref="bg2"></video>
    <video :class="['bgVideos', {'is-active': (videoVisualPhase == 3)}]" playsinline preload="auto" muted src="../assets/media/trokko_left_v2.mp4" poster="../assets/img/trokko_default.png" ref="bg3"></video>
    <video :class="['bgVideos', {'is-active': (videoVisualPhase == 4)}]" playsinline preload="auto" muted src="../assets/media/trokko_right_v2.mp4" poster="../assets/img/trokko_default.png" ref="bg4"></video>
    <video :class="['bgVideos', {'is-active': (videoVisualPhase == 5)}]" playsinline preload="auto" muted src="../assets/media/trokko_failure_v3.mp4" poster="../assets/img/trokko_default.png" ref="bg5"></video>
    <video :class="['bgVideos', {'is-active': (videoVisualPhase == 6)}]" playsinline preload="auto" muted src="../assets/media/trokko_last_success_v2.mp4" poster="../assets/img/trokko_default.png" ref="bg6"></video>
    <video :class="['bgVideos', {'is-active': (videoVisualPhase == 7)}]" playsinline preload="auto" muted src="../assets/media/trokko_last_failure_v2.mp4" poster="../assets/img/trokko_default.png" ref="bg7"></video>
</div>

音声素材を作るポイント

音声は、Howler.jsを利用して管理しました。これを使うことで、音声の多重再生などの制御をよしなにやってくれるので助かりますね。

https://howlerjs.com/

Howler.jsに読み込ませる音源は、効果音・BGMなどのすべての音声素材をつなげて出力します。その際、各素材の開始ミリ秒と再生時間をメモっておきましょう。各音声をスプライトとして定義する際にこんな感じに使います。

// [開始ミリ秒, 再生時間ミリ秒, ループ音源かどうか]

const audio = new Howl({
    src: ['../assets/media/sprite_v6.mp3'],
    sprite: {
        fanfare: [0, 5250, false],
        intro: [5250, 12000, false],
        primary: [17250, 2250, true],
        correct: [19500, 750, false],
        beforeAns: [23250, 2625, true],
        afterAns: [20250, 3000, true],
        gameOver: [25875, 4312, false],
        question: [30188, 188, false],
        curve: [30375, 2812, false],
        choice: [33188, 1968, false],
        point: [35156, 1594, false],
        gameOverJingle: [36750, 8250, false],
        lastFailure: [45000, 7500, false],
        lastSuccess: [52500, 10500, false]
    }
});

ディレクトリ構造

ディレクトリ構造(クリックで展開)
adventure/
├─ index.html
├─ adventure/
│  └─ index.html
├─ assets/
│  ├─ css/
│  │  ├─ adventure.css
│  │  └─ bootstrap.min.css
│  ├─ js/
│  │  ├─ howler.min.js
│  │  ├─ vue.3.js
│  │  ├─ main.js
│  │  └─ builder.js
│  ├─ quizes/
│  │  ├─ general.json
│  │  ├─ music.json
│  │  (ジャンルごとにjsonファイルを切り分けて格納)
│  ├─ img/
│  │  ├─ qpics/
│  │  │  ├─ index.php
│  │  │  └─ qpics/
│  │  │     ├─ 0cnDfpE9l-SDVxNHdyD7L_left.jpg
│  │  │     ├─ 0cnDfpE9l-SDVxNHdyD7L_right.jpg
│  │  │     └─ (問題ID_{left|right}というファイル名で問題用画像を保存)
│  │  └─ (img直下にはインターフェース用の画像を収録)
│  └─ media/
│     ├─ sprite_v6.mp3
│     ├─ trokko_intro_v1.mp4
│     ├─ trokko_loop_v1.mp4
│     ├─ trokko_left_v1.mp4
│     ├─ trokko_right_v1.mp4
│     ├─ trokko_failure_v1.mp4
│     ├─ trokko_last_failure_v1.mp4
│     └─ trokko_last_success_v1.mp4
└─ builder/
   ├─ index.html
   ├─ merge.html
   └─ editor.html

謝辞

勿論このソフトウェアは私一人で成り立っているものではありません。動作テストやト●ッコ側の回路との統合、それに問題の製作…ソフトウェア面だけ見ても、クラスメイト全員が参加してくれた上に成り立っているものです。また、クラス全体をまとめ上げた文化祭委員の方々や、超ノリノリで文化祭の準備に協力してくださった担任の先生の尽力があって完成したと思います。この場をお借りして感謝申し上げます。

またなにか質問等あれば追記していきます。皆さんの文化祭準備に少しでも役立てれば幸いです!

Discussion