Help us understand the problem. What is going on with this article?

自分の結婚式をちょっとだけインタラクティブにした話

きっかけ

かなりの私事の話なのですが先日結婚しまして、婚姻届を提出した次の日に結婚式と披露宴を行いました。
式の段取りは半年ほど前から式場プランナーの人と打ち合わせていて、ケーキカットやプロフィールムービーなど披露宴あるあるなプログラムを取り入れたり、一部は自分たちで用意したりしていました。
そんな事をしていた最中、式の2ヶ月ほど前でしょうか、せっかく自分エンジニアだし、何かしらゲストの人たちが楽しめるようなものを作ってサプライズ的にリリースしたいという謎のモチベーションが湧いてしまい、式準備のタスクに開発作業を追加。
式のタイムリミットが迫る中、他のタスクが山のようにあるのに、自分の首を絞めながら必死で開発を進め、なんとかリリース。ゲストの人たちも(多分)楽しんでくれたんじゃないかなあという、そんなプロダクトの技術解説記事になります。どうぞお付き合いください。

作ったもの(デモ)

こちらになります。本番で使ったものと違い、デモ用としていくつか修正してますのでご承知おきください。
PCで見るページ:https://wedding-note-demo.firebaseapp.com/display.html
スマホで見るページ:下のQRからどうぞ
qr20191205013836811.png

スマホサイトで打ち込んだメッセージがPCサイトの方にリアルタイムで表示され、さらにそれが手書き風の文字で、寄せ書きのように残り続けます。ゲストの人達はメッセージ投稿で遊べて楽しい、新郎新婦は寄せ書きとして後から読み返せて楽しい、そんなインスタレーションを作りました。

※上記デモですが、Firebase無料枠の中での公開である為、閲覧するタイミング的に表示できない場合があるかもしれません。それもご承知おきください。

Githubにソースコード上げてみたので、気になる方はこちらもどうぞ。
https://github.com/kawazu255/wedding-note-demo

技術解説

バックエンドとフロントエンドそれぞれ分けて話します。
ちなみに自分の開発環境はMacOS High Sierraです。

※記事上、わかりやすくするためGithubに上げてあるソースコードから一部改変して載せている部分があります。ご了承ください。

バックエンド

全てをFirebase任せにしております。
公開用デプロイ先はFirebase Hostingを利用し、スマホからのメッセージをPCに同期する部分はFirebase Realtime Databaseを利用してます。

Firebaseのセットアップ

Firebaseのコンソール画面でプロジェクトを作成し、ターミナルから適当なディレクトリに移動した後「firebase init」コマンドでデプロイ環境を作成します。この辺りは先人の方々が、かなり記事を書いておりとても参考になりました。
個人的には下記の記事がとてもわかりやすく、参考にさせていただきました。
https://qiita.com/kohashi/items/43ea22f61ade45972881

一点気をつける点としては、一番最初にFirebaseでどのサービスを使うかを選択する際に、ちゃんと「Database」と「Hosting」を選択することでしょうか。
1.png

送信・受信メソッド

リアルタイム通信を行いたいhtmlのbodyタグ内に、下記のようにJSファイルを読み込むように記載します。

pc.js
<script src="/__/firebase/7.0.0/firebase-app.js"></script>
<script src="/__/firebase/7.0.0/firebase-database.js"></script>
<script src="/__/firebase/init.js"></script>

「/__/」ってなっているところは、Firebase Hostingにアップロードした後、Firebase内でパスが保管され、よしなにファイルを読み込んでくれるそうです。(なのでローカル環境では動かないから注意!)
送信するメソッドはこんな感じ。

index.js
var messagesRef = firebase.database().ref('/message');
$(function() {
  $('#comment').on('submit', function() {
    var name = $('#name').val();
    var message = $('#message').val();
    messagesRef.push({name:name, message:message});
    $('#name').val('');
    $('#message').val('');
    return false;
  });
});

「firebase.database().ref('/message')」でRealtime Databaseと通信するインスタンスを作成し、pushなどのメソッドを使ってデータをやりとりする感じらしいです。上記はid=commentが付与されたbuttonを押下すると、id=nameとid=messageが付与されたinput textの内容をfirebaseにpushするぜ、って内容です。

そして受信する側はこうです。

display.js
messagesRef.on('child_added', function (snapshot) {
  var data = snapshot.val();
  var key = snapshot.key;
  var text = data.message;
  var nickname = data.name;
  //以下描画処理などなど
});

「messagesRef.on」のcallback関数は、Realtime Database内のデータが追加されるたびに呼び出されることになります。また「snapshot.val()」にデータの内容が入ってきます。
Realtime DatabaseはKeyValue型(に近いもの)なのですが、データが格納されるたびに一意のシリアルが発行され、それがKeyとなります。「snapshot.key」で、そのKeyも取得することができます。

攻撃されても安心

結婚式には会社の先輩・後輩エンジニアの皆様がたくさん来てくれました。
中にはこんな愛ある投稿までありました。
2.JPG
3.JPG

デバッグを通り越して完全に攻撃されてます。DB消しに来るあたり、さすが容赦ないですね。
ですが先ほど記載した通り、Realtime DatabaseはKeyValue型(に近いもの)。少なくともSQLインジェクションは効かなさそうです。(厳密にいうとJSON型でデータ保持してるらしい https://firebase.google.com/docs/database?hl=ja)
まだ試してないのですが、DBへの読み込み・書き込みタイミングを「database.rules.json」という設定ファイルに細かく記載できるようで、意図しないデータ更新なども防ぐ仕組みはありそうです。どの辺りまで有効なのかは今後触っていく上で理解したいなあと思っております。

ちなみに個人的には、どちらかというとXSSの方が心配で、式の前に検証していたのですが、これも実は問題なさそうです。

4.png

5.png

scriptタグを送信しても問題なく表示されてますね。ちなみにDBの中身はこんな感じです。

6.png

処理の中でエスケープ処理などは一切行なっておりません。Firebase側の方で、エスケープ対象の文字列についてはよしなにエスケープしてくれているということでしょうか。さすが天下のGoogleサービス・・・!

料金

公式の料金ページにも書いてますが(https://firebase.google.com/pricing?hl=ja)、Hosting・Realtime database共に1GBまでのストレージ、10GBまでのデータ転送が無料です。今回の用途は結婚式の中でのインスタレーションなので、そこまでの通信量を見越さず無料で使い切ることができました。
ちなみに、使用上限が近づいてくると、ちゃんとアラートのメールが飛んで来ます。
9.png

上記送られて来た時間は、お色直し後に新郎新婦登場したあたり。式の後半ですね。意外とギリギリだったなぁ・・・

フロントエンド

実装的にはこちらの方がはるかに大変でした。
ちなみに自分はバックエンドエンジニアなので、色々調べながら作っていて結構時間かかってしまった背景もあります。(そしてコードも汚くなってしまったのはまた別のお話)

基本的にどうしてもやりたかったこととしては、

  • スマホから送信されたメッセージを吹き出し的に表示する
  • 中央に配置したvideoタグ領域(新郎新婦の写真動画)や、吹き出し同士はなるべく被らないようにする
  • 全体的になんかオシャレにしたい
  • メッセージ投稿された時、どの位置に投稿されたのかわかりやすくしたい

一つ一つ解説します。

吹き出し描画

こちらの記事を参考にさせていただきました。
https://qiita.com/horikeso/items/95595f379a8dfa63c34a
上記記事では、四角型の吹き出しに三角型の矢印がついているのですが、そこは使わないのでコメントアウト。
吹き出しの一番下に右づめで名前を入れたかったので、名前用のテキスト文を空白でpaddingするという力技を使ったりしました。(もっといい処理はあった気がしてます・・・)

display.js
var nickname = comment.name.padStart(9, ' ');

//中略

// 基本設定
var boxWidth = 300;
var padding = 10;
var radius = 10;// 円弧の半径

context.fillStyle = "rgba(" + [233, 234, 234, 0.8] + ")";

// テキスト設定
var limitedWidth = boxWidth - (padding * 2);
var size = 30;
context.font = size + "px HuiFont29";

// テキスト調整 行に分解
var lineTextList = text.split("\n");
var newLineTextList = [];
lineTextList.forEach(function (lineText) {
    if (context.measureText(lineText).width > limitedWidth) {
        characterList = lineText.split("");// 1文字ずつ分割
        var preLineText = "";
        var lineText = "";
        characterList.forEach(function (character) {
            lineText += character;
            if (context.measureText(lineText).width > limitedWidth) {
                newLineTextList.push(preLineText);
                lineText = character;
            }
            preLineText = lineText;
        });
    }
    newLineTextList.push(lineText);
});
//最後にニックネームを入れる
newLineTextList.push(nickname);

吹き出し同士や動画との当たり判定

こちらもわかりやすい記事を見つけたので参考にさせていただきつつ実装。
https://qiita.com/hp0me/items/57f901e9b0babe1a320e
考え方含めとてもわかりやすかったです。(僕そんなに書くことないな・・・)

実装はこんな感じ。(グローバル変数とかあるのでGithubのソース見てもらったほうがいいかも・・・)

display.js
function decidePosition (window_w, window_h, box_w, box_h) {
  var box_x = decideX(window_w, box_w);
  var box_y = decideY(window_h, box_h);
  var draw_position = [box_x, box_y];

  //新規のテキスト領域
  var new_area_x = box_x + box_w/2;
  var new_area_y = box_y + box_h/2;
  var new_area_width = box_w;
  var new_area_height = box_h;

  //動画領域
  var movie_area_x = window_w/2;
  var movie_area_y = window_h/2;
  var movie_area_width = window_w/2;
  var movie_area_height = window_h/2;

  //当たり判定
  if(
    Math.abs(new_area_x - movie_area_x) < new_area_width/2 + movie_area_width/2 //横の判定
    &&
    Math.abs(new_area_y - movie_area_y) < new_area_height/2 + movie_area_height/2 //縦の判定
  ){
    draw_position = decidePosition(window_w, window_h, box_w, box_h);
  }

  position_arr.forEach(function(position, i, a){
    //既存のテキスト領域
    var exist_area_x = position[0] + position[2]/2;
    var exist_area_y = position[1] + position[3]/2;
    var exist_area_width = position[2];
    var exist_area_height = position[3];

    //当たり判定
    if(
      Math.abs(new_area_x - exist_area_x) < new_area_width/2 + exist_area_width/2 //横の判定
      &&
      Math.abs(new_area_y - exist_area_y) < new_area_height/2 + exist_area_height/2 //縦の判定
    ){
      reset_count++;
      if (reset_count < 10) {
        draw_position = decidePosition(window_w, window_h, box_w, box_h);
      }
    }
  });
  return draw_position;
}

function decideX (window_w, box_w) {
  var box_x = Math.floor(Math.random() * window_w);
  if (box_x + box_w > window_w) {
    box_x = decideX(window_w, box_w);
  }
  return box_x;
}

function decideY (window_h, box_h) {
  var box_y = Math.floor(Math.random() * window_h);
  if (box_y + box_h > window_h) {
    box_y = decideY(window_h, box_h);
  }
  return box_y;
}

吹き出し同士は最初の方は被らずに表示されますが、いずれ吹き出しが画面を埋め尽くしてくると、どうしても被らざるを得ないため、10回くらい計算しても被るようなら諦めてそのまま描画するようにしてます。
でも中央の動画には絶対に被らせないようにもしてます。

背景

particles.jsを使用しました。
https://vincentgarreau.com/particles.js/
プラグインこのまま、色味を変えてあげるだけで、かなり雰囲気出る画面にできました。
やっぱりパーティクルって偉大ですね。

実装部分はこちら。

display.js
window.onload = function() {
    Particles.init({
        selector: '.background',
        sizeVariations: 36,
        color: [
            '#caa846', 'rgba(202,168,70,.5)', 'rgba(202,168,70,.2)'
        ]
    });
};

ほぼこれだけで背景パーティクルにできちゃうすごい・・・

CreateJSでキラキラ実装

こちらの記事を参考にさせていただいてます。(参考にしてばっかり)
https://incloop.com/%E3%82%A8%E3%83%95%E3%82%A7%E3%82%AF%E3%83%88%E3%82%92%E8%BF%BD%E5%8A%A0%E3%81%97%E3%81%A6%E7%94%BB%E5%83%8F%E3%82%92%E3%82%AD%E3%83%A9%E3%82%AD%E3%83%A9%E3%81%95%E3%81%9B%E3%82%8B/
ただ常時キラキラさせるわけではなく、投稿があった時のみ、10秒だけキラキラさせるようにしてます。
また、式場ではプロジェクターに投影するため色全体が白とびしやすく、そのため色味調整は彩度高め・明度暗めに設定してます。

実装はこちら。

twinkle.js
(function(window){
  var PARAM = new Object();
  $.canvas = {
    init : function(text_x, text_y, text_w, text_h){
      PARAM = {
        main   : {id:$('#twinkle_area')},
        canvas : {
          id   : $('#twinkle'),
          //size : {x:text_x, y:text_y, width:text_w, height:text_h} // !!画像サイズと一致させる!!
        },
        velocity : {x:0, y:0},
        circle   : new createjs.Shape(),
        stage    : ''
      };        
      $.canvas.seting(text_x, text_y, text_w, text_h);
    },
    seting : function(text_x, text_y, text_w, text_h){
      var canvasObject = PARAM.canvas.id.get(0);
      var context      = canvasObject.getContext("2d");

      PARAM.stage = new createjs.Stage(canvasObject);
      PARAM.velocity.x = Math.floor(Math.random()*5) + 5;
      PARAM.velocity.y = Math.floor(Math.random()*5) + 5;

      var interbalid = null;
      interbalid = setInterval(function(){
        $.canvas.star(text_x, text_y, text_w, text_h);
      },80);

      setTimeout(function(){
        $.canvas.stop(interbalid);
      },10000);

      createjs.Ticker.on("tick", $.canvas.tick);
    },
    star : function(text_x, text_y, text_w, text_h){
        console.log('star');
      var shape      = new createjs.Shape();
      var g          = shape.graphics;
      var color      = (Math.random()*360);
      var glowColor1 = createjs.Graphics.getHSL(0, 100, 100, 1);
      var glowColor2 = createjs.Graphics.getHSL(color, 100, 40, 0.5);
      var radius     = (Math.random()*50);
      var position   = {x:text_x + Math.random()*text_w, y :text_y + Math.random()*text_h};

      g.beginRadialGradientFill( [glowColor1,glowColor2], [0.1,0.5], 0,0,1, 0,0,(Math.random()*10+13)*2);
      g.drawPolyStar(0, 0, radius, 6, 0.95, (Math.random()*360));
      g.endFill();

      g.beginRadialGradientFill( [createjs.Graphics.getHSL(color,100,30,0.5),createjs.Graphics.getHSL(color,100,30,0)], [0,0.5], 0,0,0, 0,0,radius);
      g.drawCircle(0, 0, radius);
      g.endFill();

      shape.compositeOperation = "lighter";

      shape.x      = position.x;
      shape.y      = position.y;
      shape.scaleX = 0;
      shape.scaleY = 0;
      shape.alpha  = 0;
      shape.shadow = new createjs.Shadow(color, 0, 0, 5);

      PARAM.stage.addChild(shape);
      $.canvas.tween(shape);
    },
    tween : function(SHAPE){
      var tween = createjs.Tween.get(SHAPE)
        .to({scaleX:1, scaleY:1, alpha:1}, 500, createjs.Ease.sineOut)
        .to({scaleX:0, scaleY:0, alpha:0, }, 800, createjs.Ease.sineIn)
      ;
      tween.call(function(){
        $.canvas.remove(this);
      });
    },
    remove : function(SHAPE){
      PARAM.stage.removeChild(SHAPE);
    },
    tick : function(){
      PARAM.stage.update();
    },
    stop : function(interbalid){
      //createjs.Ticker.reset();
      clearInterval(interbalid);
      interbalid = null;
    },
  };
})(window);

まとめと感想

長々と失礼しました。
式の2ヶ月前に思い立って、えいやで突貫したこのプロダクトですが、これのおかげでより良い式にできた気がします。
仕事で触る機会がなかなかなかったFirebaseを触れていい経験になりましたし、Canvas使ってJSを試行錯誤しながら書くのはやっぱり楽しいです。(書けないけど)
バックエンドを全てFirebase任せにできたので、その分フロントエンド側の実装に時間を割けたのは本当にありがたかったです。Firebase万歳。

あと実装以外で大変だったのは、式の当日はメイクや着替えやその他で、デバッグもできなければ設営すらもできないということ。現地テストも式の1週間前のワンチャンスのみで、その後の修正分が動くかどうかはぶっつけ本番だったのでかなりヒヤヒヤものでした。
なので前日までに検証し切るスケジュールで動き、設営も後輩に託すために簡単に引き継ぎドキュメント作ったり、全ての検証が終わった後に神に祈ったり・・・などなどやったのが裏話。

せっかく作ったことですし、この仕組みも今後何かに生かせればいいなと思ったりしました。寄せ書きが使えそうなイベントごとって他にあるだろうか。卒業式とか?

kawazu255
某広告代理店にて、バックエンド領域のテクニカルディレクターとして日々頑張っています。 最近は機械学習・ディープラーニング・電子工作・コンテナ技術などにも手を出しつつ勉強中。
Why not register and get more from Qiita?
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away