20191213のJavaScriptに関する記事は30件です。

非Javascriptエンジニアのための「Javascriptの制御構文」

前回の記事 - 非Javascriptエンジニアのための「Javascriptの比較演算子」

ITエンジニアのアクマちゃん(@akumachanit)デモ!
この記事は非JavascriptエンジニアのためのJavascript入門 3日遅れのひとり Advent Calendar 2019の記事デモ!

この記事はJavaScript 「再」入門をベースに書いているデモ。

1日サボってしまったデモ...!気を取り直して今日は制御構文についてデモ!

今回紹介する制御構文

  • 空文
  • ブロック文
  • ラベル文
  • break文
  • continue文

次回紹介するもの

  • if...else文
  • for文
  • switch文

ループ構文は次回、エラー処理のtry文はいつかエラーについてやる時に説明するデモ。

空文

;

空文は、文が必要な箇所で文を明示的に「書かない」時に使う文デモ。
わけがわからないデモね。例を出すデモ。

for (i = 0; i < arr.length; arr[i++] = 0);

for文の後にはループ内で処理する文が期待されているデモ。
それを書かないことでforのカッコ内の処理(正確にはカッコ内の3つ目の式)だけを実行できるデモ。

「文が期待されている場所で文を書かない」
これは次の項のブロック文の「1つの文しか書けない箇所に複数の文を書く」というのと対の機能を持つと言えるデモね。

ブロック文

ブロック文は{}で囲んだ0個以上の文をグループ化することができるデモ。

{
    let a = 1;
    let b = 2;
}

変数の回で紹介した通り、let, constはブロックスコープを持っているデモが、varはブロックスコープを持っていないので注意デモ!

let foo = 1;
{
    let foo = 2;
}
console.log(foo); // 1

var bar = 1;
{
    var bar = 2;
}
console.log(bar); // 2

ブロック文は後述のif...else文やwhile文、for文などの制御フロー文と使われることが多いデモ。

let a = false;
if (a) {
    console.log("foo"); // 実行されない
    console.log("bar"); // 実行されない
}

ブロック文を使わないと1文しか制御できないデモね。

let a = false;
if (a) 
    console.log("foo"); // 実行されない
    console.log("bar"); // 実行されてしまう

ラベル文

ラベル文は文に予約語ではない任意のラベルをつけられるデモ。

block1: {
    // block1とラベル付けされたブロック文
}

branch: if (true) {
    // branchとラベル付けされたif文
}

ラベル文は後述のbreakcontinueで指定することができるデモ。

break文

break文を使用するとループ、switch文、ラベル文を中断して次の処理に制御を移すことができるデモ。

while文を中断してみるデモ。

while (true) {
    if (a) {
        break; // whileが中断されて次の処理に移行します。
    }
}

atrueになるとループから抜けるデモ。

次はラベルを指定してみるデモ。

lab1: {
    lab2: {
        lab3: {
            console.log('begin')
            break labX; //ここでlab1~3を指定
            console.log('1');
        }
        console.log('2');
    }
    console.log('3');
}

// break lab1を指定した場合の出力
'begin'
// break lab2を指定した場合の出力
'begin'
'3'
// break lab3を指定した場合の出力
'begin'
'2'
'3'

指定したラベルのブロック文が中断されて次の処理に移っているデモね。

continue文

continue文はループの現在反復中の文(ブロック)を終了して、次の反復をスタートするデモ。ラベルを指定している場合は、ラベルが指定されているループの次の反復をスタートするデモ。
具体的には、while文では条件式にジャンプするデモ。for文では更新式までジャンプするデモ。

while (true) {
  console.log('loop');
  if (true) {
    break;
  }
}
console.log('finish.');

あとがき

今回はif...elseforwhile以外の制御構文を紹介したデモ。
今回紹介したブロック文やbreak, continue文は次回の制御構文で活躍するデモね!

参考

JavaScript 「再」入門
文と宣言

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

複数のNuxt.jsを用いたプロジェクトに携わってきて感じたこと

スクリーンショット 2019-12-13 17.11.30.png

みなさんこんにちは@y_temp4です。

自分は現在フリーランスのエンジニアとして主にフロントエンドの開発を行っており、これまでに複数の Vue/Nuxt の案件に携わってきました。

というわけで今回は、いくつかの Nuxt プロジェクトに関わってきて得た知見についてシェアしていければなと思います!

想定読者

この記事は以下のような方を想定読者として書いています。

  • Nuxt を導入すべきか迷っている/新規プロジェクトで Nuxt の利用を検討している
  • Nuxt の構成で迷っている
  • Nuxt の利用について興味がある

Nuxt でプロジェクトを初める条件

まずはじめに Nuxt のプロジェクトを初める条件ですが、基本的に Nuxt はフロントエンドのフレームワークであり、バックエンドと疎結合な作りになっていることが望ましいです。いわゆる SPA を作る感じですね。

ですので、新規にプロジェクトを立ち上げる際は、バックエンドは完全に API サーバーとしてフロントエンドと分離させるような設計にすべきでしょう。

例えば、自分が初めて Nuxt のコードを書いた際はバックエンド API が AWS の Lambda + API Gateway でした。

参考:【ALIS のシステム】サーバサイドアーキテクチャ:その1 〜ALIS サーバサイド構成〜 | ALIS

余談:なぜ SPA で開発するのか

本記事のメインの話題とは話が逸れるので詳しくは書きませんが、SPA で開発する必要性・メリデメに関しては、以下のスライドが参考になるかと思います。

私たちはなぜ SPA で開発するのか / Why you choose SPA - Speaker Deck

Nuxt プロジェクトの構成で意識すべきこと

Nuxt のプロジェクトをいくつか見てきた感想として、自分が一番思うのは「Nuxt の仕組みに沿った形で作ったほうが良い」ということです。まぁフレームワークを使う上で、フレームワークの仕組みに乗っかったほうがいいのは当然かも知れませんが、フロントエンドの構成はよりバックエンドよりもおざなりになりがちかなと感じています。

例えば Nuxt にはモジュールという概念があり、これを用いることによってある程度品質の担保された機能を実装できます。

また、Nuxt のプロジェクトに元から用意されたディレクトリ構造くらいは守るようにしましょう。

Nuxt は変化の速度が早いフレームワークであるので、独自に実装した箇所がバージョンアップの追従の足枷になるケースがよくあります。ですので、Nuxt の機能を逸脱したことをやろうとする場合、それにそこまでする価値があるか・既存の仕組みで実現できないか、よく検討した方が良いと感じています。

TypeScript の導入

Nuxt における TypeScript の導入は、基本的に以下の公式ドキュメントに沿えば OK です。

Nuxt TypeScript

しかし、それ以外の細かい点で TypeScript 導入を行う際につまずいたポイント等があるので、今回はそれについて列記します。

Nuxt をプログラムで使う際の設定

Nuxt.js をプログラムで使う」にあるように、通常のnuxt-tsコマンドではなく Express などから Nuxt を動かしているケースでは、@nuxt/typescript-buildの関係でうまく動作しないことがありました。

おそらくすぐに改善されるかとは思いますが、この記事の執筆時点での解決方法を記述しておきます。

import tsModule from '@nuxt/typescript-build'

...

  if (config.dev) {
    await nuxt.moduleContainer.addModule(tsModule)
    const builder = new Builder(nuxt)
    await builder.build()
  } else {
    await nuxt.ready()
  }
...

メインの部分以外はよくあるコードと同じなので省略します。このコードはtypescript-build/test/module.test.tsを参考に書きました。

Lint について

見落としがちですが、Nuxt での eslint の設定は@nuxtjs/eslint-config、もしくは TS を使う際は@nuxtjs/eslint-config-typescriptを使うと、.eslintrcをスッキリとした形で記述できます。

.eslintrc.js
module.exports = {
  extends: ['@nuxtjs/eslint-config-typescript']
}

ただ、2019 年 11 月に TypeScript の 3.7 が出ましたが、このパッケージのアップデートは TS3.7 への対応が少し遅く、ちょっとだけやきもきしたのを覚えています。

今後 TS 等の追従に伴い@nuxtjs/eslint-config-typescriptのアップデートが待てない方は、中の実装まで一応確認しておいたほうがいいかもしれません。

さいごに

Nuxt のプロジェクトに関わってきて思ったのは、やはり Nuxt は比較的簡単にはじめられる反面、フロントエンドのよくある規則等を把握しておかないとすぐに破綻するリスクもある、ということです。

今後、Vue に関しては Vue のバージョン 3 にてComposition APIが導入されこれが使われるようになると、おそらくより設計力が求められるようになるのではないか、と感じています。

Nuxt のプロジェクトでも Composition API を考慮した実装が追加されていくでしょうから、この辺は意識しておく必要があります。

個人的に Composition API は今ある Vue の問題(主に型周り)を解決しうる素晴らしい仕組みかと思いますが、規約等をきちんと決めて開発を始めないと既存のコードよりも実装のつらみが出てくることが予想されます。

参考:Vue Composition API のコラムっぽいもの集 - mya-ake com

繰り返しになりますが、今後 Nuxt の開発をする方はこの辺の流れの変化にも対応できるように意識しつつ開発を進めていくことをオススメします!


さいごに、この記事が参考になった方はぜひいいねしていただけますと幸いです!

最後まで読んでいただき、ありがとうございました ?

あわせて読みたい:Nuxt.js を用いた新規事業開発を半年以上経験して得た知見 - Qiita

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

【ALB】さくっと5秒で簡単にメンテナンス画面を出す仕組みを作った

解決したい課題

  • 複数の用途の WEB サーバ (EC2) が ELB (ALB) 配下に存在しているが、順番にメンテナンス画面を出すのに時間がかかってしまう (Jenkins などで順番にデプロイ)
    • 複数の環境のソースコード上にあるメンテナンス時間を修正して、都度デプロイしないといけない環境など
  • ALB に存在する リクエストルーティング を使ってメンテナンス画面を出す方法もあるが、1024 文字の制限があるので、最小限の HTML かテキストの出力しかできない

解決案

  • メンテナンス用の S3 + CloudFront + route53 (ここでは maintenance.hapitas.jp と定義) をたてる
    • CloudFront は GET, HEAD, OPTIONS 以上を許可
    • CloudFront は Cache Based on Selected Request HeadersWhitelist とし、Access-Control-Request-Headers Access-Control-Request-Method Origin を Whitelist Headers として登録 (同一ドメイン、スキームでアクセスするなら不要)
    • S3 上では CORS 制限をはずす
      • バケットの [Permissions] 設定の [CORS configuration] で下記の内容を登録 (同一ドメイン、スキームでアクセスするなら不要)
<?xml version="1.0" encoding="UTF-8"?>
<CORSConfiguration xmlns="http://s3.amazonaws.com/doc/2006-03-01/">
<CORSRule>
    <AllowedOrigin>*</AllowedOrigin>
    <AllowedMethod>GET</AllowedMethod>
    <MaxAgeSeconds>3000</MaxAgeSeconds>
    <AllowedHeader>Authorization</AllowedHeader>
</CORSRule>
</CORSConfiguration>
  • S3 バケットに maintenance.json ファイルを以下の内容で配置

{
  "start_date": "Fri, 13 Dec 2019 21:00:00 +0900",
  "end_date": "Fri, 13 Dec 2019 22:00:00 +0900"
}
  • S3 バケットにこだわったデザインの HTML (mainte.html) を配置 (適当ですがw)
<!DOCTYPE html>
<html lang="ja">
<head>
    <meta charset="utf-8">
    <meta name="viewport" content="width=device-width,initial-scale=1,minimum-scale=1,maximum-scale=1,user-scalable=no,shrink-to-fit=no">
    <title>こだわったメンテ画面</title>
</head>
<body>
<div>
    <h1>ただいまメンテナンス中です</h1>
    <div id="mainte-period"></div>
    <p>ご不便をおかけしてしまい申し訳ございませんが、<br>
        メンテナンス完了までしばらくお待ちください。</p>
</div>
<script id="template" type="text/x-jquery-tmpl">
<div id="mainte-period">
    <p>実施期間: ${start}  ${end}<br>
    期間中はすべてのサービスがご利用いただけません。</p>
</div>
</script>
<script src="https://code.jquery.com/jquery-latest.min.js"></script>
<script src="https://ajax.microsoft.com/ajax/jquery.templates/beta1/jquery.tmpl.min.js"></script>
<script>
"use strict";
$(function(){
    var maintenance = {
        template: $('#template'),
        targetHtml: $('#mainte-period'),
        init: function() {
            this.getJson().done(function(data) {
                var start = false;
                var end = false;
                if (typeof data.start_date == 'string' || typeof data.end_date == 'string') {
                    if (maintenance.isMainteNow(data.start_date, data.end_date)) {
                        start = maintenance.generateFormat(data.start_date);
                        end = maintenance.generateFormat(data.end_date);
                    }
                }
                if (!start || !end) {
                    return false; // do nothing.
                }
                maintenance.targetHtml.replaceWith(maintenance.template.tmpl({"start": start, "end": end}));
            });
        },
        isMainteNow: function(start, end) {
            if (!start || !end) {
                return false;
            }
            var now = new Date();
            var start = new Date(start);
            var end = new Date(end);
            return start.getTime() <= now.getTime() && now.getTime() <= end.getTime();
        },
        generateFormat: function(string) {
            var dateObject = new Date(string);
            var year = dateObject.getFullYear();
            var month = dateObject.getMonth() + 1;
            var date = dateObject.getDate();
            var hours = dateObject.getHours();
            var minutes = dateObject.getMinutes();
            var week = [ "", "", "", "", "", "", "" ][dateObject.getDay()];
            return year + '' + month + '' + date + '' + ' (' + week + ') ' + ('0' + hours).slice(-2) + ':' + ('0' + minutes).slice(-2);
        },
        getJson: function() {
            var deferred = $.Deferred();
            var date = new Date();
            $.ajax({
                type: 'GET',
                url: 'https://maintenance.hapitas.jp/maintenance.json?' + date.getTime(),
                dataType: 'json',
                success: deferred.resolve,
                error: function(xhr, textStatus, errorThrown) {
                    console.log(xhr, textStatus, errorThrown);
                    deferred.reject(xhr, textStatus, errorThrown);
                }
            });
            return deferred.promise();
        }
    };
    maintenance.init();
});
</script>
</body>
</html>
  • ALB の リクエストルーティングの優先度は default action の手前の優先度で ルールを追加 (※ default action より優先度は下げられないため、手前に配置する。ただし、非メンテ時は default action に重要なアクションがあると困る → derault action と同じ内容のルールを Source IP 0.0.0.0/0 でメンテナンスのルールより手前に追加しておく)
    • IF に Source IP を 0.0.0.0/0 とする
    • THEN に Return fixed response をいれ Response code: 503, Content-Type: test/html, Response body に以下をいれる (なんと 301 文字)
<!DOCTYPE html><html lang="ja"><head><meta charset="utf-8"></head><body><script onload="$.ajax({type: 'GET',url: 'https://maintenance.hapitas.jp/mainte.html',dataType: 'html',success: function(data) {document.write(data);}});" src="https://code.jquery.com/jquery-latest.min.js"></script></body></html>

これで準備は完了!
メンテ時は json のメンテ開始日、終了日をいれ、上記レスポンスの優先度を一番上に上げるだけ
緊急メンテで、開始日、終了日が決まっていない場合は、 json の更新はせず、優先度を上げればOK

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

【ALB】さくっと5秒でメンテナンス画面を出す仕組みを作った

解決したい課題

  • 複数の用途の WEB サーバ (EC2) が ELB (ALB) 配下に存在しているが、順番にメンテナンス画面を出すのに時間がかかってしまう (Jenkins などで順番にデプロイ)
    • 複数の環境のソースコード上にあるメンテナンス時間を修正して、都度デプロイしないといけない環境など
  • ALB に存在する リクエストルーティング を使ってメンテナンス画面を出す方法もあるが、1024 文字の制限があるので、最小限の HTML かテキストの出力しかできない

解決案

  • メンテナンス用の S3 + CloudFront + route53 (ここでは maintenance.hapitas.jp と定義) をたてる
    • CloudFront は GET, HEAD, OPTIONS 以上を許可
    • CloudFront は Cache Based on Selected Request HeadersWhitelist とし、Access-Control-Request-Headers Access-Control-Request-Method Origin を Whitelist Headers として登録 (同一ドメイン、スキームでアクセスするなら不要)
    • S3 上では CORS 制限をはずす
      • バケットの [Permissions] 設定の [CORS configuration] で下記の内容を登録 (同一ドメイン、スキームでアクセスするなら不要)
<?xml version="1.0" encoding="UTF-8"?>
<CORSConfiguration xmlns="http://s3.amazonaws.com/doc/2006-03-01/">
<CORSRule>
    <AllowedOrigin>*</AllowedOrigin>
    <AllowedMethod>GET</AllowedMethod>
    <MaxAgeSeconds>3000</MaxAgeSeconds>
    <AllowedHeader>Authorization</AllowedHeader>
</CORSRule>
</CORSConfiguration>
  • S3 バケットに maintenance.json ファイルを以下の内容で配置

{
  "start_date": "Fri, 13 Dec 2019 21:00:00 +0900",
  "end_date": "Fri, 13 Dec 2019 22:00:00 +0900"
}
  • S3 バケットにこだわったデザインの HTML (mainte.html) を配置 (適当ですがw)
<!DOCTYPE html>
<html lang="ja">
<head>
    <meta charset="utf-8">
    <meta name="viewport" content="width=device-width,initial-scale=1,minimum-scale=1,maximum-scale=1,user-scalable=no,shrink-to-fit=no">
    <title>こだわったメンテ画面</title>
</head>
<body>
<div>
    <h1>ただいまメンテナンス中です</h1>
    <div id="mainte-period"></div>
    <p>ご不便をおかけしてしまい申し訳ございませんが、<br>
        メンテナンス完了までしばらくお待ちください。</p>
</div>
<script id="template" type="text/x-jquery-tmpl">
<div id="mainte-period">
    <p>実施期間: ${start}  ${end}<br>
    期間中はすべてのサービスがご利用いただけません。</p>
</div>
</script>
<script src="https://code.jquery.com/jquery-latest.min.js"></script>
<script src="https://ajax.microsoft.com/ajax/jquery.templates/beta1/jquery.tmpl.min.js"></script>
<script>
"use strict";
$(function(){
    var maintenance = {
        template: $('#template'),
        targetHtml: $('#mainte-period'),
        init: function() {
            this.getJson().done(function(data) {
                var start = false;
                var end = false;
                if (typeof data.start_date == 'string' || typeof data.end_date == 'string') {
                    if (maintenance.isMainteNow(data.start_date, data.end_date)) {
                        start = maintenance.generateFormat(data.start_date);
                        end = maintenance.generateFormat(data.end_date);
                    }
                }
                if (!start || !end) {
                    return false; // do nothing.
                }
                maintenance.targetHtml.replaceWith(maintenance.template.tmpl({"start": start, "end": end}));
            });
        },
        isMainteNow: function(start, end) {
            if (!start || !end) {
                return false;
            }
            var now = new Date();
            var start = new Date(start);
            var end = new Date(end);
            return start.getTime() <= now.getTime() && now.getTime() <= end.getTime();
        },
        generateFormat: function(string) {
            var dateObject = new Date(string);
            var year = dateObject.getFullYear();
            var month = dateObject.getMonth() + 1;
            var date = dateObject.getDate();
            var hours = dateObject.getHours();
            var minutes = dateObject.getMinutes();
            var week = [ "", "", "", "", "", "", "" ][dateObject.getDay()];
            return year + '' + month + '' + date + '' + ' (' + week + ') ' + ('0' + hours).slice(-2) + ':' + ('0' + minutes).slice(-2);
        },
        getJson: function() {
            var deferred = $.Deferred();
            var date = new Date();
            $.ajax({
                type: 'GET',
                url: 'https://maintenance.hapitas.jp/maintenance.json?' + date.getTime(),
                dataType: 'json',
                success: deferred.resolve,
                error: function(xhr, textStatus, errorThrown) {
                    console.log(xhr, textStatus, errorThrown);
                    deferred.reject(xhr, textStatus, errorThrown);
                }
            });
            return deferred.promise();
        }
    };
    maintenance.init();
});
</script>
</body>
</html>
  • ALB の リクエストルーティングの優先度は default action の手前の優先度で ルールを追加 (※ default action より優先度は下げられないため、手前に配置する。ただし、非メンテ時は default action に重要なアクションがあると困る → derault action と同じ内容のルールを Source IP 0.0.0.0/0 でメンテナンスのルールより手前に追加しておく)
    • IF に Source IP を 0.0.0.0/0 とする
    • THEN に Return fixed response をいれ Response code: 503, Content-Type: test/html, Response body に以下をいれる (なんと 301 文字)
<!DOCTYPE html><html lang="ja"><head><meta charset="utf-8"></head><body><script onload="$.ajax({type: 'GET',url: 'https://maintenance.hapitas.jp/mainte.html',dataType: 'html',success: function(data) {document.write(data);}});" src="https://code.jquery.com/jquery-latest.min.js"></script></body></html>

これで準備は完了!
メンテ時は json のメンテ開始日、終了日をいれ、上記レスポンスの優先度を一番上に上げるだけ
緊急メンテで、開始日、終了日が決まっていない場合は、 json の更新はせず、優先度を上げればOK

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

Twitter のツリーのテキストを Chrome のコンソールで取得しよう

スクリーンショット 2019-12-13 20.49.22.png

Twitter の連続ツイートのテキストをプレーンなテキストでメモしたくなった時に、コピーが難しかったので作りました。

⚠注意点⚠

法と良識の範囲でのご利用をお願いします。

スクリプト全体

{
  const targetDom = document.querySelectorAll('div[lang="ja"][dir="auto"]');

  const checkLF = () => {
    if(navigator.platform.indexOf("Win") != -1) {
      return "\r\n";
    }else{
      return "\n";
    }
  };

  const logText = () => {
    if (!targetDom) {
      alert(`ツイートの取得に失敗しました`);
      return false;
    }
    const maxTweets = 30;
    const resultAlertText = `処理が完了しました。ログをご確認ください。`;
    let resultText;
    let lineFeedCode = checkLF();

    for (let i = 0; i < targetDom.length; i++) {
      const tmpText = targetDom[i].textContent;
      resultText = resultText + lineFeedCode + tmpText;
      if(maxTweets < i){
        resultAlertText = `処理が完了しました。ログをご確認ください。${maxTweets}ぐらい以降のツイートは取得していません。`;
        break;
      }
    }

    console.log(resultText);
    alert(resultAlertText);
  };

  logText();
}

使い方

  1. Twitter の Web 版の画面を Chrome で開いてください。
  2. console にコード全体をコピペして Enter キーを押してください。
  3. コンソールにテキストが表示されます。適当にコピーしてご利用ください。

Q & A

連続ツイートのテキストの一部しか取得できない。

環境によって違うようですが、一度に取得できるツイート数に上限があるようです。
適当に表示箇所を変更して再度お試しください。

日本語以外取得できない。Twitter にログインしていないと取得できない。

以下の部分を適切なセレクタに変更してご利用ください。

  const targetDom = document.querySelectorAll('div[lang="ja"][dir="auto"]');

UI は作らないのか?

あまり簡単に使えるようにすると、いろいろと問題がありそうなので、あえて不便にしています。

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

Riot.js v4 カスタムテンプレートを実装する

Riot.js Advent Calendar 2019 の13日目が空いていたので埋めます。

はじめに

前回のRiot.js v4 HTMLのテンプレートエンジンにPugを使うで、@riotjs/compilerregisterPreprocessorで自由にテンプレートを登録できるということがわかりました。

これを検証してみようと思います。

動きを確認

まずはデフォルトのテンプレートエンジンの動きがどうなっているかを確認。
https://github.com/riot/compiler/blob/master/src/preprocessors.js

/riot/compiler/blob/master/src/preprocessors.js
export const preprocessors = Object.freeze({
  javascript: new Map(),
  css: new Map(),
  template: new Map().set('default', code => ({ code }))
})

なるほどなるほど、引数で受け取ったcodeを元になんやかんやして、{ code: "最終的なコード" }を返せば良さそうですね。
デフォルトでは何もしないこともわかりました。

動きを確認するために、適当に登録してみます。

webpack.config.js
const path = require('path');
const compiler = require('@riotjs/compiler');

// カスタムテンプレートを登録
compiler.registerPreprocessor('template', 'step_count', (code, options) => {
  console.log("Hello World!!");
  console.log("code:", code);
  console.log("options:", options);
  return {
    code: code
  };
});

module.exports = {
  mode: 'development',
  //mode: 'production',

  entry: './src/scripts/index.js',
  output: {
    path: path.resolve(__dirname, 'app/scripts'),
    filename: 'bundle.js',
    publicPath: '/scripts/',
  },

  devtool: 'inline',
  //devtool: 'source-map',

  module: {
    rules: [
      {
        test: /\.riot$/,
        exclude: /node_modules/,
        use: [{
          loader: '@riotjs/webpack-loader',
          options: {
            hot: true, // set it to true if you are using hmr
            // add here all the other @riotjs/compiler options riot.js.org/compiler
            template: 'step_count'
          }
        }]
      },
      {
        test: /\.js$/,
        exclude: /node_modules/,
        use: {
          loader: 'babel-loader',
          options: {
            presets: ['@babel/preset-env']
          }
        }
      }
    ]
  }
};
package.json
{
  "name": "riotv4-custom-template-sample",
  "version": "1.0.0",
  "description": "",
  "main": "index.js",
  "scripts": {
    "build": "webpack --mode production --devtool source-map",
    "start": "webpack-dev-server --inline --watch --hot --colors --content-base app/ --open-page index.html --historyApiFallback true -d --port 4500"
  },
  "keywords": [],
  "author": "KAJIKEN <kentaro@kajiken.jp> (http://kajiken.jp)",
  "license": "MIT",
  "dependencies": {},
  "devDependencies": {
    "@babel/core": "^7.7.5",
    "@babel/preset-env": "^7.7.6",
    "@riotjs/compiler": "^4.5.3",
    "@riotjs/hot-reload": "^4.0.0",
    "@riotjs/webpack-loader": "^4.0.1",
    "babel-loader": "^8.0.6",
    "riot": "^4.7.1",
    "webpack": "^4.41.2",
    "webpack-cli": "^3.3.10",
    "webpack-dev-server": "^3.9.0"
  }
}

npm run buildでビルド。

ビルド中のログ
> webpack --mode production --devtool source-map

Hello World!!
code: <app>
  <p>{ props.message }</p>
</app>

options: { tagName: null,
  fragments: null,
  options:
   { template: 'step_count',
     file:
      'c:\\nodeapp\\riotv4-custom-template\\src\\scripts\\app.riot',
     scopedCss: true,
     hot: true },
  source: '<app>\r\n  <p>{ props.message }</p>\r\n</app>\r\n' }
Hash: b56824acee93ca59aa1d
Version: webpack 4.41.2
Time: 1403ms

動いていますね!
ファイルパスもoptionsから取れるので、コミットするなどファイルを使って何かすることもできそうです。

ステップカウント

とりあえずステップ数(とは名ばかりの行数)を計測してみます。

webpack.config.js
// カスタムテンプレートを登録
compiler.registerPreprocessor('template', 'step_count', (code, options) => {
  console.log(`*** Step Count *** ${ (new String(options.options.file)).replace(/.*\\/, "") }:${ code.split("\n").length }`);
  return {
    code: code
  };
});
ビルド中のログ
> webpack --mode production --devtool source-map

*** Step Count *** goodbye.riot:6
*** Step Count *** message.riot:22
*** Step Count *** hello.riot:4
*** Step Count *** app.riot:9
Hash: 6dc6caa7dcd7ec4463ef
Version: webpack 4.41.2
Time: 2757ms

モールス信号HTMLテンプレートエンジン

webpack.config.js
const path = require('path');
const compiler = require('@riotjs/compiler');

// モールス信号パターン
const pattern = {
  "・-": "a",
  "-・・・": "b",
  "-・-・": "c",
  "-・・": "d",
  "": "e",
  "・・-・": "f",
  "--・": "g",
  "・・・・": "h",
  "・・": "i",
  "・---": "j",
  "-・-": "k",
  "・-・・": "l",
  "--": "m",
  "-・": "n",
  "---": "o",
  "・--・": "p",
  "--・-": "q",
  "・-・": "r",
  "・・・": "s",
  "": "t",
  "・・-": "u",
  "・・・-": "v",
  "・--": "w",
  "-・・-": "x",
  "-・--": "y",
  "--・・": "z",
  "・----": "1",
  "・・---": "2",
  "・・・--": "3",
  "・・・・-": "4",
  "・・・・・": "5",
  "-・・・・": "6",
  "--・・・": "7",
  "---・・": "8",
  "----・": "9",
  "-----": "0",
  "-・・・・-": "-"
};

// モールス信号解読
const decode = target => target.split(" ").map(word => pattern[word] || word).join("");

// モールス信号テンプレートを登録
compiler.registerPreprocessor('template', 'morse', code => {
  var reg = /(<\/{0,1})(.+?)(>)/g;
  var match;
  var ret = code;
  while ((match = reg.exec(code)) !== null) {
    ret = ret.replace(match[0], `${ match[1] }${ decode(match[2]) }${ match[3] }`);
  }
  console.log(ret);
  return {
    code: ret
  };
});

module.exports = {
  mode: 'development',
  //mode: 'production',

  entry: './src/scripts/index.js',
  output: {
    path: path.resolve(__dirname, 'app/scripts'),
    filename: 'bundle.js',
    publicPath: '/scripts/',
  },

  devtool: 'inline',
  //devtool: 'source-map',

  module: {
    rules: [
      {
        test: /\.riot$/,
        exclude: /node_modules/,
        use: [{
          loader: '@riotjs/webpack-loader',
          options: {
            hot: true, // set it to true if you are using hmr
            // add here all the other @riotjs/compiler options riot.js.org/compiler
            template: 'morse'
          }
        }]
      },
      {
        test: /\.js$/,
        exclude: /node_modules/,
        use: {
          loader: 'babel-loader',
          options: {
            presets: ['@babel/preset-env']
          }
        }
      }
    ]
  }
};
app.riot
<・- ・--・ ・--・>
  <・--・>{ props.message }</・--・>
</・- ・--・ ・--・>
hello.riot
<-- -・-- -・・・・- ・・・・  ・-・・ ・-・・ --->
  <・・・・ ・---->Hello World!!</・・・・ ・---->
</-- -・-- -・・・・- ・・・・  ・-・・ ・-・・ --->
goodbye.riot
<-- -・-- -・・・・- --・ --- --- -・・ -・・・ -・-- >
  <・--・>Goodbye World!!</・--・>
</-- -・-- -・・・・- --・ --- --- -・・ -・・・ -・-- >
message.riot
<-- -・-- -・・・・- --  ・・・ ・・・ ・- --・ >
  <-・・ ・・ ・・・->{ props.message.slice(0, 1).toUpperCase() }{ props.message.slice(1) } World!!</-・・ ・・ ・・・->
</-- -・-- -・・・・- --  ・・・ ・・・ ・- --・ >

ビルド!

ビルド中のログ
> webpack --mode production --devtool source-map

<my-goodbye>
  <p>Goodbye World!!</p>
</my-goodbye>

<my-message>
  <div>{ props.message.slice(0, 1).toUpperCase() }{ props.message.slice(1) } World!!</div>
</my-message>

<my-hello>
  <h1>Hello World!!</h1>
</my-hello>

<app>
  <p>{ props.message }</p>
</app>

Hash: 8b904bfcedfcd87ff364
Version: webpack 4.41.2
Time: 2239ms

モールス信号HTMLテンプレートエンジンここに爆誕!

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

年末まで毎日webサイトを作り続ける大学生 〜56日目 Canvasを使って遊ぶ〜

はじめに

こんにちは!@70days_jsです。

canvas内でボールが動き続けるものを作りました。
MDNにあるゲームを参考にしています。

今日は56日目。(2019/12/13)
よろしくお願いします。

サイトURL

https://sin2cos21.github.io/day56.html

やったこと

こんなことをやりました。(gif)↓
test3.gif

丸いボールがゆっくり動いています。
真ん中にある四角に入ると、ボールの色と大きさが変わり、軌道が消えます。
スペースキーを押すとボールのスピードが上がります。

html↓

  <body>
    <div>
      spaceを押した回数(ペースアップ): <span id="spaceClick">0</span></div>
    <canvas id="myCanvas" width="480" height="320"></canvas>
  </body>

canvasを使っています。

css↓

canvas {
  background: rgba(255, 255, 255, 0.5);
  border: solid 1px black;
}

canvasのレイアウトを変えたぐらいです。

JavaScript↓

var canvas = document.getElementById("myCanvas");
var ctx = canvas.getContext("2d");
var ballRadius = 5; //ボールの大きさ
var x = canvas.width / 2; //開始位置 X
var y = canvas.height / 3; //開始位置 Y
var dx = 1;
var dy = -1;
let color = "red";
let count = 0;
let spaceClick = document.getElementById("spaceClick");
document.addEventListener("keydown", space, false);

function drawBall() {
  ctx.beginPath();
  ctx.arc(x, y, ballRadius, 0, Math.PI * 2);
  ctx.rect(220, 140, 50, 50); //x, y , 幅、高さ

  if (x > 220 && x < 270 && (y > 140 && y < 190)) {
    randomBallSize();
    randomRGB();
    ctx.clearRect(0, 0, canvas.width, canvas.height);
  }
  ctx.fillStyle = color;
  ctx.fill(); //実行
  ctx.closePath();
}

function draw() {
  if (x + dx > canvas.width - ballRadius || x + dx < ballRadius) {
    dx = -dx;
  }
  if (y + dy > canvas.height - ballRadius || y + dy < ballRadius) {
    dy = -dy;
  }
  drawBall();
  x += dx;
  y += dy;
  requestAnimationFrame(draw);
}

//setInterval(draw, 10);

function space(e) {
  count++;
  spaceClick.innerHTML = count;
  if (e.keyCode == 32) {
    let fugou = Math.sign(dx);
    if (fugou === 1) {
      dx++;
    } else {
      dx--;
    }
    let fugou2 = Math.sign(dy);
    if (fugou2 === 1) {
      dy++;
    } else {
      dy--;
    }
    console.log("fugou: " + fugou);
  }
}

function randomRGB() {
  let r = Math.floor(Math.random() * 256);
  let g = Math.floor(Math.random() * 256);
  let b = Math.floor(Math.random() * 256);
  let rgb = "rgba(" + r + ", " + g + "," + b + ", .5)";
  color = rgb;
}

function randomBallSize() {
  let ball = Math.floor(Math.random() * 8);
  ballRadius = ball + 3;
}

draw();

drawBall()関数でボールを描いています。
中にあるif文は

if (x > 220 && x < 270 && (y > 140 && y < 190)) {

真ん中の四角の位置です。
この中に入ると

randomBallSize();
randomRGB();
ctx.clearRect(0, 0, canvas.width, canvas.height);

ボールのサイズを変更、色を変更、軌道を消します。

スペースキーの方はイベントリスナーで実装しています。

document.addEventListener("keydown", space, false);

変数dxとdyは速さを調整する変数で、これら絶対値を上げていくことでボールの速度を速くしています。

最後に、

requestAnimationFrame(draw);

この関数↑でアニメーションのフレームレートを自動で判断しています。
setInterval()よりこっちの方が効率よくていいですね。

感想

やっぱりゲームみたいな描画系はcanvasを使うべきなんだなと思いました。
canvasだとチャートとか、他にも色々作れそうなので勉強してみようかな。。

最後まで読んでいただきありがとうございます。明日も投稿しますのでよろしくお願いします。

参考

1.仕上げ - ゲーム開発 | MDN

MDNさん今回もお世話になりました!ありがとうございます!

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

Cesium と 3D Tiles について(2)

はじめに

昨年度の FOSS4G Advent Calendar 2018 22日目で、Cesium と 3D Tiles について紹介しました。また、社内のテックリード会でも同様の内容で紹介しましたので、紹介できる内容に絞って、情報を展開したいと思います。地味にいいね!があるので参考になれば幸いです。

Cesium について

Cesium は、3D のビューワーエンジンで、Cesium js と Cesium ion で構成されています。

  • Cesium JS
    • Web 上の 3D マップ用の JavaScript ライブラリ
  • Cesium ion
    • ストリーミング用に3D データをタイリングし、配信するためのサービス

image.png
3D データは Cesium ion にてストリーミング用に最適化され、Cesium JS で可視化されます。

3D データについて

3D Viewer の場合は、3D データの扱いが課題になる場合があるかと思います。
例えばドローンで撮影した点群データなどのデータを表示したい。データが大きい場合、ブラウザで表示する場合はパフォーマンスなども影響するといった問題があるかと思います。

それらの課題を解決する手法として、3D データの扱いとして、Cesium では 3D Tiles、imagery、Terrain という技術を採用しています。

具体的には、以下のファイルを 3D Tiles としてサポートしています。

  • glTF (.gltf, .glb)
  • CityGML (.citygml, .xml, .gml)
  • KML/COLLADA (.kml, .kmz)
  • LASer (.las, .laz)
  • COLLADA (.dae)
  • Wavefront OBJ (.obj)

1. Cesium がサポートしているファイルと描画機構
image.png
2. Cesium がサポートしているサービス群
image.png

3D Tiles について

  • 3D Tiles は、写真測量、3D Buildings、BIM/CAD、Point Clouds などの 3D 地理空間コンテンツをストリーミングおよびレンダリングするために設計されており、空間データの構造とタイルフォーマットを定義しています。

    • https://github.com/AnalyticalGraphicsInc/3d-tiles
    • OGC (Open Geospatial Consortium) にも採用
    • OGC は、500以上の機関で、地理空間のコンテンツとサービス、センサーWebとIoT、GISデータ処理、データ共有に関する標準規格の開発と実装を行っています。
  • 3D の地理空間データとして、地形データ、建物データ、CAD (または BIM) モデル、写真測量のモデル、ポイントクラウドなどのデータを 3D Tiles に変換します。

    • 変換された 3D Tiles は、軽量でかつ最適化されているため、サイズが大きい 3D データでもストレスなく Web で配信することが可能
    • 例えば、4.48 GB の LAS ファイルは、0.64 GB まで圧縮されると公式のサイトでも紹介

3D Tiles の仕様

  • 3D Tile は、空間データの構造を持ったバイナリデータと JSON ファイルの2つのタイルセットで構成されています。
  • タイルセットでは、メタデータとタイル オブジェクトの情報をタイルセットとして、JSON ファイルに記述されています。
  • 次のいずれかの形式でレンダリングが可能なコンテンツとして配信が可能
フォーマット 用途
Batched 3D Model (b3dm) Heterogeneous 3D models.
テクスチャ化された地形や表面、3D 建物の外部と内、大規模なモデルなど
Instanced 3D Model (i3dm) 3D model instances.
木、風車、ボルト
Point Cloud (pnts) 大量の点群データ
Composite (cmpt) 異なる形式のタイルを1つのタイルに連結
  • 個々のタイルは、Feature Table や Batch Table などの形式固有のコンポーネントを持つ binary blob.
  • コンテンツは、建物や樹木を表す 3Dモデル、点群内のポイントなど、一連の機能を参照。各フィーチャには、タイルの Feature Table に格納している位置と外観のプロパティがあり、Batch Tableに保存されているアプリケーション固有のプロパティがある。
  • クライアントは、実行時に各機能を選択し、可視化または分析のためにプロパティを取得することができる。
  • Batched 3D Model および Instanced 3D Model 形式は、3D コンテンツの効率的な伝送のために設計されたオープンな仕様である glTF に基づいて構築されている。
  • これらの形式のタイルコンテンツは、バイナリボディにジオメトリとテクスチャ情報を含む glTF が埋め込まれている。

3D Tiles の仕様:https://github.com/AnalyticalGraphicsInc/3d-tiles/tree/master/specification

glTF について
https://www.khronos.org/gltf/

- - -
3Dモデル対応 Khronos Group による Web 上の3D モデルの業界標準フォーマットである glTF に対応 glTF (The GL Transmission Format)とは、OpenGL系のAPI (WebGL, OpenGL ES, OpenGL) と親和性の高い、ランタイム用途向けのアセットフォーマット
※glTFファイル(.gltf)、バイナリのglTFファイル(.glb)の直接参照が可能

3D Tiles で使用されている技術

  • 3D コンテンツを表現するフォーマットとして、glTF (GL Transmission Format)、また、Google が開発した Draco というオープンソースの 3D データ圧縮ライブラリを使用。
  • Cesium では、Draco を glTF に拡張して、Draco 圧縮モデルと 3D タイルセットを Cesium で扱うことが可能 image.png
    以下は Cesium のブログでも紹介されていましたが、Draco 圧縮を使用したことでレンダリングが早くなっていることが分かるかと思います。
glTF 2.0 (gzipped)  Draco 圧縮を使用した glTF 2.0 (gzipped)
タイルサイズ:738 MB  タイルサイズ:149 MB
ロード時間:18.921秒  ロード時間:10.548秒

画像はデモ用のため 2 倍速になっています。

3D Tiles の表示例

  • 3D Tiles のデータセット(点群データ)
    • pointCloudDraco.pnts
    • tileset.json

Cesiumでの 3D Tile のロードは、次のようになります。

var viewer = new Cesium.Viewer('cesiumContainer’); 

var tileset = viewer.scene.primitives.add(new Cesium.Cesium3DTileset({ 
    url : 'http://localhost:8003/tilesets/pointCloud/tileset.json’ 
      })); 

viewer.zoomTo(tileset, new Cesium.HeadingPitchRange(0, -0.5, 0));

CESIUM ion について

こちらの 3D Tiles のサービスを使用する場合は、CESIUM ion という Cesium が提供している 3D プラットフォームに登録して使用することができます。また、オンプレスでこれらのサービスも使用することができますが、Cesium とのビジネス上の契約が必要になるそうです。
Cesium ion の構成を以下に示します。

image.png

  • asset.cesium.com
    • ユーザーがアップロードしたデータを保存し、3D タイルセット、画像、地形、glTF、KML、および CZML(これらをまとめてアセットと呼びます)を提供する。
  • api.cesium.com 
    • ion REST API。アカウントやアセット管理に使用される。3D Tiling を開始し、ジオコーディング、サーバーベースの分析、およびユーザーが 3Dマッピングアプリケーションを作成するために必要ものを提供する。
  • cesium.com
    • 3Dコンテンツのアップロードと管理、Cesium World Terrain などのコンテンツへのアクセス、および(最終的に)インタラクティブな地図作成と地理空間分析を実行するためのシングルページアプリケーション(SPA)

CESIUM ion の詳細については、Cesium のブログでも紹介されていますのでご参照下さい。

最後に

Cesium では、3D 地理空間コンテンツの仕様として、3D Tiles が採用されていますが、Esri では、i3s という仕様があります。こちらも 3D Tiles と同様に 3D コンテンツに特化しており、OGC にも採用されています。私が知る限り、両者を明確に比べたものは今のところないように思えます。
今後 3D コンテンツの扱いがますます重要になってくると思いますので、このあたりの技術に関してはキャッチアップしていく必要があると感じています。

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

スクレイピングする際の自分なりのベストプラクティス

これはPython Advent Calendar 2019の13日目の記事です。

Python要素薄めになっちゃったけど、スクレイピングの文脈でPythonよく出てくる気がするから許してください

概要

  • (自分なりの)スクレイピングシステム構築方法のベストプラクティス的なものがちょっとだけ定まった
  • 公開していろいろ意見を聞いてみたい
  • たぶんウェブデベロッパーがスクレピングやるときに知っておくとちょっとだけ幸せになるかもしれない
  • 誰かの役に立てればいいなあ(希望)

構築方法の重要な点

重要な点は以下の2点

  1. DOMにアクセスしてデータを取得するスクレイピングする処理部分はJavaScriptを使う(とくにウェブデベロッパーの方は)
  2. Chrome, ChromeDrive, Seleniumとかのスクレピング環境にはDockerとかを使う

構成図は以下のような感じです。(OSとか書いてないけど、だいたいのイメージ)
Pythonの部分は任意でSeleniumを叩ければなんでもいいです。(以下のPythonはSeleniumを使うモノとして読み替えてください)

Group 5.png

JavaScriptを使ってスクレイピング処理

スクレピング関係のシステムを作るときにめんどくさいのはライブラリ選定だったりします。

個人的にPythonは好きだったりするので、昔はPyQueryとかBeautifulSoupを触っていました。

しかし、結構かなり面倒くさいです。

どういう風にセレクタを書けばいいのかググって実行して、ミスって、修正して、...みたいな感じなります。

最終的な結論が「Chrome Developer ToolsでJSのセレクタを書いて、欲しい情報を取得するJSを書いて、それをSeleniumから実行させる」です。(これが一番楽だと思います。)

(おそらくjQueryを触ったことがある人なら一番とっつきやすいはず)

以下のようなかんじ。

res = driver.execute_script(driver, """
    return (() => {
        // 以下をChrome Developmer Toolsでいろいろ試して作成する
        let tmp = [];
        $(".sample-area li a").each( (idx, element) => tmp.push($(element).attr("href")) )
        return tmp
    })()
""")

Chrome Developmer Toolsは以下のようなやつ。F12とか押したらでます。(Consoleタブを開くとJSがページ内で実行できます)
簡単な話、クラス情報とか丸コピして$(".class").text()とかやるだけでデータとってこれたりします。

Screen Shot 2019-12-13 at 18.39.30.png

これをライブラリの特有の関数でいろいろやろうと思うとめんどくさいのはなんとなくわかるとおもいます。

また、スクリプトがJSなのでスクレイピングライブラリに依存せずに、移植も楽です。

Chrome, ChromeDrive, Seleniumとかのスクレピング環境にはDockerとかを使う

ChromeDriverの設定とかChromeとのバージョンの相性とかいろいろやるのは人生の無駄遣いです。
Dockerとか環境をまるっともってこれるものを使いましょう。

単純なHTML,CSSのみで構成されているサイトならcurlとかで引っ張ってきてやるのもいいと思います。(単純なウェブサイトなら←ここ重要)
サーバーサイドでレンダリングされていても、内部のJSでわりとなんかゴリゴリいじってるサイトは多いです。

Python + Chrome + ChromeDrive + Seleniumの構成のサンプルを作ってみたのでスターをつけてもらうと喜びます。↓
https://github.com/redshoga/python-selenium-container

実装の流れ

  1. スクレピングで取得したいデータ、場所を明確にする。
  2. Chrome Developer Toolsでその取得したいデータが取得できるスクリプトを作成。
  3. Seleniumで作ったスクリプトを動かしてデータを煮るなり焼くなりする。

おわり

マサカリダニゲロー>???  ??

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

そういえば、JavaScriptでリアルタイム通信のゲームってどうやって作るのってなったとき。その2

この記事の続きになります

そういえば、JavaScriptでリアルタイム通信のゲームってどうやって作るのってなったとき。

概要

JavaScriptでリアルタイム通信するゲームを作る

JavaScriptはよく触ってるけど、そういえばリアルタイムの通信/対戦ゲームってどうやって作るのってなった時に参考になるかもしれないです。
PlayCanvasでマルチ対戦のゲームを作るために今回はPhotonというネットワークエンジンを使用して作成します。役割としては描画の部分はPlayCanvas、リアルタイム通信の部分についてはPhotonを使用して作っていきます。

今回デモとして作成したこちらのゲームは[WASD]で移動ができるシンプルなゲームとなっております。
2窓などをしていただければ一人でも遊べます。

PlayCanvasについて

PlayCanvasはJavaScriptで記述されたWebGLのエンジンで、最近GitHubのスター数が5000を超えました。
OSSのEngineと、SaaS型のエディターを兼ね備えているゲームエンジンです。engineについては、Three.js, Babylon.jsなどに似た使い方を使用することができます。エディターはビジュアルエディターとコードエディターがあり、エディターはクラウド上でプロジェクトの作成から公開までが出来るもので、FBXOBJといった3Dモデルの素材については、ブラウザへドラッグアンドドロップをすることでGUIから操作・配置ができるエディターとなっております。

Photonについて

Photonについては、Unity多く使われているようですが、JavaScriptのSDKもありますので、Photon JavaScript SDKを使用してマルチ対戦のゲームを作っていきます。

Photonは20CCU(同時接続)までであれば、無料で使用できます。

https://www.photonengine.com/ja/photon

PlayCanvasでリアルタイム通信をするをプロジェクト をこのような形で作ってみました。

init.js

起動Photonの起動を行っています。index.html, style.cssを読み込みUIのセットアップも行います。

app.js

app.jsにてPhotonとPlayCanvasの同期をしています。Photonには様々なライフサイクルで発行されるイベントがありますが、今回使用したものについて説明します。

/*jshint esversion: 6, asi: true, laxbreak: true*/
const App = pc.createScript('app');

App.prototype.initialize = function() {
    this.photon = this.app.root.children[0].photon;
    this.photon.setLogLevel(999);
    this.photon.onJoinRoom = () => {
        Object.values(this.photon.actors).map(event => {
            const { isLocal, actorNr } = event;
            if (isLocal) {
            } else {
                const entity = new pc.Entity();
                entity.addComponent("model", {
                    type: "box"
                });
                entity.setPosition(0, 1, 0);
                entity.tags.add(`${actorNr}`);
                this.app.root.addChild(entity);
            }
        });
    };

    this.photon.onActorJoin = event => {
        const { isLocal, actorNr } = event;
        if (isLocal) {
        } else {
            const entity = new pc.Entity();
            entity.addComponent("model", {
                type: "box"
            });
            entity.setPosition(0, 1, 0);
            entity.tags.add(`${actorNr}`);
            this.app.root.addChild(entity);
        }
    };

    this.photon.onEvent = (code, content, actorNr) => {
        const entities = this.app.root.findByTag(`${actorNr}`);
        const [entity] = entities;

        switch (code) {
            case 1: {
                const { x, y, z } = content;
                entity.setLocalPosition(x, y, z);
                break;
            }
            case 2: {
                const { x, y, z, w } = content;
                entity.setLocalRotation(x, y, z, w);
                break;
            }
            default: {
                break;
            }
        }
    };
};
photon.onJoinRoom

onJoinRoomはルームに入った際の処理になります。今回のような作りでは呼ばれるタイミング
- ルームを新しく作ったとき
- 既存のルームに入る時

  1. ルームに入った時
  2. 他のプレイヤーを取得
  3. 新しいエンティティ("Model:Box")としてゲーム画面に配置
// 1.ルームに入った時
this.photon.onJoinRoom = () => {
    //2. 他のプレイヤーを取得
    Object.values(this.photon.actors).map(event => {
        const { isLocal, actorNr } = event;
        if (isLocal) {
        } else {
            // 新しいエンティティを作成
            const entity = new pc.Entity();
            entity.addComponent("model", {
                type: "box"
            });
            // 新しく生成したエンティティのポジションの設定とタグを付与している
            entity.setPosition(0, 1, 0); // x, y, z
            entity.tags.add(`${actorNr}`);
            // PlayCanvasの画面上に配置する
            //3. 新しいエンティティ("Model:Box")としてゲーム画面に配置
            this.app.root.addChild(entity);
        }
    });
};
photon.onActorJoin

onActorJoinは自分の入っているルームに他のプレイヤーが参加してきたときの処理になります。

  1. 他のプレイヤーが入ってきた時
  2. 新しいエンティティを作成
  3. 新しいエンティティ("Model:Box")としてゲーム画面に配置
// 1.他のプレイヤーが入ってきた時
this.photon.onActorJoin = event => {
    // isLocal: 自分であるかの判定
    // actorNr: 入った順に1から振られる、ユニークな番号
    const { isLocal, actorNr } = event;
    // 自分自身であるかの判定
    if (isLocal) {
    } else {
        //  2. 新しいエンティティを作成
        const entity = new pc.Entity();
        entity.addComponent("model", {
            type: "box"
        });
        // ポジションとタグを設定
        entity.setPosition(0, 1, 0);
        entity.tags.add(`${actorNr}`);
        // 3. 新しいエンティティ("Model:Box")としてゲーム画面に配置
        this.app.root.addChild(entity);
    }
};

photon.onEvent

onEventは、今回はplayer.jsにより、データが送信された場合に呼び出されます。

  1. actorNrのタグを元にエンティティを検索
  2. CodeによってPositionRotationかの判定を行う
this.photon.onEvent = (code, content, actorNr) => {
    // 1. `actorNr`のタグを元に`エンティティ`を検索
    const entities = this.app.root.findByTag(`${actorNr}`);
    const [entity] = entities;

    //2. `Code`によって`Position`か`Rotation`かの判定を行う
    switch (code) {
        case 1: {
            const { x, y, z } = content;
            entity.setLocalPosition(x, y, z);
            break;
        }
        case 2: {
            const { x, y, z, w } = content;
            entity.setLocalRotation(x, y, z, w);
            break;
        }
        default: {
            break;
        }
    }
};

player.js

Player.jsはキーボードの操作が押された際にPhotonサーバーにデータを送るものになります。

/*jshint esversion: 6, asi: true, laxbreak: true*/
const Player = pc.createScript("player");

// PlayCanvas Editor上で使用するためにAttributesを作成
// Attributesの説明
// https://developer.playcanvas.com/ja/user-manual/scripting/script-attributes/

Player.attributes.add("moveSpeed", { type: "number", default: 0.1 });
Player.attributes.add("rotateSpeed", { type: "number", default: 2 });

const move = (direction, entity, self) => {
    const { photon } = self;
    switch (direction) {
        case "up": {
            entity.translateLocal(0, 0, -self.moveSpeed);
            break;
        }
        case "down": {
            entity.translateLocal(0, 0, self.moveSpeed);
            break;
        }
        default: {
            break;
        }
    }
    // send position
    // コード番号 1で現在のEntityのポジションを送信
    photon.raiseEvent(1, entity.getLocalPosition());
};

const rotate = (direction, entity, self) => {
    const { photon } = self;

    switch (direction) {
        case "left": {
            entity.rotate(0, -self.rotateSpeed, 0);
            break;
        }
        case "right": {
            entity.rotate(0, self.rotateSpeed, 0);
            break;
        }
        default: {
            break;
        }
    }
    // send rotation
    // コード番号 2で現在のEntityのポジションを送信
    photon.raiseEvent(2, entity.getLocalRotation());
};

Player.prototype.initialize = function () {
    this.photon = this.app.root.children[0].photon;
    if (this.app.touch) {
        this.app.touch.on(
            pc.EVENT_TOUCHSTART,
            () => {
                const { photon } = this
                const { x, y, z } = this.entity.getPosition();
                this.entity.rigidbody.teleport(x, y + 0.5, z);
                photon.raiseEvent(1, this.entity.getLocalPosition());

                photon.raiseEvent(2, this.entity.getLocalRotation());


            },
            null
        );
    }
};


// update code called every frame
Player.prototype.update = function (dt) {
    const { keyboard } = this.app;
    // 移動のキーが押されていたら
    if (keyboard.isPressed(pc.KEY_W) || keyboard.isPressed(pc.KEY_UP)) {
        move("up", this.entity, this);
    } else if (keyboard.isPressed(pc.KEY_S) || keyboard.isPressed(pc.KEY_DOWN)) {
        move("down", this.entity, this);
    }

    // 回転のキーが押されたら
    if (keyboard.isPressed(pc.KEY_A) || keyboard.isPressed(pc.KEY_LEFT)) {
        rotate("right", this.entity, this);
    } else if (keyboard.isPressed(pc.KEY_D) || keyboard.isPressed(pc.KEY_RIGHT)) {
        rotate("left", this.entity, this);
    }
};

photon.raiseEvent

RPCs and RaiseEvent

raiseEventを使用して任意のタイミングでデータの送信を行います。今回は移動が行われた時と回転が行われた際に現在の場所を送信しています。

raiseEvent(eventCode, data, options)

型はそれぞれ

 eventCode:number
 object: object
 options: object
移動時
    ....
    // send position
    // コード番号 1で現在のEntityのポジションを送信
    photon.raiseEvent(1, entity.getLocalPosition());
};

回転時
    ...
    // send rotation
    // コード番号 2で現在のEntityのポジションを送信
    photon.raiseEvent(2, entity.getLocalRotation());
};
参考

raiseEvent - Photon Document

initialize

PlayCanvasのスクリプトのライフサイクルには、スクリプト一度だけ呼び出されるinitialize、毎フレーム呼び出されるUpdateがあります。

  1. Photonのプロパティを代入
  2. タッチされた際に発火するイベントの定義
Player.prototype.initialize = function () {
    // Rootエンティティからphotonプロパティを取得
    // 1. Photonのプロパティを代入
    this.photon = this.app.root.children[0].photon;

    // タッチ操作が可能だったら
    if (this.app.touch) {

    // タッチ操作が行われたら
    // PlayCanvas: タッチについて
    // https://support.playcanvas.jp/hc/ja/articles/227190908

    // 2. タッチされた際に発火するイベントの定義
    // PlayCanvas: イベント種類について
    // https://developer.playcanvas.com/ja/user-manual/scripting/communication/
        this.app.touch.on(
            pc.EVENT_TOUCHSTART,
            () => {
                const { photon } = this
                const { x, y, z } = this.entity.getPosition();
                this.entity.rigidbody.teleport(x, y + 0.5, z);
                // コード1で現在のポジションを送信
                photon.raiseEvent(1, this.entity.getLocalPosition());
                // コード2で現在の回転を送信
                photon.raiseEvent(2, this.entity.getLocalRotation());


            },
            null
        );
    }
};
update

updateはフレームごとに呼ばれます

  1. 移動のキーが押されていたら
  2. 回転のキーが押されたら
  3. Player.prototype.update = function (dt) {
    const { keyboard } = this.app;
    // 1. 移動のキーが押されていたら
    if (keyboard.isPressed(pc.KEY_W) || keyboard.isPressed(pc.KEY_UP)) {
        // 移動
        move("up", this.entity, this);
    } else if (keyboard.isPressed(pc.KEY_S) || keyboard.isPressed(pc.KEY_DOWN)) {
        // 移動
        move("down", this.entity, this);
    }
    
    // 2. 回転のキーが押されたら
    if (keyboard.isPressed(pc.KEY_A) || keyboard.isPressed(pc.KEY_LEFT)) {
        // 回転
        rotate("right", this.entity, this);
    } else if (keyboard.isPressed(pc.KEY_D) || keyboard.isPressed(pc.KEY_RIGHT)) {
        // 回転
        rotate("left", this.entity, this);
    }
    };
    

前回
そういえば、JavaScriptでリアルタイム通信のゲームってどうやって作るのってなったとき。

以下PlayCanvas開発で参考になりそうな記事の一覧です。

その他の記事はこちらになります。
- AR年賀状を作る
- React Native + PlayCanvasを使ってスマートフォンゲームを爆速で生み出す
- PlayCanvasのエディター上でHTML, CSSを組み込む方法
- 【iOS13】新しくなったWebVRの使い方

PlayCanvasのユーザー会のSlackを作りました!

少しでも興味がありましたら、ユーザー同士で解決・PlayCanvasを推進するためのSlackを作りましたので、もしよろしければご参加ください!

日本PlayCanvasユーザー会 - Slack

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

Photon JavaScript SDKのざっくりとした説明と、PlayCanvasでリアルタイム通信をするゲームを作る。

この記事の続きになります

そういえば、JavaScriptでリアルタイム通信のゲームってどうやって作るのってなったとき。

概要

JavaScriptでリアルタイム通信するゲームを作る

JavaScriptはよく触ってるけど、そういえばリアルタイムの通信/対戦ゲームってどうやって作るのってなった時に参考になるかもしれないです。
PlayCanvasでマルチ対戦のゲームを作るために今回はPhotonというネットワークエンジンを使用して作成します。役割としては描画の部分はPlayCanvas、リアルタイム通信の部分についてはPhotonを使用して作っていきます。

今回デモとして作成したこちらのゲームは[WASD]で移動ができるシンプルなゲームとなっております。
2窓などをしていただければ一人でも遊べます。

PlayCanvasについて

PlayCanvasはJavaScriptで記述されたWebGLのエンジンで、最近GitHubのスター数が5000を超えました。
OSSのEngineと、SaaS型のエディターを兼ね備えているゲームエンジンです。engineについては、Three.js, Babylon.jsなどに似た使い方を使用することができます。エディターはビジュアルエディターとコードエディターがあり、エディターはクラウド上でプロジェクトの作成から公開までが出来るもので、FBXOBJといった3Dモデルの素材については、ブラウザへドラッグアンドドロップをすることでGUIから操作・配置ができるエディターとなっております。

Photonについて

Photonについては、Unity多く使われているようですが、JavaScriptのSDKもありますので、Photon JavaScript SDKを使用してマルチ対戦のゲームを作っていきます。

Photonは20CCU(同時接続)までであれば、無料で使用できます。

https://www.photonengine.com/ja/photon

pc1.PNG

PlayCanvasでリアルタイム通信をするをプロジェクト を作ってみました。

pc2.PNG

Photon SDKのドキュメント

docs.PNG

Photon JavaScript SDKのドキュメント

PlayCanvasのプロジェクト

PlayCanvasはクラウド上でスクリプトを管理します。

Ap.PNG

init.js

起動Photonの起動を行っています。index.html, style.cssを読み込みUIのセットアップも行います。

Ane.PNG

app.js

app.jsにてPhotonとPlayCanvasの同期をしています。Photonには様々なライフサイクルで発行されるイベントがありますが、今回使用したものについて説明します。

ao.PNG

/*jshint esversion: 6, asi: true, laxbreak: true*/
const App = pc.createScript('app');

App.prototype.initialize = function() {
    this.photon = this.app.root.children[0].photon;
    this.photon.setLogLevel(999);
    this.photon.onJoinRoom = () => {
        Object.values(this.photon.actors).map(event => {
            const { isLocal, actorNr } = event;
            if (isLocal) {
            } else {
                const entity = new pc.Entity();
                entity.addComponent("model", {
                    type: "box"
                });
                entity.setPosition(0, 1, 0);
                entity.tags.add(`${actorNr}`);
                this.app.root.addChild(entity);
            }
        });
    };

    this.photon.onActorJoin = event => {
        const { isLocal, actorNr } = event;
        if (isLocal) {
        } else {
            const entity = new pc.Entity();
            entity.addComponent("model", {
                type: "box"
            });
            entity.setPosition(0, 1, 0);
            entity.tags.add(`${actorNr}`);
            this.app.root.addChild(entity);
        }
    };

    this.photon.onEvent = (code, content, actorNr) => {
        const entities = this.app.root.findByTag(`${actorNr}`);
        const [entity] = entities;

        switch (code) {
            case 1: {
                const { x, y, z } = content;
                entity.setLocalPosition(x, y, z);
                break;
            }
            case 2: {
                const { x, y, z, w } = content;
                entity.setLocalRotation(x, y, z, w);
                break;
            }
            default: {
                break;
            }
        }
    };
};
photon.onJoinRoom

onJoinRoomはルームに入った際の処理になります。今回のような作りでは呼ばれるタイミング
- ルームを新しく作ったとき
- 既存のルームに入る時

  1. ルームに入った時
  2. 他のプレイヤーを取得
  3. 新しいエンティティ("Model:Box")としてゲーム画面に配置
// 1.ルームに入った時
this.photon.onJoinRoom = () => {
    //2. 他のプレイヤーを取得
    Object.values(this.photon.actors).map(event => {
        const { isLocal, actorNr } = event;
        if (isLocal) {
        } else {
            // 新しいエンティティを作成
            const entity = new pc.Entity();
            entity.addComponent("model", {
                type: "box"
            });
            // 新しく生成したエンティティのポジションの設定とタグを付与している
            entity.setPosition(0, 1, 0); // x, y, z
            entity.tags.add(`${actorNr}`);
            // PlayCanvasの画面上に配置する
            //3. 新しいエンティティ("Model:Box")としてゲーム画面に配置
            this.app.root.addChild(entity);
        }
    });
};
photon.onActorJoin

onActorJoinは自分の入っているルームに他のプレイヤーが参加してきたときの処理になります。

  1. 他のプレイヤーが入ってきた時
  2. 新しいエンティティを作成
  3. 新しいエンティティ("Model:Box")としてゲーム画面に配置
// 1.他のプレイヤーが入ってきた時
this.photon.onActorJoin = event => {
    // isLocal: 自分であるかの判定
    // actorNr: 入った順に1から振られる、ユニークな番号
    const { isLocal, actorNr } = event;
    // 自分自身であるかの判定
    if (isLocal) {
    } else {
        //  2. 新しいエンティティを作成
        const entity = new pc.Entity();
        entity.addComponent("model", {
            type: "box"
        });
        // ポジションとタグを設定
        entity.setPosition(0, 1, 0);
        entity.tags.add(`${actorNr}`);
        // 3. 新しいエンティティ("Model:Box")としてゲーム画面に配置
        this.app.root.addChild(entity);
    }
};

photon.onEvent

onEventは、今回はplayer.jsにより、データが送信された場合に呼び出されます。

  1. actorNrのタグを元にエンティティを検索
  2. CodeによってPositionRotationかの判定を行う
this.photon.onEvent = (code, content, actorNr) => {
    // 1. `actorNr`のタグを元に`エンティティ`を検索
    const entities = this.app.root.findByTag(`${actorNr}`);
    const [entity] = entities;

    //2. `Code`によって`Position`か`Rotation`かの判定を行う
    switch (code) {
        case 1: {
            const { x, y, z } = content;
            entity.setLocalPosition(x, y, z);
            break;
        }
        case 2: {
            const { x, y, z, w } = content;
            entity.setLocalRotation(x, y, z, w);
            break;
        }
        default: {
            break;
        }
    }
};

player.js

playerrr.PNG

Player.jsはキーボードの操作が押された際にPhotonサーバーにデータを送るものになります。

/*jshint esversion: 6, asi: true, laxbreak: true*/
const Player = pc.createScript("player");

// PlayCanvas Editor上で使用するためにAttributesを作成
// Attributesの説明
// https://developer.playcanvas.com/ja/user-manual/scripting/script-attributes/

Player.attributes.add("moveSpeed", { type: "number", default: 0.1 });
Player.attributes.add("rotateSpeed", { type: "number", default: 2 });

const move = (direction, entity, self) => {
    const { photon } = self;
    switch (direction) {
        case "up": {
            entity.translateLocal(0, 0, -self.moveSpeed);
            break;
        }
        case "down": {
            entity.translateLocal(0, 0, self.moveSpeed);
            break;
        }
        default: {
            break;
        }
    }
    // send position
    // コード番号 1で現在のEntityのポジションを送信
    photon.raiseEvent(1, entity.getLocalPosition());
};

const rotate = (direction, entity, self) => {
    const { photon } = self;

    switch (direction) {
        case "left": {
            entity.rotate(0, -self.rotateSpeed, 0);
            break;
        }
        case "right": {
            entity.rotate(0, self.rotateSpeed, 0);
            break;
        }
        default: {
            break;
        }
    }
    // send rotation
    // コード番号 2で現在のEntityのポジションを送信
    photon.raiseEvent(2, entity.getLocalRotation());
};

Player.prototype.initialize = function () {
    this.photon = this.app.root.children[0].photon;
    if (this.app.touch) {
        this.app.touch.on(
            pc.EVENT_TOUCHSTART,
            () => {
                const { photon } = this
                const { x, y, z } = this.entity.getPosition();
                this.entity.rigidbody.teleport(x, y + 0.5, z);
                photon.raiseEvent(1, this.entity.getLocalPosition());

                photon.raiseEvent(2, this.entity.getLocalRotation());


            },
            null
        );
    }
};


// update code called every frame
Player.prototype.update = function (dt) {
    const { keyboard } = this.app;
    // 移動のキーが押されていたら
    if (keyboard.isPressed(pc.KEY_W) || keyboard.isPressed(pc.KEY_UP)) {
        move("up", this.entity, this);
    } else if (keyboard.isPressed(pc.KEY_S) || keyboard.isPressed(pc.KEY_DOWN)) {
        move("down", this.entity, this);
    }

    // 回転のキーが押されたら
    if (keyboard.isPressed(pc.KEY_A) || keyboard.isPressed(pc.KEY_LEFT)) {
        rotate("right", this.entity, this);
    } else if (keyboard.isPressed(pc.KEY_D) || keyboard.isPressed(pc.KEY_RIGHT)) {
        rotate("left", this.entity, this);
    }
};

photon.raiseEvent

RPCs and RaiseEvent

raiseEventを使用して任意のタイミングでデータの送信を行います。今回は移動が行われた時と回転が行われた際に現在の場所を送信しています。

raiseEvent(eventCode, data, options)

型はそれぞれ

 eventCode:number
 object: object
 options: object
移動時
    ....
    // send position
    // コード番号 1で現在のEntityのポジションを送信
    photon.raiseEvent(1, entity.getLocalPosition());
};

回転時
    ...
    // send rotation
    // コード番号 2で現在のEntityのポジションを送信
    photon.raiseEvent(2, entity.getLocalRotation());
};
参考

raiseEvent - Photon Document

initialize

PlayCanvasのスクリプトのライフサイクルには、スクリプト一度だけ呼び出されるinitialize、毎フレーム呼び出されるUpdateがあります。

  1. Photonのプロパティを代入
  2. タッチされた際に発火するイベントの定義
Player.prototype.initialize = function () {
    // Rootエンティティからphotonプロパティを取得
    // 1. Photonのプロパティを代入
    this.photon = this.app.root.children[0].photon;

    // タッチ操作が可能だったら
    if (this.app.touch) {

    // タッチ操作が行われたら
    // PlayCanvas: タッチについて
    // https://support.playcanvas.jp/hc/ja/articles/227190908

    // 2. タッチされた際に発火するイベントの定義
    // PlayCanvas: イベント種類について
    // https://developer.playcanvas.com/ja/user-manual/scripting/communication/
        this.app.touch.on(
            pc.EVENT_TOUCHSTART,
            () => {
                const { photon } = this
                const { x, y, z } = this.entity.getPosition();
                this.entity.rigidbody.teleport(x, y + 0.5, z);
                // コード1で現在のポジションを送信
                photon.raiseEvent(1, this.entity.getLocalPosition());
                // コード2で現在の回転を送信
                photon.raiseEvent(2, this.entity.getLocalRotation());


            },
            null
        );
    }
};
update

updateはフレームごとに呼ばれます

  1. 移動のキーが押されていたら
  2. 回転のキーが押されたら
  3. Player.prototype.update = function (dt) {
    const { keyboard } = this.app;
    // 1. 移動のキーが押されていたら
    if (keyboard.isPressed(pc.KEY_W) || keyboard.isPressed(pc.KEY_UP)) {
        // 移動
        move("up", this.entity, this);
    } else if (keyboard.isPressed(pc.KEY_S) || keyboard.isPressed(pc.KEY_DOWN)) {
        // 移動
        move("down", this.entity, this);
    }
    
    // 2. 回転のキーが押されたら
    if (keyboard.isPressed(pc.KEY_A) || keyboard.isPressed(pc.KEY_LEFT)) {
        // 回転
        rotate("right", this.entity, this);
    } else if (keyboard.isPressed(pc.KEY_D) || keyboard.isPressed(pc.KEY_RIGHT)) {
        // 回転
        rotate("left", this.entity, this);
    }
    };
    

前回
そういえば、JavaScriptでリアルタイム通信のゲームってどうやって作るのってなったとき。

以下PlayCanvas開発で参考になりそうな記事の一覧です。

その他の記事はこちらになります。

日本PlayCanvasユーザー会 - Slack

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

[Vue.js] v-modelのinputの処理を間引く

v-modelでの入力する際のinput処理を間引く

input時に動く処理が多くて重くなってしまったので、間引けないか考えた時のメモ
※class styleで記述しているので注意

setterが動く際にlodashのdebounceを使って間引くようにする

<template>
  <textarea v-model="text" />
</template>

<script lang="ts">
import _ from 'lodash'
import { Component, Vue } from 'vue-property-decorator'

@Component
export default class DebounceTextArea extends Vue {
  public _text: string = ""

  public get text(): string {
    return this._text
  }

  // debounceの返り値がFunctionなのでmethodとして定義しておく
  public debounceTextSetter = _.debounce((text: string) => {
    this._text = text
  }, 1000)

  public set text(text: string) {
    // debounceで入力が終わってから値が設定されるようにする
    this.debounceTextSetter(text)
  }
}
</script>
  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

Differential Serving で必要なコードは 2 割以上削れる!

Differential Serving と言う技術は実はもう2年ほど前からPhilip Walton さんの記事をきっかけに知る人ぞ知る JS のバンドルサイズを減らせるための技術なんですけど、検索しても、日本語でこのテーマについて話す記事はないので、とりあえずまとめ記事のつもりでこれを書きました、よろしくお願いします!

何のための技術ですか?

今でも IE 11 のサポートを切れないアプリは多いと思います、そう言うアプリを作っている人はほとんど IE 11 を含めた全てのブラウザーにアプリがちゃんと起動できるように ES5 までにコードをコンパイルしていると思います。

しかし、IE 11 のために追加する polyfills や ES5 でコードを書くとどうしても必要になって来る追加コードはビルドされたバンドルを重くします。

どれぐらい重くなるかと言うのはコード次第ですが大体 20%から 30%までになります。

Shubham Kanodia さんがこの記事でいくつかのライブラリーをテストしました

しかも、ユーザー全体のほぼ 90%が ES Modules に対応しています! つまり、10%のユーザーのために 90%のユーザーに送るコードの量が多くなっています。

それでもその 10%のユーザーを切れないアプリは多いと思います、なんかこう都合良く ES Modules に対応しているユーザーだけにその小さめのコードを送れるためのフロントエンドコードはないのかな…

実はあります、これからそれを紹介します。

もの凄く簡単に言うとこれです

<!-- 主にIE 11のためのJS -->
<script nomodule src="/app.legacy.js"></script>
<!-- 最新ブラウザーのためのJS -->
<script type="module" src="/app.js"></script>

コンセプト的にはアプリのバンドルを二つ用意します。

  1. IE11 などの legacy ブラウザー用の ES5 にコンパイルされたバンドル。
  2. 最新ブラウザー用の ES Modules にコンパイルされたバンドル。

ここで重要なのは nomoduletype="module" です、これらを使ってユーザーのブラウザーにどのコードをロードするかの判断を任せます。

nomoduletype="module"って何ですか? 

nomodulescriptタグの属性で ES Modules に対応しているブラウザーにこのコードを無視するように示します。

type="module"は逆にブラウザーにコードは ES Modules で書いてあることを示します。

これらを合わせたサンプルはこれです

このコードを ES Modules に対応しているブラウザーで見るとこうなります。

Modernブラウザーの例

見ての通りtype="module"になっているコードだけをダウンロードして実行します。

そして、こちらのサンプルを IE 11 で見るとこうなります。

Legacyブラウザーの例

見ての通りnomoduleになっているコードだけを実行するけど両方をダウンロードしていますね…

そこまで簡単には行かないんですよね

先ほどの例もそうですけど、実は Safari 10 や Firefox 58 や IE 11 や Edge 15-18 などは両方のコードをダウンロードします。

でもこれらはほとんど Wi-Fi でしか使わない PC ブラウザーだからそこまで問題にはならないと思われがちですがタチの悪いことに Safari 10 は両方をダウンロードするだけでなく両方を実行します…

John Stew さんはこちらでいくつかのテストを行ったので詳しくはこれを見てください

Safari 10 のこの記事を書いている時点でのユーザーの割合は 1%以下ではありますができるだけ多くのユーザーに対応したいのでこれだけだとアウトなんですよね…

じゃもう手詰まり?

実は上記のブラウザーの問題を解決できる方法はあります、しかも、それを都合良くまとめてくれる方法は色々な人気のツールにはすでに入っています。

ツールは何をしていますか?

ツールはこう言ったコードを自動的に書いてくれる。

<!-- これが実際にVue CLIを使うと出て来るコードとほぼ同じものです -->
<script type="module" src="https://example.com/app.js"></script>
<script>
  !(function() {
    var check = document.createElement("script");
    if (!("noModule" in check) && "onbeforeload" in check) {
      var support = false;
      document.addEventListener(
        "beforeload",
        function(e) {
          if (e.target === check) {
            support = true;
          } else if (!e.target.hasAttribute("nomodule") || !support) {
            return;
          }
          e.preventDefault();
        },
        true
      );

      check.type = "module";
      check.src = ".";
      document.head.appendChild(check);
      check.remove();
    }
  })();
</script>
<script
  type="text/javascript"
  src="https://example.com/app.legacy.js"
  nomodule
></script>

これは利用できる範囲の JS の feature の中で Safari 10 を見つけてその場合に module と nomodule の script が両方実行されないように働きかけます。

詳しくはこちらの gistを見てください。

これはあくまで一つのやり方で、こちらではまだ legacy ブラウザーが両方ダウンロードすることになります、これから紹介するツールの中で、それすら解決するものもあるので、自分のプロジェクトに合うものもあると思います。

Vue CLI 3+の場合

Vue CLI を使っているなら話はすごい簡単です、ビルド段階でフラグを一つ追加するだけで済みます

vue-cli-service build --modern

webpack の場合

webpack を使っているならもう少し設定をいじる必要はあります。

  1. この 2 つのプラグインの内から 1 つをインストールします。
  2. 次に webpack で ES5 用の設定と ES Modules 用の設定を用意します。こちらの記事の「Generate two bundles」の部分にいい例があります。

  3. ➁ でビルドしたバンドルを ➀ のプラグインに入れます

Rollup の場合

Rollup ではrollup-plugin-index-htmlを使えば簡単にできます。

こちらは性能が良く、設定次第では両方がダウンロードされてしまう問題まで解決できるようになっています。

使い方は先ほどの webpack の使い方に似ています。

Web Components でプロジェクトを作っているなら、先ほどのプラグインを内部で使っているOpen WC の設定 がおすすめです。

Web Components のプロジェクトでなくても上記の設定が参考になると思います。

最後に

Differential Serving は個人的に誰もが知っておくべき技術だと思います、これを入れるとまだ legacy ブラウザーを使っているユーザーを犠牲にせず最新ブラウザーを使っているユーザーにもっといい体験をさせられます。いわゆる「Win Win」な話です。

もっと知りたい人には(全て英語の記事ですみません mm)

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

【Rails6】(送信時のリロード無し!)Action CableでSlack風チャットアプリを作成

Railsの学習をされた方なら誰しも一度は作ったであろうTwitterの簡易クローンアプリ。最初に実装したときは大変でした:sweat:

この記事では, Action Cable を利用して, Slack のようなリアルタイムチャットアプリの作成方法,さらに Javascript でいろいろな機能を付けるところまでを解説します!

なお, Action Cable を扱う多くのチャットアプリ記事では,メッセージ送信時にリロードが発生しますが,この記事では送信も非同期で行えるように設計します。

容量制限の都合で見づらいですが,こちらが完成後のチャットアプリです。

chat_app.gif

1. Action Cableがなぜ必要なのか?

Action Cable をご存知ない方もおられると思いますので,簡単に解説したいと思います。通常のTwitterの簡易クローンアプリ(CRUDアプリ)と,この記事で作成するアプリとの大きな違いは次の2点です。


  1. ページ更新せず(リロードせず)にメッセージの新規投稿ができ,投稿一覧に反映される
  2. 他人が投稿したメッセージが,ページ更新無しに投稿一覧に反映される

1つ目は, Ajax を利用することで実装できます。ところが,2つ目は厄介です。

HTTPの仕様により,原則として,リクエストを出さなければサーバー側からデータを取得することはできません。つまり,リアルタイムで「誰かが投稿した」という情報を受け取ることはできないのです:cry:

この問題を解決し,低コストで双方向通信できるプロトコルが WebSocket であり,この WebSocket をRailsで簡単に扱える機能が Action Cable なのです!

2. 実装するアプリの仕様について

  • Slack のように 新規メッセージが下に来る 設計

    • Twitter のように,新規メッセージが上に来る仕様よりも難易度が上がります
    • 例えば,ページを開いた時に 一番下に移動させないと新規メッセージが見られない!!! という問題に対処しなければなりません :scream:
  • 未入力時は投稿ボタンを無効化し,色を変化

  • 入力フォームで改行した際に縦幅を広げる

  • 無限スクロール機能(過去メッセージの読み込み機能)を実装

    • 大量のメッセージを全て読み込むと双方の負荷が大きすぎるため
  • 補足

    • ログインページのデザイン部分を整える内容は省略します(以前に書きました
    • Rails だけでなく,Javascript のプログラムもそれなりに書きます
    • CoffeeScript は使用しません。
    • デザイン部分を楽に仕上げるため, Bootstrap4 を積極的に使用します
    • jQuery は極力避けます(便利なときだけ使用します)

3. 開発環境

  • macOS Catalina 10.15.1
  • Ruby 2.6.4
  • Rails 6.0.1
    • Rails 5 の場合は,一部設定が変わります
  • Bootstrap 4.3.1
  • Devise 4.7.1

4. 手順

4-0. 準備

まずはアプリを作成し,すぐに必要となる gem を入れて動作確認をしておきましょう。

  • rails newのオプション部分はお好みで
    • -d postgresql データベースを PostgreSQL に設定
    • -T Minitest 用のファイル・ディレクトリを作成しない
    • --skip-coffee CoffeeScript のセットアップをしない
    • --skip-bundle bundle install をしない
$ rails new chat_app -d postgresql -T --skip-coffee --skip-bundle
$ cd chat_app
  • rails g controllerで余計なファイルを作成しないように,config/initializersに次のgenerators.rbを作成
    • 設定はお好みで
    • ただし,g.javascripts false にしてしまうと, rails g channelroom_channel.js が作成されなくなるので注意
config/initializers/generators.rb
Rails.application.config.generators do |g|
  g.helper false
  g.stylesheets false
  g.javascripts true
  g.template_engine :erb
  g.skip_routes true
  g.test_framework false
end
  • Gemfileに次を追加
Gemfile
# ログイン機能
gem 'devise'

# 日本語化
gem 'rails-i18n', '~> 6.0'
gem 'devise-i18n'

# こちらはお好みです。ログインページにBootstrapが適用され,見た目がマシになります
gem 'devise-bootstrap-views', '~> 1.0'

# こちらもお好みです。動作確認用のランダムメッセージを入れるために使用します
gem 'faker'
$ bundle install
$ rails webpacker:install

# 念のため動作確認をしておきます
$ rails db:create
$ rails s
  • http://localhost:3000で「Yay! You’re on Rails!」を確認できたら念のためコミットし,ブランチを変えておくのがよいかと思います

4-1. Bootstrap4 の導入

デザイン部分を楽に仕上げるため,この記事では Bootstrap4 を積極的に使用していきます。Rails 6 の場合は次の設定を行いましょう。

$ yarn add bootstrap jquery popper.js
  • environment.jsを次に置き換える
config/webpack/environment.js
const { environment } = require('@rails/webpacker')

const webpack = require('webpack')
environment.plugins.append('Provide', new webpack.ProvidePlugin({
    $: 'jquery',
    jQuery: 'jquery',
    Popper: ['popper.js', 'default']
}))

module.exports = environment
app/javascript/packs/application.js
// 一番下に次を追加
require("bootstrap/dist/js/bootstrap")
// js.erb内でjQueryを使用されたい場合は,「window.$ = jQuery;」も必要です
  • application.cssの拡張子cssscssに変更して,次に置き換える
app/assets/stylesheets/application.scss
/*
 *= require_tree .
 *= require_self
 */

@import "bootstrap/scss/bootstrap";

4-2. 基本設定

誰が投稿したメッセージであるか を特定するには ログイン機能 が必要となります。そこで Devise でログイン機能を付け,ログイン関連のリンクを付けたヘッダーを付けておきます。また,ログイン後のトップページ(チャットルーム)も作成しておきましょう。

  • ヘッダー(ナビバー)を追加し,レスポンシブ対応のためのmetaタグを追加
    • お好みで変更して下さい
    • Bootstrap4 の場合は,ヘッダーのクラスに sticky-top 属性を付けるだけで位置を固定化できます
app/views/layouts/application.html.erb
 (略)
-     <title>ChatApp</title>
+     <title>Slack風チャットルーム</title>
     <%= csrf_meta_tags %>
     <%= csp_meta_tag %>
+    <meta name="viewport" content="width=device-width,initial-scale=1">
 (略)
  <body>
+    <%= render 'shared/header' %>
     <%= yield %>
   </body>
app/views/shared/_header.html.erb
<header class="sticky-top">
  <nav class="navbar navbar-expand-sm navbar-light bg-light">
    <%= link_to "Slack風チャットルーム", root_path, class: 'navbar-brand' %>
    <button type="button" class="navbar-toggler" data-toggle="collapse" data-target="#navbarNav" aria-controls="navbarNav" aria-expanded="false" aria-label="ナビゲーションの切替">
      <span class="navbar-toggler-icon"></span>
    </button>
    <div class="collapse navbar-collapse" id="navbarNav">
      <ul class="navbar-nav">
        <% if user_signed_in? %>
          <li class="nav-item active">
            <%= link_to 'アカウント編集', edit_user_registration_path, class: 'nav-link' %>
          </li>
          <li class="nav-item active">
            <%= link_to 'ログアウト', destroy_user_session_path, method: :delete, class: 'nav-link' %>
          </li>
        <% else %>
          <li class="nav-item active">
            <%= link_to "新規登録", new_user_registration_path, class: 'nav-link' %>
          </li>
          <li class="nav-item active">
            <%= link_to "ログイン", new_user_session_path, class: 'nav-link' %>
          </li>
        <% end %>
      </ul>
    </div>
  </nav>
</header>
  • トップページ用のコントローラとビューを作成
$ rails g controller rooms show
  • トップページを設定
config/routes.rb
Rails.application.routes.draw do
  root 'rooms#show'
end
  • 日本語化とタイムゾーンの変更
config/application.rb
module AssociationTutorial
  class Application < Rails::Application
    # (略)
    # the framework and any gems in your application.

    # ********** 以下を追加 **********
    config.i18n.default_locale = :ja
    config.time_zone = 'Asia/Tokyo'
    # ********** 以上を追加 **********

    # Don't generate system test files.
    config.generators.system_tests = nil
  end
end
  • Deviseでログイン機能を付ける
$ rails g devise:install
$ rails g devise user
$ rails db:migrate
  • 全ページをログイン必須に変更
app/controllers/application_controller.rb
class ApplicationController < ActionController::Base
  before_action :authenticate_user!
end
  • $ rails sでサーバーを再起動して確認すると,次のような状態になります

スクリーンショット 2019-12-13 8.00.35.png

4-3. メッセージ投稿機能(Ajax)

DHH氏の動画から派生した多くの記事では, Action Cable のデータ送信機能(speakなど)を利用していますが,これを使いますとメッセージ送信時にリロードが発生してしまいます:scream:

そこで,この記事ではメッセージの送信は Ajax を用いて非同期で送信することとします。これにより Action Cable 特有の知識・問題の一部を回避することもできます:smiley:

まずはメッセージ投稿機能を付けます。大雑把な手順を確認しましょう。

  1. messages テーブルとモデルを作成
  2. 投稿一覧ページに,form_withで投稿フォームを作成
  3. コントローラのcreateアクションで投稿内容をデータベースに保存
    • 投稿メッセージを非同期で投稿一覧に反映する部分はここで実装しません

メッセージを保存するためのデータベースとモデルを作成します。

  • user_id は User モデルとの関連付けで必要
  • content はメッセージ内容を保存するカラム
$ rails g model Message user_id:integer content:text
  • 念のためnull: falseを追加しておきます。
db/migrate/日時_create_messages.rb
      t.integer :user_id, null: false
      t.text :content, null: false
$ rails db:migrate
  • モデルに関連付けとバリデーションを入れておきます。
    • メッセージの文字数制限を 500 文字にしていますが,お好みで
app/models/user.rb
 class User < ApplicationRecord
   devise :database_authenticatable, :registerable,
         :recoverable, :rememberable, :validatable

  # 次の一行を追加
  has_many :messages, dependent: :destroy
end
app/models/message.rb
class Message < ApplicationRecord
  # **********以下を追加**********
  belongs_to :user
  validates :user_id, presence: true
  validates :content, presence: true, length: { maximum: 500 }
  # **********以上を追加**********
end
  • コントローラ側で,(この時点では)全メッセージを取得することにします
    • Message.all では,いわゆる N+1問題 が発生するので注意(後に補足します)
app/controllers/rooms_controller.rb
class RoomsController < ApplicationController
  def show
    # 投稿一覧表示に利用
    @messages = Message.includes(:user).order(:id)
    # メッセージ投稿に利用
    @message = current_user.messages.build
  end
end
  • 投稿フォームを作成する前に,コントローラを作成し,ルーティングを設定
$ rails g controller messages create
  • ここで作成されるapp/views/messages/create.html.erbは削除
config/routes.rb
Rails.application.routes.draw do
  devise_for :users
  root 'rooms#show'
  # 次の一行を追加
  resources :messages, only: :create
end
  • メッセージを表示するためのビューと,投稿フォームを作成
    • Bootstrap4 の場合は,フッターのクラスに fixed-bottom 属性を付けるだけで位置を固定化できます
    • Ajax を利用しますので, form_withlocal: true を入れてはいけません!
app/views/rooms/show.html.erb
<div id="message-container">
  <%= render @messages %>
</div>
<%= render 'footer' %>
app/views/rooms/_footer.html.erb
<footer class="fixed-bottom" id="footer">
  <%= form_with model: @message do |f| %>
    <div class="form-group">
      <!-- ここは自動的に message_content というidが付きます。アンダーバーなので注意! -->
      <%= f.text_area :content, class: 'form-control', rows: '1', maxlength: '500' %>
    </div>

    <div class="form-group">
      <%= f.submit '送信', class: 'btn btn-primary btn-block', id: 'message-button' %>
    </div>
  <% end %>
</footer>
  • <%= render @messages %>に挿入する部分テンプレートを作成
    • messagesディレクトリの中に次の_message.html.erbを作成
    • とりあえず Eメール, 作成日時, メッセージ を表示することにしておきます
app/views/messages/_message.html.erb
<div class="message" id="message-<%= message.id %>">
  <p><%= message.user.email %>: <%= l message.created_at, format: '%Y年%-m月%-d日(%a) %H:%M' %></p>
  <%= simple_format(h message.content) %>
</div>
  • 補足

    • 単純に<%= message.count %>では,改行が反映されません
    • simple_formatメソッド単独では<h1>タグなどが反映されてしまうため,hメソッドで全てのHTMLタグをエスケープしておきます
    • メーセージを書いたユーザーの情報をmessage.userで取得しているので, コントローラで @messages = Message.all にしていると各メッセージに対してSQLが発行されてしまいます(N+1問題)
  • application.scssに最低限度追加しておきます

    • 一番下のメッセージがフッターに隠れないよう padding-bottom は大きめに取っておく必要があります
app/assets/stylesheets/application.scss
// メッセージ一覧
#message-container {
  max-width: 768px;
  margin: 0 auto;
  padding: 1rem 1rem 8rem 1rem;
}

// メッセージ投稿フォーム
footer {
  max-width: 768px;
  padding: 1rem;
  margin: 0 auto;
  background-color: white;
}
  • 動作確認のための seeds.rb を作成します。中身はお好みで。
    • 改行が反映されるかをチェックするため,改行を含むメッセージも追加しておくとよいです
    • 最初は作成するメッセージ個数を少なめにしておきます
db/seeds.rb
# 作成するユーザー・メッセージの個数
user_count = 3
message_count = 3

ApplicationRecord.transaction do
  # テストユーザーが無ければ作成
  user_count.times do |n|
    User.find_or_create_by!(email: "test#{n + 1}@example.com") do |user|
      user.password = 'password'
    end
  end

  # メッセージを全消去した上で,サンプルメッセージを作成。メッセージを作成したユーザーはランダムに決定する
  Message.destroy_all
  user_ids = User.ids
  message_list = []
  message_count.times do |n|
    user_id = user_ids.sample
    line_count = rand(1..4)
    # Fakerで1〜4行のランダムメッセージを作成
    content = Faker::Lorem.paragraphs(number: line_count).join("\n")
    message_list << { user_id: user_id, content: content }
  end
  Message.create!(message_list)
end
puts '初期データの追加が完了しました!'
$ rails db:seed
  • 例えば次のアカウントでログインできるようになります

    • Eメール: test1@example.com
    • パスワード: password
  • この時点で次のような表示になっていると思います

    • メッセージが意味不明なのは Faker の仕様です:sweat_smile:

スクリーンショット 2019-12-08 21.41.42.png

  • メッセージを送信できるようにコントローラを設定
app/controllers/messages_controller.rb
class MessagesController < ApplicationController
  def create
    @message = current_user.messages.create!(message_params)
  end

  private
  def message_params
    params.require(:message).permit(:content)
  end
end
  • 次のcreate.js.erbを作成
app/views/messages/create.js.erb
// フォームに入力した文字列を消去
document.getElementById('message_content').value = ''

これでメッセージを投稿できるようになりました。フォームに例えば「おはよう」と入力して送信ボタンを押します。文字が消えた後,ページを更新(リロード)し,投稿メッセージが反映されればOKです!

なお, create.js.erb に投稿メッセージを表示させる操作は入れません。

投稿したメッセージは本人だけでなく, チャット参加者全員 に追加しなければなりません。ここで Action Cable が登場します。

4.4 Action Cable の設定・確認

Action Cable を使用し,フロント側とサーバー側が監視し合う状態(双方向通信ができる状態)にしましょう。

$ rails g channel Room

# 次の2つが出力されたらOK
#     create  app/channels/room_channel.rb
#     create  app/javascript/channels/room_channel.js
config/routes.rb
 Rails.application.routes.draw do
+  mount ActionCable.server => '/cable'
   devise_for :users
   root 'rooms#show'
 end
  • これだけで監視状態ができあがります!動作確認をしてみましょう。
    • 以下は省略して 4.5 に進んでもOKです
    • Rails 5 の場合は異なる点が複数ありますのでご注意下さい
app/channels/room_channel.rb
 class RoomChannel < ApplicationCable::Channel

   # サーバー側からフロント側を監視できているかを確認できたときに動くメソッド
   def subscribed
+    5.times { puts '***test***' }
   end
   #(略)
app/javascript/channels/room_channel.js
 import consumer from "./consumer" 

 consumer.subscriptions.create("RoomChannel", {

   // フロント側からサーバー側を監視できているかを確認できたときに動く関数
   connected() {
+    console.log('test')
   },
   // (略)

$ rails s でサーバーを再起動し,タブを更新して確認してみて下さい。接続確認できたタイミングでメッセージが出力されるはずです。

  • コンソールに***test***が5回出力されていればOKです

スクリーンショット 2019-12-09 8.10.59.png

  • ChromeのデベロッパーツールのConsoleタブを開き,testと表示されていればOKです

スクリーンショット 2019-12-09 8.14.08.png

チェックができたら,追加した 5.times { puts '***test***' }console.log('test')削除して下さい

4.5 チャット機能の実装

Action Cable を利用して チャット参加者全員 が投稿メッセージをリアルタイムで受信し,ページに反映できるように設定していきましょう!

【補足】 最初に予告しましたとおり, Action Cable の送信機能は使用しません

app/channels/room_channel.rb
class RoomChannel < ApplicationCable::Channel
  def subscribed
    # 配信する部屋名を決定
    stream_from "room_channel"
  end

  def unsubscribed
  end
end
app/controllers/messages_controller.rb
class MessagesController < ApplicationController
  def create
    @message = current_user.messages.create!(message_params)
  # ********** 以下を追加 **********
    # 投稿されたメッセージをチャット参加者に配信
    ActionCable.server.broadcast 'room_channel', message: @message.template
  # ********** 以上を追加 **********
  end
end
app/models/message.rb
class Message < ApplicationRecord
  belongs_to :user
  validates :user_id, presence: true
  validates :content, presence: true, length: { maximum: 500 }
  # ********** 以下を追加 **********
  # 投稿されたメッセージをメッセージ用の部分テンプレートでHTMLに変換
  def template
    ApplicationController.renderer.render partial: 'messages/message', locals: { message: self }
  end
  # ********** 以上を追加 **********
end

【補足】 Jobを作成している記事を多く見かけますが,少なくともHerokuにデプロイする場合は必要ないようです。

【参考】 (stackoverflow) ActionCable: Why put broadcasts in a separate job? For forms why not broadcast from controllers?

app/javascript/channels/room_channel.js
import consumer from "./consumer"

// turbolinks の読み込みが終わった後にidを取得しないと,エラーが出ます。
document.addEventListener('turbolinks:load', () => {

    // js.erb 内で使用できるように変数を定義しておく
    window.messageContainer = document.getElementById('message-container')

    // 以下のプログラムが他のページで動作しないようにしておく
    if (messageContainer === null) {
        return
    }

    consumer.subscriptions.create("RoomChannel", {
        connected() {
        },

        disconnected() {
        },

        received(data) {
            // サーバー側から受け取ったHTMLを一番最後に加える
            messageContainer.insertAdjacentHTML('beforeend', data['message'])
        }
    })
})

これで,チャット機能の基礎は完成です!

サーバーを再起動し,2つのタブでhttp://localhost:3000を開き,片方に「おはよう」と入力して送信してみて下さい。
もう片方でも「おはよう」が表示されたならOKです。
(2種類のブラウザを使い,別のユーザーでログインした状態でチェックするとなおよいです)

4.6 機能の改善

さて,現状では複数の問題があります。

  1. メッセージが多くなると,ページを開いたときに最新メッセージが見えなくなってしまう:scream:

  2. フォームが空欄でも投稿ボタンを押すと送信できてしまう:sweat_smile:

    • モデル側でバリデーションは入っているが,サーバーに無用な負荷をかけてしまう
  3. フォームが一行では複数行のメッセージを書きづらい:sweat:

    • 最初から複数行の幅をとると,メッセージの見える範囲が狭くなる(スマホだと致命的)

順番に解決していきましょう!

:small_red_triangle_down: メッセージが多くなると,ページを開いたときに最新メッセージが見えなくなってしまう
:arrow_right: ページを開いたときにページの一番下に移動させればよい!

app/javascript/channels/room_channel.js
// (略)
  consumer.subscriptions.create("RoomChannel", {
// (略)
  })
// ********** 以下を追加 **********
    const documentElement = document.documentElement
    // js.erb 内でも使用できるように変数を決定
    window.messageContent = document.getElementById('message_content')
    // 一番下まで移動する関数。js.erb 内でも使用できるように変数を決定
    window.scrollToBottom = () => {
        window.scroll(0, documentElement.scrollHeight)
    }

    // 最初にページ一番下へ移動させる
    scrollToBottom()
// ********** 以上を追加 **********
}
  • さらにメッセージ投稿後に,投稿した最新メッセージが見られるように一番下へ移動させます
app/views/messages/create.js.erb
// フォームに入力した文字列を消去
messageContent.value = ''
// 一番下へスクロール
scrollToBottom()

:small_red_triangle_down: フォームが空欄でも投稿ボタンを押すとサーバーにリクエストが出せてしまう
:arrow_right: フォームが空欄なら投稿ボタンを無効化すればよい!

  • Bootstrap4 を導入しているので,クラスに disable を追加することでボタンを無効化できます
    • disabled 属性の追加・削除でもよいのですが,メッセージ送信後にボタンを無効化できないので採用しません(form_withで作成したボタンは,メッセージ送信直後に自動でdisabled属性が追加され,送信完了後にdisabled属性が削除されます。そのためか,create.js.erbや,room_channel.jsreceived 関数でdisabled属性を追加しても無効化されてしまいます)
app/views/rooms/_footer.html.erb
 <!-- 最初はボタンを無効化するため,クラスに disabled を追加 -->
- <%= f.submit '送信', class: 'btn btn-primary btn-block', id: 'message-button' %>
+ <%= f.submit '送信', class: 'btn btn-primary btn-block disabled', id: 'message-button' %>
  • フォームに入力されたときに,空欄でなければボタンを有効化,空欄なら無効化します
app/javascript/channels/room_channel.js
    // (略)
    // ********** 以下を追加 **********
    const messageButton = document.getElementById('message-button')

    // 空欄でなければボタンを有効化,空欄なら無効化する関数
    const button_activation = () => {
        if (messageContent.value === '') {
            messageButton.classList.add('disabled')
        } else {
            messageButton.classList.remove('disabled')
        }
    }

    // フォームに入力した際の動作
    messageContent.addEventListener('input', () => {
        button_activation()
    })

    // 送信ボタンが押された時にボタンを無効化
    messageButton.addEventListener('click', () => {
        messageButton.classList.add('disabled')
    })
    // ********** 以上を追加 **********
})
  • さらに,ボタンが無効化されているときは灰色にします
app/assets/stylesheets/application.scss
// ボタン無効化時のスタイル
.btn-primary.disabled {
  background-color: #6c757d;
  border-color: #6c757d;
  cursor: auto;
}

:small_red_triangle_down: フォームが一行では複数行のメッセージが書きづらい
:arrow_right: 改行したときにフォームの行数を増やし,行数が減ったときはフォームの行数も減らすようにする

  • フォームにある改行の個数からフォームの行数を決定
    • ただし,最大行数は 10 としておきます
app/javascript/channels/room_channel.js
    // (略)
    // フォームに入力した際に動作
    messageContent.addEventListener('input', () => {
        button_activation()
    // ********** 以下を追加 **********
        changeLineCheck()
    // ********** 以上を追加 **********
    })
    // 送信ボタンが押された時にボタンを無効化し,フォーム行数を1に戻す
    messageButton.addEventListener('click', () => {
        messageButton.classList.add('disabled')
    // ********** 以下を追加 **********
        changeLineCount(1)
    // ********** 以上を追加 **********
    })
    // ********** 以下を追加 **********
    // フォームの最大行数を決定
    const maxLineCount = 10

    // 入力メッセージの行数を調べる関数
    const getLineCount = () => {
        return (messageContent.value + '\n').match(/\r?\n/g).length;
    }

    let lineCount = getLineCount()
    let newLineCount

    const changeLineCheck = () => {
        // 現在の入力行数を取得(ただし,最大の行数は maxLineCount とする)
        newLineCount = Math.min(getLineCount(), maxLineCount)
        // 以前の入力行数と異なる場合は変更する
        if (lineCount !== newLineCount) {
            changeLineCount(newLineCount)
        }
    }

    const changeLineCount = (newLineCount) => {
        // フォームの行数を変更
        messageContent.rows = lineCount = newLineCount
    }
    // ********** 以上を追加 **********
})
app/assets/stylesheets/application.scss
// ユーザーがフォームサイズを自由に変更できる機能を停止させておく
#message_content {
  resize: none;
}

これで,行数が自動で変化するようになります……が,別の問題が発生します。

スクリーンショット 2019-12-13 16.37.43.png

これは,フッターの高さを変化させたのに,padding-bottomを変更していないからです。さらに,変更分だけ上下にスクロールさせた方が親切です。そこで,さらにコードを加えます。

app/javascript/channels/room_channel.js
    // (略)
    // ********** 以下を追加 **********
    const footer = document.getElementById('footer')
    let footerHeight = footer.scrollHeight
    let newFooterHeight, footerHeightDiff
    // ********** 以上を追加 **********

    const changeLineCount = (newLineCount) => {
        // フォームの行数を変更
        messageContent.rows = lineCount = newLineCount
    // ********** 以下を追加 **********
        // 新しいフッターの高さを取得し,違いを計算
        newFooterHeight = footer.scrollHeight
        footerHeightDiff = newFooterHeight - footerHeight
        // 新しいフッターの高さをチャット欄の padding-bottom に反映し,スクロールさせる
        // 行数が増える時と減る時で操作順を変更しないとうまくいかない
        if (footerHeightDiff > 0) {
            messageContainer.style.paddingBottom = newFooterHeight + 'px'
            window.scrollBy(0, footerHeightDiff)
        } else {
            window.scrollBy(0, footerHeightDiff)
            messageContainer.style.paddingBottom = newFooterHeight + 'px'
        }
        footerHeight = newFooterHeight
    // ********** 以上を追加 **********
    }
})

これで,フォーム行数の増減に対して自然な動きをするようになりました!

  • この時点での最終的なroom_channel.jsはこちらです。
    • 変数を書く場所などを整理する作業はお好みで
app/javascript/channels/room_channel.js
import consumer from "./consumer"

// turbolinks の読み込みが終わった後にidを取得しないと,エラーが出ます。
document.addEventListener('turbolinks:load', () => {

    // js.erb 内で使用できるように変数を定義しておく
    window.messageContainer = document.getElementById('message-container')

    // 以下のプログラムが他のページで動作しないようにしておく
    if (messageContainer === null) {
        return
    }

    consumer.subscriptions.create("RoomChannel", {
        connected() {
        },

        disconnected() {
        },

        received(data) {
            // サーバー側から受け取ったHTMLを一番最後に加える
            messageContainer.insertAdjacentHTML('beforeend', data['message'])
        }
    })

    const documentElement = document.documentElement
    // js.erb 内でも使用できるように変数を決定
    window.messageContent = document.getElementById('message_content')
    // 一番下まで移動する関数。js.erb 内でも使用できるように変数を決定
    window.scrollToBottom = () => {
        window.scroll(0, documentElement.scrollHeight)
    }

    // 最初にページ一番下へ移動させる
    scrollToBottom()

    const messageButton = document.getElementById('message-button')

    // 空欄でなければボタンを有効化,空欄なら無効化する関数
    const button_activation = () => {
        if (messageContent.value === '') {
            messageButton.classList.add('disabled')
        } else {
            messageButton.classList.remove('disabled')
        }
    }

    // フォームに入力した際の動作
    messageContent.addEventListener('input', () => {
        button_activation()
        changeLineCheck()
    })

    // 送信ボタンが押された時にボタンを無効化し,フォーム行数を1に戻す
    messageButton.addEventListener('click', () => {
        messageButton.classList.add('disabled')
        changeLineCount(1)
    })
    // フォームの最大行数を決定
    const maxLineCount = 10

    // 入力メッセージの行数を調べる関数
    const getLineCount = () => {
        return (messageContent.value + '\n').match(/\r?\n/g).length;
    }

    let lineCount = getLineCount()
    let newLineCount

    const changeLineCheck = () => {
        // 現在の入力行数を取得(ただし,最大の行数は maxLineCount とする)
        newLineCount = Math.min(getLineCount(), maxLineCount)
        // 以前の入力行数と異なる場合は変更する
        if (lineCount !== newLineCount) {
            changeLineCount(newLineCount)
        }
    }

    const footer = document.getElementById('footer')
    let footerHeight = footer.scrollHeight
    let newFooterHeight, footerHeightDiff

    const changeLineCount = (newLineCount) => {
        // フォームの行数を変更
        messageContent.rows = lineCount = newLineCount
        // 新しいフッターの高さを取得し,違いを計算
        newFooterHeight = footer.scrollHeight
        footerHeightDiff = newFooterHeight - footerHeight
        // 新しいフッターの高さをチャット欄の padding-bottom に反映し,スクロールさせる
        // 行数が増える時と減る時で操作順を変更しないとうまくいかない
        if (footerHeightDiff > 0) {
            messageContainer.style.paddingBottom = newFooterHeight + 'px'
            window.scrollBy(0, footerHeightDiff)
        } else {
            window.scrollBy(0, footerHeightDiff)
            messageContainer.style.paddingBottom = newFooterHeight + 'px'
        }
        footerHeight = newFooterHeight
    }
})

4.7 無限スクロール機能

メッセージ数が少ない間は問題無いのですが,もし1万件メッセージがあったらどうでしょう。最初に全て読み込むのは,クライアント側・サーバー側双方に負荷がかかりますし,SEOにも影響が出てくるでしょう。

そこで,最初の時点では100件だけ読み込み,一番上までスクロールしたとき更に読み込むように設計してみましょう。

非同期でメッセージをさらに読み込むためには Ajax を利用しなければなりませんが,メッセージのフォームと異なり,ボタンがありませんform_withlink_toなどを使うのは不自然です。

そこで,JavascriptAjax を利用するためのプログラムを書きます。

  • まず,メッセージを大量に追加しておきます
db/seeds.rb
  # 作成するユーザー・メッセージの個数
  user_count = 3
- message_count = 3
+ message_count = 1000
$ rails db:seed
  • ルーティングとコントローラーを用意し,最初に読み込むメッセージを制限します
config/routes.rb
+  get '/show_additionally', to: 'rooms#show_additionally'
app/controllers/rooms_controller.rb
class RoomsController < ApplicationController
  def show
    # 次の一行を変更。最新の100件のみ取得する。
    @messages = Message.includes(:user).order(:id).last(100)
    @message = current_user.messages.build
  end

  # ********** 以下を追加 **********
  def show_additionally
  end
  # ********** 以上を追加 **********
end
  • room_channel.js から Ajax を利用する
    • 表示済みのメッセージの内,最も古いメッセージidを送信しておく
    • data-remote: true を入れることで,show_additionally.js.erbを作動できる
app/javascript/channels/room_channel.js
    // (略)
    // ********** 以下を追加 **********
    let oldestMessageId
    // メッセージの追加読み込みを可否を決定する変数
    window.showAdditionally = true

    window.addEventListener('scroll', () => {
        if (documentElement.scrollTop === 0 && showAdditionally) {
            showAdditionally = false
            // 表示済みのメッセージの内,最も古いidを取得
            oldestMessageId = document.getElementsByClassName('message')[0].id.replace(/[^0-9]/g, '')
            // Ajax を利用してメッセージの追加読み込みリクエストを送る。最も古いメッセージidも送信しておく。
            $.ajax({
                type: 'GET',
                url: '/show_additionally',
                cache: false,
                data: {oldest_message_id: oldestMessageId, remote: true}
            })
        }
    }, {passive: true});
    // ********** 以上を追加 **********
})
app/controllers/rooms_controller.rb
  # (略)
  def show_additionally
    # ********** 以下を追加 **********
    # 追加のメッセージ50件を取得する
    last_id = params[:oldest_message_id].to_i - 1    
    @messages = Message.includes(:user).order(:id).where(id: 1..last_id).last(50)
    # ********** 以上を追加 **********
  end
end
app/views/rooms/show_additionally.js.erb
// 今回は上からメッセージを追加
messageContainer.insertAdjacentHTML('afterbegin', "<%= j(render @messages) %>")
// メッセージが存在するときだけ,読み込み可能とする
<% if @messages.present? %>
    showAdditionally = true
<% end %>
  • メッセージが正しく追加されるかを調べるために,一旦,message.idも表示させるようにします
    • 確認後に削除してOKです
app/views/messages/_message.html.erb
<div class="message" id="message-<%= message.id %>">
  <p><%= message.id %>: <%= message.user.email %>: <%= l message.created_at, format: '%Y年%-m月%-d日(%a) %H:%M' %></p>
  <%= simple_format(h(message.content)) %>
</div>

これでメッセージの追加読み込みが可能となります!……が,問題が……
メッセージが追加されたのに,スクロール位置が一番上のままなのです:scream:

そこで,メッセージの追加されたタイミングでスクロールさせ,同じメッセージが見える状態にしておきます。

app/views/rooms/show_additionally.js.erb
// const や let を使うと2回目以降にエラーが発生します
var messageHeight = messageContainer.scrollHeight
// 今回は上からメッセージを追加
messageContainer.insertAdjacentHTML('afterbegin', "<%= j(render @messages) %>")
var newMessageHeight = messageContainer.scrollHeight
window.scrollBy(0, newMessageHeight - messageHeight)
// メッセージが存在するときだけ,読み込み可能とする
<% if @messages.present? %>
    showAdditionally = true
<% end %>

これで読み込み後もメッセージの位置が変わらなくなりました:grinning:

4.8 Herokuにデプロイする場合の注意点

このままHerokuにプッシュしても動作しません。
cable.yml を次のように編集してからデプロイしてみて下さい。

config/cable.yml
production:
-  adapter: redis
-  url: <%= ENV.fetch("REDIS_URL") { "redis://localhost:6379/1" } %>
-  channel_prefix: chat_app_production
+  adapter: async

4.9 ユーザー名とサムネイルを追加

※時間がある時に追記します……

5. 参考記事・動画

6. サンプルコード

$ git clone https://github.com/T-Tsujii/chat_app.git
  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

React まとめ①

Reactを自分なりにまとめてみた①

初めての投稿です。
Reactの復習用メモとして残します。

まず前提としてReactはJavascriptのライブラリなので、Javascriptの最低限の理解が必要というのは言うまでもないことなのですが、他の言語をやったことがある方でしたら、入りやすいのかなと思います。

Javascriptを完璧にやってからReactをやるというのも良いと思いますが、ES6の記法をさらってReactを触り始めてもいいんじゃないかなって思います。

ここで重要なのは、分かんないのはJavascriptなのかReactなのかを明確にする必要があるということです。

最初ということでJSXについての記述がほとんどになりますのでご存知のかたは②にお進み下さい。

const element = <h1>Hello, world!</h1>;

この構文はJSXと呼ばれるJavascriptの拡張構文です。
ReactではこのJSXを多用します。

const name = 'Josh Perez';
const element = <h1>Hello, {name}</h1>;
ReactDOM.render(
  element,
  document.getElementById('root')
);

constでnameを宣言して、中括弧に囲んでJSX内で使用しています。
PHPで言うところの
$name = “Josh Perez”;
のようなものですが、Javascriptにはlet,const,varが存在しているので差異は理解する必要があります。

const element = <div tabIndex="0"></div>;

文字列リテラルを属性として指定するために引用符を使用できます。
JSXではキャメルケースのプロパティ命名規則を使用します。
キャメルケースとは、アルファベットで複合語やフレーズを表記する際、各単語や要素語の先頭の文字を大文字で表記する手法のことです。
例えばtabindexはtabIndexとなります。

const title = response.potentiallyMaliciousInput;
// This is safe:
const element = <h1>{title}</h1>;

RectDOMはJSXに埋め込まれた値をレンダリングされる前にエスケープするので、
ユーザーの入力したあらゆるコードが注入できないことが保証されています。
PHPで言うところのhtmlspecialchars()のようなものが、JSXでは担保されているということでしょうか。

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

rtcstats-wrapperとChart.jsでWebRTC統計情報を可視してみた

これはなに?

前回の記事

https://qiita.com/monmee/items/77310482b0a6a034a887

で紹介したrtcstats-wrapperですが、具体的にどう使えるん?みたいなところは紹介してなかったので、一例を簡単に作ってみた記事。

rtcstats-wrapperの想定される使い方として
- 例えば、アプリ内にwebrtc-internal的な統計グラフを表示、ユーザに通信状況をFBする
- 例えば、パケットロス率が異常値に達したらSlackに通知するなどのWebRTC通信の監視体制を作る
- 例えば、取得した統計情報とユーザIDを紐付けて、各ユーザの通信環境パフォーマンスの解析材料とする
などがあります。

今回は一番上の

例えば、アプリ内にwebrtc-internal的な統計グラフを表示、ユーザに通信状況をFBする

をやってみました。

できたもの

Gistにソースコードをupしています。
とりあえず動かしたれ!精神でやってる気合のコードでお見苦しいところもあるかと思いますが参考程度に見てください。

index.html
sciprt.js
config.js

以下画像が完成物のガワです。

左がSafari、右がFirefoxです。定期的にstatsを取得してグラフをリアタイで動かしてます。
画像では受信/送信トラフィックのみ表示してますが、rtcstats-wrapperから取得できる値であれば他の統計情報も取れます。(本当に取れるかは各ブラウザのgetStats()実装状況によるけど)

ミニ chrome://webrtc-internals が他ブラウザでも見れるという感じです。

必要なもの

rtcstats-wrapper

各ブラウザのgetStats()を標準化して統計情報の瞬間値を計測してくれるラッパー。

WebRTCアプリ

rtcstats-wrapperは現在ブラウザP2Pのみ対応です。
SkyWayのサンプルアプリを使いました。

データ可視化ライブラリ

今回使った外部ライブラリはこれ。
- Chart.js
- chartjs-plugin-streaming
- moment.js

時系列データをリアルタイムにブラウザに出力できれば何でも良いと思います。
例えば、グラフ描画系ならChart.js以外にも、d3.jsGoogle Chartらへんが候補としてありそうです。
今回はリアルタイムデータ向けChart.jsプラグインのchartjs-plugin-streamingをChart.jsと併用しています。

時間軸がスムーズに動かなかったりDeprecated Warningがコンソール表示されるあたり、chartjs-plugin-streamingはChart.js v2.8.0以降に対応してなさそうなので、今回はChart.js v2.7.3を使用しました。

Chart.jsで時系列グラフを作るためにmoment.jsも必要らしいのでそちらも必要。

前準備

使用するライブラリをダウンロードします。
Vanilla JSでimport/export文を使えるように最後の2ファイルにはtype="module"を追加しています。
config.jsについては後述します。

index.html
<script src="/node_modules/rtcstats-wrapper/dist/rtcstats-wrapper.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/moment.js/2.24.0/moment.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/chart.js@2.7.3/dist/Chart.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/chartjs-plugin-streaming@latest/dist/chartjs-plugin-streaming.min.js"></script>
<script src="//cdn.webrtc.ecl.ntt.com/skyway-latest.js"></script>
<script src="../_shared/key.js"></script>
<script type="module" src="./script.js"></script>
<script type="module" src="./config.js"></script>

サンプルアプリ上にChartを生成する

兎にも角にもChartを作ります。
詳しくはChart.js公式ドキュメントをご覧になってください...なんですが、Chartを生成するためには
- canvas要素を作成する
- canvas要素とChart用の設定オブジェクトを引数に渡してnew Chart()する
必要があります。

この処理をサンプルコードに追加します。

index.html
<div class="chart" id="js-chart"></div>
script.js
const Chart = window.Chart;

// ...

const ctx = document.createElement('canvas');
ctx.id = 'canvas_' + key;
chartArea.appendChild(ctx);

const chart = new Chart(ctx, value);

Chart用configはまだ作っていないので、まだこのコードは動きません。
config.jsというChart用設定ファイルを作っています。

Chart用設定ファイルを作成する

Chart.jsとchartjs-plugin-streamingの公式ドキュメントに従って書いていきます。

config.js
// 簡略化のため一部のみ抜粋

const defaultConfig = {
  type: 'line',
  data: {
    datasets: [],
  },
  options: {
    title: {
      display: true,
    },
    scales: {
      xAxes: [
        {
          type: 'realtime',
          time: {
            unit: 'minute',
          },
        },
      ],
      yAxes: [
        {
          ticks: {
            beginAtZero: true,
            sampleSize: 5,
          },
        },
      ],
    },
    plugins: {
      streaming: {
        duration: 180000, // 180000ミリ秒(5分)のデータを表示
        refresh: 5000,
        frameRate: 5,
      },
    },
  },
};

const RECEIVED_TRAFFIC = deepCopy({}, defaultConfig);

RECEIVED_TRAFFIC.data.datasets = [
  {
    label: 'audio',
    data: [],
    backgroundColor: 'rgba(0, 0, 0, 0)',
    borderColor: 'rgba(255, 99, 132, 0.5)',
    pointRadius: 1,
  },
  {
    label: 'video',
    data: [],
    backgroundColor: 'rgba(0, 0, 0, 0)',
    borderColor: 'rgba(54, 162, 235, 0.5)',
    pointRadius: 1,
  },
];
RECEIVED_TRAFFIC.options.title.text = '受信ビットレート(Kbps)';
export const config = {
  RECEIVED_TRAFFIC,
  // ...
}

function deepCopy(src, dst) {
  return Object.assign(src, JSON.parse(JSON.stringify(dst)));

大事なのはdefaultConfigの設定部分。
webrtc-internalライクなチャートを表示したいのであれば、横軸は時間軸、縦軸は統計値のリアルタイム折れ線チャートを作る必要があるため、その設定を書きます。
あとは取りたい各統計情報にdefaultConfigをディープコピーして、それぞれデータラベルや折れ線の色を調整したりタイトルを設定したりするなどの細かい作業です。

描画処理を書く

先程の設定ファイルをimportして、描画処理を書きます。
updateChart 関数がそれに当たります。

script.js
// 簡略化のため一部のみ抜粋
import { config } from './config.js';

const { RTCStatsMoment } = window.RTCStatsWrapper;
const Chart = window.Chart;
const Peer = window.Peer;

const charts = new Map();
let timerId;

(async function main() {
  const chartArea = document.getElementById('js-chart');

  // ...
  // New each charts for WebRTC stats
  for (const [key, value] of Object.entries(config)) {
    const ctx = document.createElement('canvas');
    ctx.id = 'canvas_' + key;
    chartArea.appendChild(ctx);

    const chart = new Chart(ctx, value);
    charts.set(key, chart);
  }

  // Register caller handler
  callTrigger.addEventListener('click', () => {
    // ...
    mediaConnection.on('stream', async stream => {
      // ...
      // Update chart for stats
      timerId = _updateCharts(mediaConnection);
    });

    mediaConnection.once('close', () => {
      // ...
      // Stop drawing for stats
      clearInterval(timerId);
    });

    closeTrigger.addEventListener('click', () => {
      // ...
      clearInterval(timerId);
    });
  });

  peer.once('open', id => (localId.textContent = id));

  // Register callee handler
  peer.on('call', mediaConnection => {
    // ...    
    mediaConnection.on('stream', async stream => {
      // ...
      timerId = _updateCharts(mediaConnection);
    });

    mediaConnection.once('close', () => {
      // ...
      clearInterval(timerId);
    });

    closeTrigger.addEventListener('click', () => {
      // ...
      clearInterval(timerId);
    });
  });

  // ...

  async function _updateCharts(mc) {
    const moment = new RTCStatsMoment();
    const peerConnection = await mc.getPeerConnection();
    return setInterval(async () => {
      const stats = await peerConnection.getStats();
      moment.update(stats);
      const report = moment.report();

      for (const [key, chart] of charts) {
        switch (key) {
          case 'RECEIVED_TRAFFIC':
            chart.data.datasets[0].data.push({
              t: new Date(),
              y: report.receive.audio.bitrate / 1024, // bps -> Kbps
            });
            chart.data.datasets[1].data.push({
              t: new Date(),
              y: report.receive.video.bitrate / 1024,
            });
            break;
          case 'SENT_TRAFFIC':
          // ...

          default:
            console.warn('No value!');
            break;
        }
        chart.update();
      }
    }, 5000);
  }
})();

引数にMediaConnectionを持っていたり、関数名と直接関係のない処理を書いていたり、計算処理がハードコードだったりと、かなりイケてないですがやりたかったことを伝えると...。
仕組みとしては、
1. 取得したchartをMap Objectに格納し
2. 5秒ごとにgetStats()
3. 標準化したStatsの瞬間値を取得し
4. for-switch文でchartの種類を判別して
5. それぞれ適したデータ{t, y}をchartのdataにPush

しています。この処理により、各statsごとにリアルタイムチャートを表示させることが可能です。

まとめというか感想

  • 雑なQiitaになってしまいすみませんでした!
  • 各ブラウザアプリ内にstatsグラフを表示させることは可能です
  • chartjs-plugin-streamingがここ数ヶ月メンテされていない雰囲気なので運用レベルで可視化を考えるなら他のツールのほうが良いかも
  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

rtcstats-wrapperとChart.jsでWebRTC統計情報を可視化してみた

これはなに?

前回の記事

https://qiita.com/monmee/items/77310482b0a6a034a887

で紹介したrtcstats-wrapperですが、具体的にどう使えるん?みたいなところは紹介してなかったので、一例を簡単に作ってみた記事。

rtcstats-wrapperの想定される使い方として
- 例えば、アプリ内にwebrtc-internal的な統計グラフを表示、ユーザに通信状況をFBする
- 例えば、パケットロス率が異常値に達したらSlackに通知するなどのWebRTC通信の監視体制を作る
- 例えば、取得した統計情報とユーザIDを紐付けて、各ユーザの通信環境パフォーマンスの解析材料とする
などがあります。

今回は一番上の

例えば、アプリ内にwebrtc-internal的な統計グラフを表示、ユーザに通信状況をFBする

をやってみました。

できたもの

Gistにソースコードをupしています。
とりあえず動かしたれ!精神でやってる気合のコードでお見苦しいところもあるかと思いますが参考程度に見てください。

index.html
sciprt.js
config.js

以下画像が完成物のガワです。

左がSafari、右がFirefoxです。定期的にstatsを取得してグラフをリアタイで動かしてます。
画像では受信/送信トラフィックのみ表示してますが、rtcstats-wrapperから取得できる値であれば他の統計情報も取れます。(本当に取れるかは各ブラウザのgetStats()実装状況によるけど)

ミニ chrome://webrtc-internals が他ブラウザでも見れるという感じです。

必要なもの

rtcstats-wrapper

各ブラウザのgetStats()を標準化して統計情報の瞬間値を計測してくれるラッパー。

WebRTCアプリ

rtcstats-wrapperは現在ブラウザP2Pのみ対応です。
SkyWayのサンプルアプリを使いました。

データ可視化ライブラリ

今回使った外部ライブラリはこれ。
- Chart.js
- chartjs-plugin-streaming
- moment.js

時系列データをリアルタイムにブラウザに出力できれば何でも良いと思います。
例えば、グラフ描画系ならChart.js以外にも、d3.jsGoogle Chartらへんが候補としてありそうです。
今回はリアルタイムデータ向けChart.jsプラグインのchartjs-plugin-streamingをChart.jsと併用しています。

時間軸がスムーズに動かなかったりDeprecated Warningがコンソール表示されるあたり、chartjs-plugin-streamingはChart.js v2.8.0以降に対応してなさそうなので、今回はChart.js v2.7.3を使用しました。

Chart.jsで時系列グラフを作るためにmoment.jsも必要らしいのでそちらも必要。

前準備

使用するライブラリをダウンロードします。
Vanilla JSでimport/export文を使えるように最後の2ファイルにはtype="module"を追加しています。
config.jsについては後述します。

index.html
<script src="/node_modules/rtcstats-wrapper/dist/rtcstats-wrapper.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/moment.js/2.24.0/moment.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/chart.js@2.7.3/dist/Chart.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/chartjs-plugin-streaming@latest/dist/chartjs-plugin-streaming.min.js"></script>
<script src="//cdn.webrtc.ecl.ntt.com/skyway-latest.js"></script>
<script src="../_shared/key.js"></script>
<script type="module" src="./script.js"></script>
<script type="module" src="./config.js"></script>

サンプルアプリ上にChartを生成する

兎にも角にもChartを作ります。
詳しくはChart.js公式ドキュメントをご覧になってください...なんですが、Chartを生成するためには
- canvas要素を作成する
- canvas要素とChart用の設定オブジェクトを引数に渡してnew Chart()する
必要があります。

この処理をサンプルコードに追加します。

index.html
<div class="chart" id="js-chart"></div>
script.js
const Chart = window.Chart;

// ...

const ctx = document.createElement('canvas');
ctx.id = 'canvas_' + key;
chartArea.appendChild(ctx);

const chart = new Chart(ctx, value);

Chart用configはまだ作っていないので、まだこのコードは動きません。
config.jsというChart用設定ファイルを作っています。

Chart用設定ファイルを作成する

Chart.jsとchartjs-plugin-streamingの公式ドキュメントに従って書いていきます。

config.js
// 簡略化のため一部のみ抜粋

const defaultConfig = {
  type: 'line',
  data: {
    datasets: [],
  },
  options: {
    title: {
      display: true,
    },
    scales: {
      xAxes: [
        {
          type: 'realtime',
          time: {
            unit: 'minute',
          },
        },
      ],
      yAxes: [
        {
          ticks: {
            beginAtZero: true,
            sampleSize: 5,
          },
        },
      ],
    },
    plugins: {
      streaming: {
        duration: 180000, // 180000ミリ秒(5分)のデータを表示
        refresh: 5000,
        frameRate: 5,
      },
    },
  },
};

const RECEIVED_TRAFFIC = deepCopy({}, defaultConfig);

RECEIVED_TRAFFIC.data.datasets = [
  {
    label: 'audio',
    data: [],
    backgroundColor: 'rgba(0, 0, 0, 0)',
    borderColor: 'rgba(255, 99, 132, 0.5)',
    pointRadius: 1,
  },
  {
    label: 'video',
    data: [],
    backgroundColor: 'rgba(0, 0, 0, 0)',
    borderColor: 'rgba(54, 162, 235, 0.5)',
    pointRadius: 1,
  },
];
RECEIVED_TRAFFIC.options.title.text = '受信ビットレート(Kbps)';
export const config = {
  RECEIVED_TRAFFIC,
  // ...
}

function deepCopy(src, dst) {
  return Object.assign(src, JSON.parse(JSON.stringify(dst)));

大事なのはdefaultConfigの設定部分。
webrtc-internalライクなチャートを表示したいのであれば、横軸は時間軸、縦軸は統計値のリアルタイム折れ線チャートを作る必要があるため、その設定を書きます。
あとは取りたい各統計情報にdefaultConfigをディープコピーして、それぞれデータラベルや折れ線の色を調整したりタイトルを設定したりするなどの細かい作業です。

描画処理を書く

先程の設定ファイルをimportして、描画処理を書きます。
updateChart 関数がそれに当たります。

script.js
// 簡略化のため一部のみ抜粋
import { config } from './config.js';

const { RTCStatsMoment } = window.RTCStatsWrapper;
const Chart = window.Chart;
const Peer = window.Peer;

const charts = new Map();
let timerId;

(async function main() {
  const chartArea = document.getElementById('js-chart');

  // ...
  // New each charts for WebRTC stats
  for (const [key, value] of Object.entries(config)) {
    const ctx = document.createElement('canvas');
    ctx.id = 'canvas_' + key;
    chartArea.appendChild(ctx);

    const chart = new Chart(ctx, value);
    charts.set(key, chart);
  }

  // Register caller handler
  callTrigger.addEventListener('click', () => {
    // ...
    mediaConnection.on('stream', async stream => {
      // ...
      // Update chart for stats
      timerId = _updateCharts(mediaConnection);
    });

    mediaConnection.once('close', () => {
      // ...
      // Stop drawing for stats
      clearInterval(timerId);
    });

    closeTrigger.addEventListener('click', () => {
      // ...
      clearInterval(timerId);
    });
  });

  peer.once('open', id => (localId.textContent = id));

  // Register callee handler
  peer.on('call', mediaConnection => {
    // ...    
    mediaConnection.on('stream', async stream => {
      // ...
      timerId = _updateCharts(mediaConnection);
    });

    mediaConnection.once('close', () => {
      // ...
      clearInterval(timerId);
    });

    closeTrigger.addEventListener('click', () => {
      // ...
      clearInterval(timerId);
    });
  });

  // ...

  async function _updateCharts(mc) {
    const moment = new RTCStatsMoment();
    const peerConnection = await mc.getPeerConnection();
    return setInterval(async () => {
      const stats = await peerConnection.getStats();
      moment.update(stats);
      const report = moment.report();

      for (const [key, chart] of charts) {
        switch (key) {
          case 'RECEIVED_TRAFFIC':
            chart.data.datasets[0].data.push({
              t: new Date(),
              y: report.receive.audio.bitrate / 1024, // bps -> Kbps
            });
            chart.data.datasets[1].data.push({
              t: new Date(),
              y: report.receive.video.bitrate / 1024,
            });
            break;
          case 'SENT_TRAFFIC':
          // ...

          default:
            console.warn('No value!');
            break;
        }
        chart.update();
      }
    }, 5000);
  }
})();

引数にMediaConnectionを持っていたり、関数名と直接関係のない処理を書いていたり、計算処理がハードコードだったりと、かなりイケてないですがやりたかったことを伝えると...。
仕組みとしては、
1. 取得したchartをMap Objectに格納し
2. 5秒ごとにgetStats()
3. 標準化したStatsの瞬間値を取得し
4. for-switch文でchartの種類を判別して
5. それぞれ適したデータ{t, y}をchartのdataにPush

しています。この処理により、各statsごとにリアルタイムチャートを表示させることが可能です。

まとめというか感想

  • 雑なQiitaになってしまいすみませんでした!
  • 各ブラウザアプリ内にstatsグラフを表示させることは可能です
  • chartjs-plugin-streamingがここ数ヶ月メンテされていない雰囲気なので運用レベルで可視化を考えるなら他のツールのほうが良いかも
  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

そういえば、JavaScriptでリアルタイム通信のゲームってどうやって作るのってなったとき。

PlayCanvasアドベントカレンダー11日目!

投稿日が遅れてしまいましたが、この記事はPlayCanvasアドベントカレンダー 12月11日の記事となります。
PhotonのJS SDKとゲームエンジンを組み合わせて色々できそうだなと思っていたのでアドベントカレンダーを機にもう一度触ってみます。

HTML5 VTuber LIVEシステムとか作りたい

JavaScriptでリアルタイム通信するゲームを作る

JavaScriptはよく触ってるけど、そういえばリアルタイムの通信/対戦ゲームってどうやって作るのってなった時に参考になるかもしれないです。
PlayCanvasでマルチ対戦のゲームを作るために今回はPhotonというネットワークエンジンを使用して作成します。役割としては描画の部分はPlayCanvas、リアルタイム通信の部分についてはPhotonを使用して作っていきます。

今回デモとして作成したこちらのゲームは[WASD]で移動ができるシンプルなゲームとなっております。

PlayCanvasについて

PlayCanvasはJavaScriptで記述されたWebGLのエンジンで、最近GitHubのスター数が5000を超えました。
OSSのEngineと、SaaS型のエディターを兼ね備えているゲームエンジンです。engineについては、Three.js, Babylon.jsなどに似た使い方を使用することができます。エディターはビジュアルエディターとコードエディターがあり、エディターはクラウド上でプロジェクトの作成から公開までが出来るもので、FBXOBJといった3Dモデルの素材については、ブラウザへドラッグアンドドロップをすることでGUIから操作・配置ができるエディターとなっております。

Photonについて

Photonについては、Unity多く使われているようですが、JavaScriptのSDKもありますので、Photon JavaScript SDKを使用してマルチ対戦のゲームを作っていきます。

Photonは20CCU(同時接続)までであれば、無料で使用できます。

https://www.photonengine.com/ja/photon

PlayCanvasとPhotonを組み合わせる

demo123.png

PlayCanvasとPhotonを組み合わせるにはこちらのリポジトリを参考になります。

https://github.com/utautattaro/Photon-for-PlayCanvas

Photon x PlayCanvasでできること

3Dや2Dのリアルタイムゲームを作ることができます。こちらのタンクのゲームは4人がリアルタイム1つのルームに入り対戦できるゲームです。

プロジェクトをフォークして作成する

上記のリポジトリを参考に、自分でプロジェクトを使用して作ることもできますが、PlayCanvasには便利なForkという機能があるのでそちらを利用して作ってみます。

1. Forkする

こちらのデモ用のPlayCanvasのプロジェクトはPublicなプロジェクトとなっておりますので、PlayCanvasにログインしアクセスしていただくと、Forkできます。

2. PlayCanvas EditorのAppIdを設定する

今回簡単なプロジェクトを作る上では、AppIdを設定することができればマルチプレイ対戦ができるようになっています。AppIdの設定にはPhotonのIDを作る必要があるので、設定します。

a. Photonに登録する
  1. Photon 公式サイトにアクセスして登録をします。
b. PhotonからAppId取得する
  1. Photonのダッシュボードから新規アプリを作成し、AppIdを取得します。
c. AppIdをPlayCanvas上に設定する
  1. Forkしたプロジェクトに入る
  1. コピーしたAppIdを貼り付ける
PhotonのJavaScriptSDKをダウンロード

PlayCanvasでPhotonを使用するためにJavaScriptのSDKをダウンロードします。

  1. PhotonのSDKの最新版をダウンロード

https://www.photonengine.com/ja/sdks  

① SDKを解凍し\photon-javascript-sdk_v4-1-0-1\libの中にあるPhoton-Javascript_SDKをPlayCanvasにアップロード

② SETTINGSをクリック

③ Photon-Javascript_SDKのSCRIPT LOADIGN ORDERを一番上に!

しかしこのままだとPlayCanvasで使用する際にPhotonのSDKを読み込む前にPhotonのAPIを使用するプログラムが書いてあるため、SETTIGNSからSCRIPTのLOADING ORDERを変更します。

ゲームを起動する

PlayCanvasではエディター上には開発時に使用するLaunchとリリースする際に使用するPublishの2つの機能があります。

a. Launchで確認する

開発時にはこちらのLaunchボタンを押して確認します。

スクリプトについて

init.js

起動Photonの起動を行っています。index.html, style.cssを読み込みUIのセットアップも行います。

app.js

app.jsにてPhotonとPlayCanvasの同期をしています。Photonには様々なライフサイクルで発行されるイベントがありますが、今回使用したものについて説明します。

/*jshint esversion: 6, asi: true, laxbreak: true*/
const App = pc.createScript('app');

App.prototype.initialize = function() {
    this.photon = this.app.root.children[0].photon;
    this.photon.setLogLevel(999);
    this.photon.onJoinRoom = () => {
        Object.values(this.photon.actors).map(event => {
            const { isLocal, actorNr } = event;
            if (isLocal) {
            } else {
                const entity = new pc.Entity();
                entity.addComponent("model", {
                    type: "box"
                });
                entity.setPosition(0, 1, 0);
                entity.tags.add(`${actorNr}`);
                this.app.root.addChild(entity);
            }
        });
    };

    this.photon.onActorJoin = event => {
        const { isLocal, actorNr } = event;
        if (isLocal) {
        } else {
            const entity = new pc.Entity();
            entity.addComponent("model", {
                type: "box"
            });
            entity.setPosition(0, 1, 0);
            entity.tags.add(`${actorNr}`);
            this.app.root.addChild(entity);
        }
    };

    this.photon.onEvent = (code, content, actorNr) => {
        const entities = this.app.root.findByTag(`${actorNr}`);
        const [entity] = entities;

        switch (code) {
            case 1: {
                const { x, y, z } = content;
                entity.setLocalPosition(x, y, z);
                break;
            }
            case 2: {
                const { x, y, z, w } = content;
                entity.setLocalRotation(x, y, z, w);
                break;
            }
            default: {
                break;
            }
        }
    };
};
photon.onJoinRoom

onJoinRoomはルームに入った際の処理になります。今回のような作りでは呼ばれるタイミング
- ルームを新しく作ったとき
- 既存のルームに入る時

  1. ルームに入った時
  2. 他のプレイヤーを取得
  3. 新しいエンティティ("Model:Box")としてゲーム画面に配置
// 1.ルームに入った時
this.photon.onJoinRoom = () => {
    //2. 他のプレイヤーを取得
    Object.values(this.photon.actors).map(event => {
        const { isLocal, actorNr } = event;
        if (isLocal) {
        } else {
            // 新しいエンティティを作成
            const entity = new pc.Entity();
            entity.addComponent("model", {
                type: "box"
            });
            // 新しく生成したエンティティのポジションの設定とタグを付与している
            entity.setPosition(0, 1, 0); // x, y, z
            entity.tags.add(`${actorNr}`);
            // PlayCanvasの画面上に配置する
            //3. 新しいエンティティ("Model:Box")としてゲーム画面に配置
            this.app.root.addChild(entity);
        }
    });
};
photon.onActorJoin

onActorJoinは自分の入っているルームに他のプレイヤーが参加してきたときの処理になります。

  1. 他のプレイヤーが入ってきた時
  2. 新しいエンティティを作成
  3. 新しいエンティティ("Model:Box")としてゲーム画面に配置
// 1.他のプレイヤーが入ってきた時
this.photon.onActorJoin = event => {
    // isLocal: 自分であるかの判定
    // actorNr: 入った順に1から振られる、ユニークな番号
    const { isLocal, actorNr } = event;
    // 自分自身であるかの判定
    if (isLocal) {
    } else {
        //  2. 新しいエンティティを作成
        const entity = new pc.Entity();
        entity.addComponent("model", {
            type: "box"
        });
        // ポジションとタグを設定
        entity.setPosition(0, 1, 0);
        entity.tags.add(`${actorNr}`);
        // 3. 新しいエンティティ("Model:Box")としてゲーム画面に配置
        this.app.root.addChild(entity);
    }
};

photon.onEvent

onEventは、今回はplayer.jsにより、データが送信された場合に呼び出されます。

  1. actorNrのタグを元にエンティティを検索
  2. CodeによってPositionRotationかの判定を行う
this.photon.onEvent = (code, content, actorNr) => {
    // 1. `actorNr`のタグを元に`エンティティ`を検索
    const entities = this.app.root.findByTag(`${actorNr}`);
    const [entity] = entities;

    //2. `Code`によって`Position`か`Rotation`かの判定を行う
    switch (code) {
        case 1: {
            const { x, y, z } = content;
            entity.setLocalPosition(x, y, z);
            break;
        }
        case 2: {
            const { x, y, z, w } = content;
            entity.setLocalRotation(x, y, z, w);
            break;
        }
        default: {
            break;
        }
    }
};

player.js

Player.jsはキーボードの操作が押された際にPhotonサーバーにデータを送るものになります。

/*jshint esversion: 6, asi: true, laxbreak: true*/
const Player = pc.createScript("player");

// PlayCanvas Editor上で使用するためにAttributesを作成
// Attributesの説明
// https://developer.playcanvas.com/ja/user-manual/scripting/script-attributes/

Player.attributes.add("moveSpeed", { type: "number", default: 0.1 });
Player.attributes.add("rotateSpeed", { type: "number", default: 2 });

const move = (direction, entity, self) => {
    const { photon } = self;
    switch (direction) {
        case "up": {
            entity.translateLocal(0, 0, -self.moveSpeed);
            break;
        }
        case "down": {
            entity.translateLocal(0, 0, self.moveSpeed);
            break;
        }
        default: {
            break;
        }
    }
    // send position
    // コード番号 1で現在のEntityのポジションを送信
    photon.raiseEvent(1, entity.getLocalPosition());
};

const rotate = (direction, entity, self) => {
    const { photon } = self;

    switch (direction) {
        case "left": {
            entity.rotate(0, -self.rotateSpeed, 0);
            break;
        }
        case "right": {
            entity.rotate(0, self.rotateSpeed, 0);
            break;
        }
        default: {
            break;
        }
    }
    // send rotation
    // コード番号 2で現在のEntityのポジションを送信
    photon.raiseEvent(2, entity.getLocalRotation());
};

Player.prototype.initialize = function () {
    this.photon = this.app.root.children[0].photon;
    if (this.app.touch) {
        this.app.touch.on(
            pc.EVENT_TOUCHSTART,
            () => {
                const { photon } = this
                const { x, y, z } = this.entity.getPosition();
                this.entity.rigidbody.teleport(x, y + 0.5, z);
                photon.raiseEvent(1, this.entity.getLocalPosition());

                photon.raiseEvent(2, this.entity.getLocalRotation());


            },
            null
        );
    }
};


// update code called every frame
Player.prototype.update = function (dt) {
    const { keyboard } = this.app;
    // 移動のキーが押されていたら
    if (keyboard.isPressed(pc.KEY_W) || keyboard.isPressed(pc.KEY_UP)) {
        move("up", this.entity, this);
    } else if (keyboard.isPressed(pc.KEY_S) || keyboard.isPressed(pc.KEY_DOWN)) {
        move("down", this.entity, this);
    }

    // 回転のキーが押されたら
    if (keyboard.isPressed(pc.KEY_A) || keyboard.isPressed(pc.KEY_LEFT)) {
        rotate("right", this.entity, this);
    } else if (keyboard.isPressed(pc.KEY_D) || keyboard.isPressed(pc.KEY_RIGHT)) {
        rotate("left", this.entity, this);
    }
};

photon.raiseEvent

RPCs and RaiseEvent

raiseEventを使用して任意のタイミングでデータの送信を行います。今回は移動が行われた時と回転が行われた際に現在の場所を送信しています。

raiseEvent(eventCode, data, options)

型はそれぞれ

 eventCode:number
 object: object
 options: object
移動時
    ....
    // send position
    // コード番号 1で現在のEntityのポジションを送信
    photon.raiseEvent(1, entity.getLocalPosition());
};

回転時
    ...
    // send rotation
    // コード番号 2で現在のEntityのポジションを送信
    photon.raiseEvent(2, entity.getLocalRotation());
};
参考

raiseEvent - Photon Document

initialize

PlayCanvasのスクリプトのライフサイクルには、スクリプト一度だけ呼び出されるinitialize、毎フレーム呼び出されるUpdateがあります。

  1. Photonのプロパティを代入
  2. タッチされた際に発火するイベントの定義
Player.prototype.initialize = function () {
    // Rootエンティティからphotonプロパティを取得
    // 1. Photonのプロパティを代入
    this.photon = this.app.root.children[0].photon;

    // タッチ操作が可能だったら
    if (this.app.touch) {

    // タッチ操作が行われたら
    // PlayCanvas: タッチについて
    // https://support.playcanvas.jp/hc/ja/articles/227190908

    // 2. タッチされた際に発火するイベントの定義
    // PlayCanvas: イベント種類について
    // https://developer.playcanvas.com/ja/user-manual/scripting/communication/
        this.app.touch.on(
            pc.EVENT_TOUCHSTART,
            () => {
                const { photon } = this
                const { x, y, z } = this.entity.getPosition();
                this.entity.rigidbody.teleport(x, y + 0.5, z);
                // コード1で現在のポジションを送信
                photon.raiseEvent(1, this.entity.getLocalPosition());
                // コード2で現在の回転を送信
                photon.raiseEvent(2, this.entity.getLocalRotation());


            },
            null
        );
    }
};
update

updateはフレームごとに呼ばれます

  1. 移動のキーが押されていたら
  2. 回転のキーが押されたら
  3. Player.prototype.update = function (dt) {
    const { keyboard } = this.app;
    // 1. 移動のキーが押されていたら
    if (keyboard.isPressed(pc.KEY_W) || keyboard.isPressed(pc.KEY_UP)) {
        // 移動
        move("up", this.entity, this);
    } else if (keyboard.isPressed(pc.KEY_S) || keyboard.isPressed(pc.KEY_DOWN)) {
        // 移動
        move("down", this.entity, this);
    }
    
    // 2. 回転のキーが押されたら
    if (keyboard.isPressed(pc.KEY_A) || keyboard.isPressed(pc.KEY_LEFT)) {
        // 回転
        rotate("right", this.entity, this);
    } else if (keyboard.isPressed(pc.KEY_D) || keyboard.isPressed(pc.KEY_RIGHT)) {
        // 回転
        rotate("left", this.entity, this);
    }
    };
    
起動

ゲーム起動するとindex.htmlstyle.cssに定義されているUIと、ゲームが表示されます。

b. PublishをしてURLを共有する

Launchはログインしているユーザーしか閲覧できないのでこちらから公開用のURLを発行することで、インターネット上で共有できます。

おわりに

リアルタイムの通信をするゲームもかなり簡単に作れるそうですね。
もう少し興味がある方は、PhotonSDKのAPIはplayer.jsで記述されているのでそちらをいじると色々できます。

→ player.jsをいじった記事を書きました
そういえば、JavaScriptでリアルタイム通信のゲームってどうやって作るのってなったとき。その2

https://qiita.com/yushimatenjin/items/eb4311c3640a20bbdfb9

以下PlayCanvas開発で参考になりそうな記事の一覧です。

入門
- PlayCanvas入門- モデルの作成~ゲームに入れ込むまで
- JavaScriptでスロットを実装する。【PlayCanvas】
- 3Dモデルのビューワーを3分で作る【初めてのPlayCanvas】
- PlayCanvasのコードエディターでes6に対応する
- Gulpのプラグインを書いたらPlayCanvasでの開発がめちゃくちゃ便利になった
- PlayCanvas Editorに外部スクリプトを読み込む新機能が追加されたので開発方法を考える。- Reduxを組み込む

その他の記事はこちらになります。
- AR年賀状を作る
- React Native + PlayCanvasを使ってスマートフォンゲームを爆速で生み出す
- PlayCanvasのエディター上でHTML, CSSを組み込む方法
- 【iOS13】新しくなったWebVRの使い方

PlayCanvasのユーザー会のSlackを作りました!

少しでも興味がありましたら、ユーザー同士で解決・PlayCanvasを推進するためのSlackを作りましたので、もしよろしければご参加ください!

日本PlayCanvasユーザー会 - Slack

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

そういえば、JavaScriptでリアルタイム通信のゲームってどうやって作るのってなったとき。[PlayCanvas x Photon]

PlayCanvasアドベントカレンダー11日目!

投稿日が遅れてしまいましたが、この記事はPlayCanvasアドベントカレンダー 12月11日の記事となります。
PhotonのJS SDKとゲームエンジンを組み合わせて色々できそうだなと思っていたのでアドベントカレンダーを機にもう一度触ってみます。

HTML5 VTuber LIVEシステムとか作りたい

JavaScriptでリアルタイム通信するゲームを作る

JavaScriptはよく触ってるけど、そういえばリアルタイムの通信/対戦ゲームってどうやって作るのってなった時に参考になるかもしれないです。
PlayCanvasでマルチ対戦のゲームを作るために今回はPhotonというネットワークエンジンを使用して作成します。役割としては描画の部分はPlayCanvas、リアルタイム通信の部分についてはPhotonを使用して作っていきます。

今回デモとして作成したこちらのゲームは[WASD]で移動ができるシンプルなゲームとなっております。

PlayCanvasについて

PlayCanvasはJavaScriptで記述されたWebGLのエンジンで、最近GitHubのスター数が5000を超えました。
OSSのEngineと、SaaS型のエディターを兼ね備えているゲームエンジンです。engineについては、Three.js, Babylon.jsなどに似た使い方を使用することができます。エディターはビジュアルエディターとコードエディターがあり、エディターはクラウド上でプロジェクトの作成から公開までが出来るもので、FBXOBJといった3Dモデルの素材については、ブラウザへドラッグアンドドロップをすることでGUIから操作・配置ができるエディターとなっております。

Photonについて

Photonについては、Unity多く使われているようですが、JavaScriptのSDKもありますので、Photon JavaScript SDKを使用してマルチ対戦のゲームを作っていきます。

Photonは20CCU(同時接続)までであれば、無料で使用できます。

https://www.photonengine.com/ja/photon

PlayCanvasとPhotonを組み合わせる

demo123.png

PlayCanvasとPhotonを組み合わせるにはこちらのリポジトリを参考になります。

https://github.com/utautattaro/Photon-for-PlayCanvas

Photon x PlayCanvasでできること

3Dや2Dのリアルタイムゲームを作ることができます。こちらのタンクのゲームは4人がリアルタイム1つのルームに入り対戦できるゲームです。

プロジェクトをフォークして作成する

上記のリポジトリを参考に、自分でプロジェクトを使用して作ることもできますが、PlayCanvasには便利なForkという機能があるのでそちらを利用して作ってみます。

1. Forkする

こちらのデモ用のPlayCanvasのプロジェクトはPublicなプロジェクトとなっておりますので、PlayCanvasにログインしアクセスしていただくと、Forkできます。

2. PlayCanvas EditorのAppIdを設定する

今回簡単なプロジェクトを作る上では、AppIdを設定することができればマルチプレイ対戦ができるようになっています。AppIdの設定にはPhotonのIDを作る必要があるので、設定します。

a. Photonに登録する
  1. Photon 公式サイトにアクセスして登録をします。
b. PhotonからAppId取得する
  1. Photonのダッシュボードから新規アプリを作成し、AppIdを取得します。
c. AppIdをPlayCanvas上に設定する
  1. Forkしたプロジェクトに入る
  1. コピーしたAppIdを貼り付ける
PhotonのJavaScriptSDKをダウンロード

PlayCanvasでPhotonを使用するためにJavaScriptのSDKをダウンロードします。

  1. PhotonのSDKの最新版をダウンロード

https://www.photonengine.com/ja/sdks  

① SDKを解凍し\photon-javascript-sdk_v4-1-0-1\libの中にあるPhoton-Javascript_SDKをPlayCanvasにアップロード

② SETTINGSをクリック

③ Photon-Javascript_SDKのSCRIPT LOADIGN ORDERを一番上に!

しかしこのままだとPlayCanvasで使用する際にPhotonのSDKを読み込む前にPhotonのAPIを使用するプログラムが書いてあるため、SETTIGNSからSCRIPTのLOADING ORDERを変更します。

ゲームを起動する

PlayCanvasではエディター上には開発時に使用するLaunchとリリースする際に使用するPublishの2つの機能があります。

a. Launchで確認する

開発時にはこちらのLaunchボタンを押して確認します。

起動

ゲーム起動するとindex.htmlstyle.cssに定義されているUIと、ゲームが表示されます。

b. PublishをしてURLを共有する

Launchはログインしているユーザーしか閲覧できないのでこちらから公開用のURLを発行することで、インターネット上で共有できます。

おわりに

リアルタイムの通信をするゲームもかなり簡単に作れるそうですね。
もう少し興味がある方は、PhotonSDKのAPIはplayer.jsで記述されているのでそちらをいじると色々できます。

以下PlayCanvas開発で参考になりそうな記事の一覧です。

入門
- PlayCanvas入門- モデルの作成~ゲームに入れ込むまで
- JavaScriptでスロットを実装する。【PlayCanvas】
- 3Dモデルのビューワーを3分で作る【初めてのPlayCanvas】
- PlayCanvasのコードエディターでes6に対応する
- Gulpのプラグインを書いたらPlayCanvasでの開発がめちゃくちゃ便利になった
- PlayCanvas Editorに外部スクリプトを読み込む新機能が追加されたので開発方法を考える。- Reduxを組み込む

その他の記事はこちらになります。
- AR年賀状を作る
- React Native + PlayCanvasを使ってスマートフォンゲームを爆速で生み出す
- PlayCanvasのエディター上でHTML, CSSを組み込む方法
- 【iOS13】新しくなったWebVRの使い方

PlayCanvasのユーザー会のSlackを作りました!

少しでも興味がありましたら、ユーザー同士で解決・PlayCanvasを推進するためのSlackを作りましたので、もしよろしければご参加ください!

日本PlayCanvasユーザー会 - Slack

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

スプラトゥーン2のガチマ情報を常に見たかった

表題を悩んだ結果、PWAでホーム画面にアイコン追加でいっか。

※mithrilを使う必要性がなくなったのでmithrilは別途…

できたもの

https://pwa.manic-design-lab.dev/
Screen Shot 2019-12-13 at 3.38.41 PM.png

やりたかったこと

push7というASPを利用させていただき、push通知が飛ばせるように
・ホーム画面へ追加
・手動でmanifest.json、service-workerを書かないでwebpackのpluginだけで完結させる

できなかったこと

・キャッシュコントールが雑多で終わった
・ゆえのオフライン時のアップデート挙動の確立
・細かい設計
ガチマ潜りすぎてでき(ry

力添えいただいた

・splatoon2ステージ情報APIを作ってくださっている方

リポジトリはこちら

https://github.com/Gits-migii/pwa_spla

以下ServiceWorker + manifest.json周りの説明

new GenerateSW({
  swDest: 'sw.js',
  clientsClaim: true,
  skipWaiting: true,
  importScripts:['push7-worker.js'],
  runtimeCaching: [{
    urlPattern: '/images/stage/',
    handler: 'NetworkFirst',
    options: {
      networkTimeoutSeconds: 10,
      cacheName: 'my-api-cache',
      expiration: {
        maxEntries: 64,
        maxAgeSeconds: 60,
      },
      backgroundSync: {
        name: 'my-queue-name',
        options: {
          maxRetentionTime: 60 * 60,
        },
      },
      cacheableResponse: {
        statuses: [0, 200],
        headers: {'x-test': 'true'},
      },
      broadcastUpdate: {
        channelName: 'my-update-channel',
      },
      fetchOptions: {
        mode: 'no-cors',
      },
      matchOptions: {
        ignoreSearch: true,
      },
    },
  }, {
    urlPattern:new RegExp('^https://app\.splatoon2\.nintendo\.net/'),
    handler: 'StaleWhileRevalidate',
    options: {
      cacheableResponse: {
        statuses: [0, 200]
      }
    }
  }]
})

ほぼworkbox公式のそのままです。
和訳ほしいので他力本願してるところです。

詰みかけたところ

webpack-dev-serverが参照している仮想HTMLにmanifest.jsonの入れ方がわからなくて詰んだ。
HtmlWebpackPluginのinjectをtrueにして解決

webpack-pwa-manifest

こいつを使って、fingerprintなどで開発時のキャッシュ問題をクリアにしようとしていたところ3つくらい追加でpluginを入れる必要があり、めんどくせえ!!!となってfingerprintsをfalseにしておしまい。
単純にmanifestで必要な部分を列挙しています。

所感

workboxがかんたんに使えるようになったので、だいぶPWA化するのは楽になったのではないでしょうか。
iOS、Android間もpush通知くらいまでは落ちましたし。
アドベントカレンダーというものに初めて参加してみましたが、もっと前から準備するべきでした(戒め)
来年はもっと…やるぞおおおおおおおお

乱文失礼しました。

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

今日の日付をJavaScriptで表示させる方法

今日の日付は、JavaScriptで以下の通りに書きます。

      const today = new Date();
      const todayHtml = today.getFullYear() + '/' + (today.getMonth() + 1) + '/' + today.getDate();

      document.write('<p class="date">' + todayHtml + '</p>');

HTMLとJavaScript全体のコードは以下の通りです。

<!DOCTYPE html>
<html lang="en">

<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <meta http-equiv="X-UA-Compatible" content="ie=edge">
  <title>TODAY</title>

  <!--Bootstrap CDN ここから-->
  <!-- Latest compiled and minified CSS -->
  <link rel="stylesheet" href="https://stackpath.bootstrapcdn.com/bootstrap/3.4.1/css/bootstrap.min.css"
    integrity="sha384-HSMxcRTRxnN+Bdg0JdbxYKrThecOKuH5zCYotlSAcp1+c8xmyTe9GYg1l9a69psu" crossorigin="anonymous">

  <!-- Optional theme -->
  <link rel="stylesheet" href="https://stackpath.bootstrapcdn.com/bootstrap/3.4.1/css/bootstrap-theme.min.css"
    integrity="sha384-6pzBo3FDv/PJ8r2KRkGHifhEocL+1X2rVCTTkUfGk7/0pbek5mMa1upzvWbrUbOZ" crossorigin="anonymous">
  <!--Bootstrap CDN ここまで-->
  <link rel="stylesheet" href="styles.css">
</head>

<body>
  <header>
    <h1>TODAY</h1>
  </header>

  <div class="container">
    <script>
      const today = new Date();
      const todayHtml = today.getFullYear() + '/' + (today.getMonth() + 1) + '/' + today.getDate();

      document.write('<p class="date">' + todayHtml + '</p>');
    </script>
</body>

</html>

【プログラマー未経験者の方へ】
ドットインストールが難しくて萎えている方や、模写コーディングが出来なくて悩んでいる方はUdemyがめっちゃおすすめです。
僕はプログラマー未経験者ですが、以下の記事の講座で模写コーディング、さらには自分でコーディングできるまでになりました。
https://greenberet.net/mosya-coding-udemy/
僕はコーディングできるまでかなり苦しみ、何度も挫折しかけましたが、Udemyに助けられました。
参考にしていただければ幸いです。

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

document.writeの使い方

<!DOCTYPE html>
<html lang="en">

<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <meta http-equiv="X-UA-Compatible" content="ie=edge">
  <title>TODAY</title>

  <!--Bootstrap CDN ここから-->
  <!-- Latest compiled and minified CSS -->
  <link rel="stylesheet" href="https://stackpath.bootstrapcdn.com/bootstrap/3.4.1/css/bootstrap.min.css"
    integrity="sha384-HSMxcRTRxnN+Bdg0JdbxYKrThecOKuH5zCYotlSAcp1+c8xmyTe9GYg1l9a69psu" crossorigin="anonymous">

  <!-- Optional theme -->
  <link rel="stylesheet" href="https://stackpath.bootstrapcdn.com/bootstrap/3.4.1/css/bootstrap-theme.min.css"
    integrity="sha384-6pzBo3FDv/PJ8r2KRkGHifhEocL+1X2rVCTTkUfGk7/0pbek5mMa1upzvWbrUbOZ" crossorigin="anonymous">
  <!--Bootstrap CDN ここまで-->
  <link rel="stylesheet" href="styles.css">
</head>

<body>
  <header>
    <h1>TODAY</h1>
  </header>

  <div class="container">
    <script>

      document.write('<p class="date">20xx/1/1</p>');
    </script>

  </div>
</body>
</html>

(''); についての説明

documentは「オブジェクト」 writeは「メソッド」 ('')はパラメータと呼ぶ。
日本語風に言うと、「画面上(オブジェクト)に( )(パラメータ)を書く(メソッド)という意味になる。

const today = new Date(); を使う理由

変数とは設計図を箱の中に入れる作業。

今回の場合、設計図=new Date(); 箱=const today
となる。

【プログラマー未経験者の方へ】
ドットインストールが難しくて萎えている方や、模写コーディングが出来なくて悩んでいる方はUdemyがめっちゃおすすめです。
僕はプログラマー未経験者ですが、以下の記事の講座で模写コーディング、さらには自分でコーディングできるまでになりました。
https://greenberet.net/mosya-coding-udemy/
僕はコーディングできるまでかなり苦しみ、何度も挫折しかけましたが、Udemyに助けられました。
参考にしていただければ幸いです。

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

Angularで、階層構造を持つJSONから入力フォームを動的に生成する

Angularで階層構造を持つJSONから入力フォームを動的に生成するコードを、GitHubで公開しました。

GitHubはこちら
dynamic-form-builder-recursive

画面サンプル

※インデントで階層構造を表しています

入力フォームの設定用JSON

  fields: any[] = [
      {
          "id": "text1",
          "text": "text1",
          "type": "text"
      },
      {
          "id": "radio1",
          "text": "radio1",
          "type": "radio",
          "options": [
              {
                  "text": "option1",
                  "value": "option1"
              },
              {
                  "text": "option2",
                  "value": "option2"
              },
              {
                  "text": "option3",
                  "value": "option3"
              }
          ]
      },
      {
          "id": "checkbox1",
          "text": "checkbox1",
          "type": "checkbox",
          "options": [
              {
                  "text": "option1",
                  "value": "option1"
              },
              {
                  "text": "option2",
                  "value": "option2"
              },
              {
                  "text": "option3",
                  "value": "option3"
              }
          ]
      },
      {
          "id": "text2",
          "text": "text2",
          "type": "text",
          "item": [
              {
                  "id": "text2-1",
                  "text": "text2-1",
                  "type": "text",
                  "item": [
                      {
                          "id": "text2-1-1",
                          "text": "text2-1-1",
                          "type": "text",
                          "item": [
                              {
                                  "id": "text2-1-1-1",
                                  "text": "text2-1-1-1",
                                  "type": "text",
                                  "item": [
                                      {
                                          "id": "text2-1-1-1-1",
                                          "text": "text2-1-1-1-1",
                                          "type": "text"
                                      }
                                  ]

                              }
                          ]
                      }
                  ]
              }
          ]
      }
  ];

参考させていただいたコード

Angular 6 dynamic from builder with Reactive Forms.

参考というかほぼコピペですがwww

ポイント

入力フォームの設定用JSONと同じ階層のFormGroupを作成

  ngOnInit() {
    // FormControl生成
    let fieldsCtrls:any = this.walkJSON(this.fields, function(item) {
      let fieldsCtrl: any = {};
      if (item.type != 'checkbox') {
        fieldsCtrl = new FormControl(item.value || '', Validators.required)
      } else {
        let opts = {};
        for (let opt of item.options) {
          opts[opt.value] = new FormControl('');
        }
        fieldsCtrl = new FormGroup(opts)
      }
      return fieldsCtrl;
    });

    this.form = new FormGroup(fieldsCtrls);
  }

  walkJSON(data, callback){
    const formGroup: any = {};
    data.forEach(item => {

      formGroup[item.id] = callback(item);
      if (item.item) {
        formGroup[item.id + '_child'] = new FormGroup(this.walkJSON(item.item, callback));
      }
    });

    return formGroup;
  }

設定項目の中に子を持つ場合があるので、再帰的に処理します。
FormGroupには必ず名前を付けないといけないので、親のIDに「_child」を付けています。(ダサいのでなんとかしたいですが。。。)

テンプレート側で、コンポーネントを再帰的に呼び出す

  template: `
  <ng-container [formGroup]="form">
    <ng-container [ngSwitch]="field.type">
      <div class="child">
        <textbox *ngSwitchCase="'text'" [field]="field" [form]="form"></textbox>
        <checkbox *ngSwitchCase="'checkbox'" [field]="field" [form]="form"></checkbox>
        <radio *ngSwitchCase="'radio'" [field]="field" [form]="form"></radio>
        <div *ngIf="!isValid && isDirty">{{field.text}} is required</div>
      </div>
    </ng-container>
    <!-- 子を持つ場合 -->
    <ng-container  *ngIf="field.item">
    <div *ngFor="let childField of field.item" class="parent">
        <field-builder [field]="childField" [form]="form.controls[field.id + '_child']"></field-builder>
    </div>
    </ng-container>
  </ng-container>
  `

field-builderコンポーネントから、更にfield-builderコンポーネントを呼び出します。
その際に、同じ階層のFromGroupを指定します。

感想

Angularは、設定ファイルから画面を生成するみたいなことにはあんまり向いてないんじゃないかと思ったけど、ちゃんと仕組みが用意されていた。
学習コストは高いが、慣れれば色々できそうな気がします。

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

jsでポケモン素早さ計算ツールを作ってみました

jsで素早さ計算ツールをつくってみた

ソード・シールドが発売して約1ヶ月がたちましたね。

ランクバトルの方も、1ヶ月という短い期間ですが、
なんとなく環境も固まりつつあるのかなと思います。

その理由は登場するポケモンの少なさでしょう。
前作のサン・ムーンは約800体のポケモンが登場しましたが、
ソード・シールドでは半分の400体(内部データ含めず)。

その中でもカプ・コケコの未登場や、メガゲンガーが使えなくなったことにより、
今まで素早さ調整の基準とされてきた130族のラインが、今作でさほど重要でなくなりました。
※今作で登場する130族はサンダース、コオリッポ(ナイスフェイス)のみ

また、ダイマックス技であるダイジェットの登場により、飛行技を覚えるポケモンの素早さを1段階上げることができます。

実際、ギャラドスなんかも以前は竜舞型が多かったですが、今は飛び跳ねるをダイジェットにして素早さを上げる型が多いです。

何が言いたいかというと、ソード・シールドではより多くのポケモンが素早さを積みやすい環境にあり、
それを前提にした素早さ調整が大事になって来るのではないかと思われます。

そういった時に、手早く素早さを実数値で確認できるツールがあると便利だなぁと思って、
今回JavaScriptで「すばやさ計算ツール」を作ってみることにしました。

いざ、実装をしてみると...
う〜ん、むずかしい!!!

会社の人にアドバイスをもらいながら、コツコツとつくりました。

そしてできたのがコチラ↓↓
すばやさ計算ツール

ソースの中身には自信がありませんが、UIに若干のこだわりアリ!
ファーストビューで実数値が確認できたり、努力値を±1づつボタンで微調整できます。

まだ未完成ですが、とりあえず期限を決めていたのでアップする形になりました...このあと随時バージョンアップしていきます。

そのうちアプリ版も出したいと考えてますので、その時はまた紹介していこうと思います。
参考になるかわかりませんが、最後にコードのせときますね。

js
window.onload = function () {
  //最速出力ボタン
  let saisoku = document.getElementById('saisoku');
  saisoku.addEventListener('click', function (e) {
    //デフォルトのクリック処理をブロックする
    e.preventDefault();
    document.getElementById('doryokuchi').value = '252';
    document.getElementById('seikaku').value = '1.1';
    document.getElementById('kotaichi').value = '31';
    document.getElementById("output_kotaichi").value = "31";
    //計算の関数を呼び出し
    keisan()
  });

  //準速出力ボタン
  let jyunsoku = document.getElementById("jyunsoku");
  jyunsoku.addEventListener("click", function (e) {
    //デフォルトのクリック処理をブロックする
    e.preventDefault();
    document.getElementById("doryokuchi").value = "252";
    document.getElementById("seikaku").value = "1";
    document.getElementById("kotaichi").value = "31";
    document.getElementById("output_kotaichi").value = "31";
    //計算の関数を呼び出し
    keisan()
  });

  //無振出力ボタン
  let mufuri = document.getElementById("mufuri");
  mufuri.addEventListener("click", function (e) {
    //デフォルトのクリック処理をブロックする
    e.preventDefault();
    document.getElementById("doryokuchi").value = "0";
    document.getElementById("seikaku").value = "1";
    document.getElementById("kotaichi").value = "31";
    document.getElementById("output_kotaichi").value = "31";
    //計算の関数を呼び出し
    keisan()
  });

  //下降出力ボタン
  let kakou = document.getElementById("kakou");
  kakou.addEventListener("click", function (e) {
    //デフォルトのクリック処理をブロックする
    e.preventDefault();
    document.getElementById("doryokuchi").value = "0";
    document.getElementById("seikaku").value = "0.9";
    document.getElementById("kotaichi").value = "31";
    document.getElementById("output_kotaichi").value = "31";
    //計算の関数を呼び出し
    keisan()
  });

  //最遅出力ボタン
  let saichi = document.getElementById("saichi");
  saichi.addEventListener("click", function (e) {
    //デフォルトのクリック処理をブロックする
    e.preventDefault();
    document.getElementById("doryokuchi").value = "0";
    document.getElementById("seikaku").value = "0.9";
    document.getElementById("kotaichi").value = "0";
    document.getElementById("output_kotaichi").value = "0";
    //計算の関数を呼び出し
    keisan()
  });

  // 素早さ種族値の取得
  let syuzokuchi = document.getElementById("syuzokuchi");
  // 素早さ種族値を変更したとき
  syuzokuchi.addEventListener("change", function (e) {
    e.preventDefault();
    keisan()
  });
  //個体値を変更した時に再計算
  let kotaichi = document.getElementById("kotaichi");
  kotaichi.addEventListener("change", function (e) {
    e.preventDefault();
    keisan()
  });

  //努力値の取得
  let doryokuchi = document.getElementById("doryokuchi");
  // 努力値を変更したとき
  doryokuchi.addEventListener("change", function (e) {
    e.preventDefault();
    keisan()
  });
  //性格補正値の取得
  let seikaku = document.getElementById("seikaku");
  // 性格補正値を変更したとき
  seikaku.addEventListener("change", function (e) {
    e.preventDefault();
    keisan()
  });
  //レベルの取得
  let level = document.getElementById("level");
  // レベルを変更したとき
  level.addEventListener("change", function (e) {
    e.preventDefault();
    keisan()
  });

  //道具を変更した時に再計算
  let item = document.getElementsByName("item");
  let itemValue = 1;
  item[0].addEventListener("change", function (e) {
    e.preventDefault();
    if (item[0].checked) {
      itemValue = item[0].value
    }
    keisan()
  });
  item[1].addEventListener("change", function (e) {
    e.preventDefault();
    if (item[1].checked) {
      itemValue = item[1].value
    }
    keisan()
  });
  item[2].addEventListener("change", function (e) {
    e.preventDefault();
    if (item[2].checked) {
      itemValue = item[2].value
    }
    keisan()
  });

  // 上昇値を変更した時に再計算
  let jyousyouchi = document.getElementById("jyousyouchi");
  // 上昇値を変更したとき
  jyousyouchi.addEventListener("change", function (e) {
    e.preventDefault();
    keisan()
  });

  //計算式
  function keisan() {
    let keisan = ((parseInt(syuzokuchi.value, 10) * 2 + parseInt(kotaichi.value, 10) + parseInt(doryokuchi.value, 10) / 4) * (parseInt(level.value, 10) / 100) + 5) * parseFloat(seikaku.value, 10) * parseFloat(itemValue, 10) * parseFloat(jyousyouchi.value, 10);
    let jissuchi = document.getElementById("jissuchi");
    jissuchi.value = parseInt(keisan);
    console.log(kotaichi.value)
  }
}
  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

アプリ管理者がJavaScriptカスタマイズを行うためのプラグイン

Qiitaに書くこと自体がひさしぶりになってしまいました。Spicaです!
機能拡張スタンダードAll-Inというプラグインを販売しております。

最近ハマっているゲームはペルソナ5ロイヤルです。無印のペルソナ5もやりましたが、また最初から遊んでもやっぱり面白いっすね。あと十三機兵防衛圏というのを積んでいます。P5Rの1週目が終わったらとりかかる!

関係ない話はこのへんにして……kintoneの話を。
今回は基本に立ち返って、アプリのJavaScriptカスタマイズを手軽にする方法について書いていこうと思います。
ネタとしては軽めかなと思いましたが、kintoneカスタマイズのハードルを少しだけ下げてみようという試み、ということで。
プラグインも作りましたのでお使いいただければと。

そもそもカスタマイズするためには

JavaScriptやCSSでkintone全体をカスタマイズする(kintoneヘルプ)
にある通り、JavascriptやCSSファイルの読み込みを設定できるのはシステム管理者のみとなっています。

セキュリティ的には仕方ない部分もあるんですが、この縛りさえなければ……! と思ったことある方、多いんじゃないかなと思います。きっと多い。多いんじゃないかな。多いに決まってる。
ちゃんとアプリのアクセス権限を統制して編集者に気をつけるからアプリ管理者にもカスタマイズさせてくれよ、と。

pluginの設定情報にスクリプトを保存したらいいのでは?

実装の仕組みですがしごく単純。
jQueryにglobalEvalというメソッドがあります。
渡された文字列をグローバルのスコープでスクリプトとして実行するというものです。
プラグインにスクリプトを保存して、こいつで実行してしまえというすんぽう。

CSSも編集できるようにしました。
こっちはもっと単純に、<style>タグの中身にしてbodyあたりに要素を追加してやるだけです。

というわけでplugin作りました

icon.png
プラグイン (CustomizeEditor)

プラグインの設定画面はこんな感じです。
kintone_adc_2019_img1.png

なんとなくエディターふうに体裁を整えてますが、ここで書いたスクリプトはevalで実行されるので、デバッグは非常に大変です。
ここはひとつ、kintone開発者ライセンスを申請してですね、自分の環境でスクリプトを作り込んで完成したものをmasterデプロイする感覚で使うのがよきかなと思います。

みんな開発者になればいいんですよ。以上です!

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

ReactFire v2 alphaを試してみる(Auth編)

はじめに

前回記事では、react-firebase-hooksを試したのですが、12月6日のReact Day Berlinでは、ReactFireのプレゼンがありました。こちらはとても先進的で期待できます。

本記事では、Authの部分を移植してみたいと思います。

ReactFireとは

ReactFire v1は2016年5月のリリースを最後に、その後deprecateされ、Firebase JS SDKを直接使うようアナウンスされています。それが2019年7月よりv2として再度開発しているようです。

https://github.com/FirebaseExtended/reactfire

現在、masterブランチがv2になっています。

コーディング

import React, { Suspense, useState, useRef, useEffect } from "react";
import ReactDOM from "react-dom";

import "firebase/auth";
import { FirebaseAppProvider, useAuth, useUser } from "reactfire";

const firebaseConfig = {
  apiKey: "...",
  authDomain: "...",
  databaseURL: "...",
  projectId: "...",
  storageBucket: "...",
  messagingSenderId: "...",
  appId: "..."
};

const Login = () => {
  const auth = useAuth();
  const [email, setEmail] = useState("");
  const [pass, setPass] = useState("");
  const [error, setError] = useState(null);
  const [pending, setPending] = useState(false);
  const mounted = useRef(true);
  useEffect(() => {
    const cleanup = () => {
      mounted.current = false;
    };
    return cleanup;
  }, []);
  const onSubmit = async e => {
    e.preventDefault();
    setError(null);
    setPending(true);
    try {
      await auth().signInWithEmailAndPassword(email, pass);
    } catch (e) {
      console.log(e.message, mounted);
      if (mounted.current) setError(e);
    } finally {
      if (mounted.current) setPending(false);
    }
  };
  return (
    <div>
      <form onSubmit={onSubmit}>
        <input
          type="email"
          value={email}
          onChange={e => setEmail(e.target.value)}
          placeholder="Email..."
        />
        <input
          type="password"
          value={pass}
          onChange={e => setPass(e.target.value)}
          placeholder="Password..."
        />
        <button type="submit">Login</button>
        {pending && "Pending..."}
        {error && `Error: ${error.message}`}
      </form>
    </div>
  );
};

const Logout = () => {
  const auth = useAuth();
  const [pending, setPending] = useState(false);
  const mounted = useRef(true);
  useEffect(() => {
    const cleanup = () => {
      mounted.current = false;
    };
    return cleanup;
  }, []);
  const logout = async () => {
    setPending(true);
    await auth().signOut();
    if (mounted.current) setPending(false);
  };
  return (
    <div>
      <button type="button" onClick={logout}>
        Logout
      </button>
      {pending && "Pending..."}
    </div>
  );
};

const Main = () => {
  const user = useUser();
  if (!user) {
    return <Login />;
  }
  return (
    <div>
      User: {user.email}
      <Logout />
    </div>
  );
};

const App = () => {
  return (
    <FirebaseAppProvider firebaseConfig={firebaseConfig}>
      <Suspense fallback={<div>Loading...</div>}>
        <Main />
      </Suspense>
    </FirebaseAppProvider>
  );
};

const rootElement = document.getElementById("root");
ReactDOM.render(<App />, rootElement);

動きました。nextではだめで、canaryを使う必要がありました。

おわりに

結果的には、前回のコードとそれほど変わっていないのですが、useUserがあるので、自分でcontextを用意しなくていい分は楽になるかもしれません。Suspenseでローディング処理を書かなくていいのも楽ではあります。

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

5000兆円の凄さを実感するためのアプリ

この記事はPONOS Advent Calendar 2019の16日目の記事です。
download (2).png

きっかけ

5000兆円欲しいねって話になって、どれぐらいの量になるんだろうと思って調べたら「積み上げたら5万km」という情報が出てきた。
イマイチ実感が沸かない、きっと誰かがシミュレータを作ってるはずだ!だって3年前、つまり平成のネタだしね!
でも検索しても出てこなかった。。。
よし!作ろう!!!

完成品

こちらです。
https://ackyla.com/5000chouen.html

技術とか

最初はcanvasでもりもり動くものを作ろうかなーとか思ってましたが、時間がないので色々諦めて簡単な作りにしました。
ただhtmlとjavascriptとcssを書いてgithubにおいただけです。
The deep seaの影響を受けました。

最後に

5000兆円はすごい!!
5000兆円欲しい!!!

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

jsで素早さ計算ツールをつくってみた

jsで素早さ計算ツールをつくってみた

ソード・シールドが発売して約1ヶ月がたちましたね。

ランクバトルの方も、1ヶ月という短い期間ですが、
なんとなく環境も固まりつつあるのかなと思います。

その理由は登場するポケモンの少なさでしょう。
前作のサン・ムーンは約800体のポケモンが登場しましたが、
ソード・シールドでは半分の400体(内部データ含めず)。

その中でもカプ・コケコの未登場や、メガゲンガーが使えなくなったことにより
今まで素早さ調整の基準とされてきた130族のラインが、今作ではさほど重要でなくなりました。
※今作で登場する130族はサンダース、コオリッポ(ナイスフェイス)のみ

また、ダイマックス技のダイジェットの登場により、飛行技を覚えるポケモンの素早さを1段階上げることができます。

実際、ギャラドスなんかも以前は竜舞型が多かったですが、今は飛び跳ねるをダイジェットにして素早さを上げる型が大半です。

何が言いたいかというと、ソード・シールドではより多くのポケモンが素早さを手軽に上昇することができ、
それを前提にした素早さ調整が大事になって来るのではないかと思われます。

そういった時に、手早く素早さを実数値で確認できるツールがあると便利だなぁと思って、今回JavaScriptで「すばやさ計算ツール」を作ってみることにしました。

う〜ん、むずかしい!!!

会社の人にアドバイスをもらいながら、コツコツとつくりました。

そしてできたのがコチラ↓↓
subayasa.site

ソースの中身には自信がありませんが、UIには多少こだわっており、ファーストビューで実数値が確認できたり、努力値を±1づつボタンで変更したりできます。

たぶん、他のサイトにはないUIなので、需要は多少なりともあるとは思います。

今回で一旦完成ですが、バージョンアップしたり、アプリ版も出したいと考えてますので、
その時はまた紹介していこうと思います。

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

DataTablesを日本語化したときの留意点

DataTablesについて

DataTablesはHTML上で表(テーブル)を扱う時に非常に便利なJavaScriptのライブラリです。
高機能で、必要になりそうな操作はほとんどがプラグイン形式で用意されています。
そのなかで、1点、重要と思われる留意点を見つけましたので共有したいと思います。

国際化プラグイン(language.urlオプション)はテーブルの初期化を非同期にする

国際化プラグインを使ってUIの日本語化(国際化)を行うと、設定オブジェクトの取得がAjaxで行われるため、$(dom).DataTable()による初期化とそれ以降の処理のタイミングがずれます。
公式ドキュメントにもちゃんと注意書きがありました。

Note that when this parameter is set, DataTables' initialisation will be asynchronous due to the Ajax data load. That is to say that the table will not be drawn until the Ajax request as completed. As such, any actions that require the table to have completed its initialisation should be placed into the initComplete option callback.

つまり、公式ドキュメントで仮に下記のような見本があったとして、

var table = $("#DataTable").DataTable({...});//オプションを渡して初期化
table.xxxApi().doAnythng();//何かのAPIを呼び出してDateTablesを操作する

初期化のオプションにlanguage.urlが含まれていると2行目のAPIの動作は保証されません
DataTablesを使っていて、APIの挙動がおかしい、イベント処理やDOM制御が上手くいないときは、これを疑ってください。

解決策

initCompleteオプションを使う

DataTablesの初期化オプションに用意されているinitCompleteオプションでハンドラを設定して、DataTablesの初期化が完了してから後続の処理を行います。

$("#DataTable").DataTable({
    "language" : {"url":"any.json"},
    "initComplete" : function(settings, json) {
        this.xxxApi().doAnythng();//ここで処理する
    }
});
//以下、DataTablesとは関係しないその他の処理

この場合はthisでAPIにアクセスできますが、別途、関数を定義して実行する場合には$(dom).DataTable()$(dom).dataTable()の違いにも注意が必要です。
ちなみに、このハンドラではDataTableの設定オブジェクトとテーブル内のデータを受け取ることができますので、それらに応じて諸々の処理が可能です。

language.urlを使用しない

素直にlanguageオプションにコードからオブジェクトで設定を渡す方法もあります1。日本語の設定はこのページに記述されています。カスタマイズの際にも参考にしましょう。
この場合は実行タイミングのずれがないため、公式ドキュメントの例の通りに記述しても動作してくれるはずです。

まとめ

DataTablesは便利で高機能ですが、それだけに動作は複雑です。機能とそれに関わるAPIやオプションは、Google先生の翻訳に頼りながらでも一読しておきましょう。
私は拡張機能のButtonsのDOM操作で数時間悩みました。Buttons自体はとても便利な拡張ですよ。


  1. 項目も多いしバージョン変更などがあると対応に追われそうですが… 

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

Salesforce + JavaScript開発者をご機嫌にする JSforce 2.0 α版を速攻プレビュー

ついに出る! JSforce 2.0

JSforce 2.0 のαバージョンがリリースされています。(2.0.0-alpha.1)

2.0は、1.xでの JavaScript (ES5)ベースのコードからTypeScriptでの総書き換えになります。一時期はJSforceのAuthorの好みというか主義からFlowtypeでの書き換えを行っていましたが、昨今のTypeScriptの型表現力の向上と採用数の増加を受けてTypeScriptに途中で書き換えられました。そのため、4,5年くらい前から計画されていた2.0ではありますが、やっとのαリリースになります。

JSforceがTypeScriptベースで書き直されたことの恩恵は、まずはライブラリ内のコードの品質を高める効果が第一となるので、同ライブラリを利用するだけのユーザから見ればあまり感じないかもしれません。しかし昨今ではTypeScriptを用いてアプリケーションを開発するユーザも多いでしょう。その場合、TypeScriptの型定義ファイルが同梱されることによって、より安全にAPIを利用した開発が可能になっています。

α版なのでインストールの際には明示的にバージョン指定が必要です。

$ npm install -g jsforce@2.0.0-alpha.1

なおα版につき現在いくつかのAPIモジュール(stream, bulk など)はまだ利用できません。

2.0の注目機能をピックアップ

TypeScriptで項目名を自動補完!スキーマ型機能

JSforce2.0の最大の特徴はTypeScriptで書き直されたことで型定義がネイティブに提供されることです。
もちろん今までもDefinitelyTypedで有志によって公開された型定義ファイルを入手することで型を使った開発はできましたが、一部APIインターフェースと型定義が異なるところがあったり、ざっくりとしか定義されていなかったりと、少し不満もあったのではないかと思います。またSalesforceオブジェクト/項目などのスキーマについては未対応であるなど、Apexでのスキーマ情報を活かした開発に慣れている人にとっては物足りない面もありました。

2.0ではTypeScriptの型推論能力を活用し、APIのインターフェースのみならず、クエリやCRUD操作においてSalesforceオブジェクトのスキーマ情報を利用したレコードの型チェック/推論ができるようになりました。これにより、Visual Studio Codeなどを利用してコーディングを行う際、SObject#find()などのクエリで絞り込む項目名や取得する項目名を自動補完したり、クエリ実行後に取得されたレコードに含まれる項目の型をTypeScriptで保証できるようになります。

項目自動補完がどのように行われるのかはこちらのスクリーンキャストをご覧いただければ分かるかと思います。

jsforce-2-autocomplete-demo.gif

上記動画の完成コードは以下のようになります。

import jsforce, { Connection, StandardSchema } from 'jsforce';

(async () => {
  // Specify schema of connecting organization
  const conn = new Connection<StandardSchema>();
  await conn.login(process.env.SF_USERNAME, process.env.SF_PASSWORD);

  // fetch records using find()
  const recs = await conn.sobject('Opportunity').find({
    CloseDate: { $lte: jsforce.Date.YESTERDAY },
    IsClosed: true
  });

  // output retrieved records info
  for (const rec of recs) {
    console.log(rec.Amount, rec.Name, rec.LastActivityDate);
  }
})();

Connectionインスタンスの作成時のコンストラクタ呼び出しの型変数としてStandardSchemaというスキーマ用の型を指定しています。こちらは標準オブジェクト/項目のみを利用する場合のビルトインのスキーマ型定義です。

もし自分の開発組織でカスタムオブジェクト/項目を追加している場合は、カスタムのスキーマ型定義を生成して利用することができます。組織に接続してスキーマ型定義ファイルをダンプするためのコマンド jsforce-gen-schema が新たに用意されています。

$ jsforce-gen-schema --help
Usage: jsforce-gen-schema [options]

Options:
  -u, --username [username]      Salesforce username
  -p, --password [password]      Salesforce password (and security token, if available)
  -c, --connection [connection]  Connection name stored in connection registry
  -l, --loginUrl [loginUrl]      Salesforce login url
  -n, --schemaName [schemaName]  Name of schema type (default: "MySchema")
  -o, --outputFile <outputFile>  Generated schema file path (default: "./schema.d.ts")
  --sandbox                      Login to Salesforce sandbox
  --no-cache                     Do not generate cache file for described result in tmp directory
  --clearCache                   Clear all existing described cache files
  -V, --version                  output the version number
  -h, --help                     output usage information

$ jsforce-gen-schema -u username@example.org -p pass123 -n MySchema -o ./myschema.d.ts
Logged in as : username@example.org
describing global
describing AcceptedEventRelation
describing Account
describing AccountBrand
describing AccountBrandShare
describing AccountChangeEvent
describing AccountCleanInfo
...
Dumped to: ./myschema.d.ts

利用時には生成されたmyschema.d.tsファイルをimportして、スキーマ型をコンストラクタの型変数に指定します。

import jsforce, { Connection } from 'jsforce';
import { MySchema } from './myschema';

...

const conn = new Connection<MySchema>();

ログインの必要なし!Salesforce CLIの接続を利用できる接続レジストリ機能

今まで正式にはドキュメント化されていませんでしたが、JSforceには接続レジストリという機能がありました。これはJSforce CLIで接続した接続済のコネクションをJavaScriptコード内から名前指定で利用できるという機能です。取得したConnectionインスタンスはすでに接続済みなので、ユーザ名/パスワードやOAuthなどでログイン処理を書く必要がありません。

import jsforce from 'jsforce';

(async() => {
  const username = 'username@example.com';
  const conn = await jsforce.registry.getConnection(username);
  const accounts = await conn.sobject('Account').find().limit(10);
  console.log(accounts);
})();

今回、この接続情報のレジストリに、Salesforce CLI(sfdx)が保管する組織の接続情報を利用できるようになりました。Registry#getConnection()にはユーザ名だけでなくエイリアスも指定可能です。

index.js
import jsforce from 'jsforce';

(async() => {
  const usernameOrAlias = 'username@example.com';
  // const usernameOrAlias = 'my-scratch-org';
  const conn = await jsforce.registry.getConnection(usernameOrAlias);
  const accounts = await conn.sobject('Account').find().limit(10);
  console.log(accounts);
})();

なおsfdxで保持されている組織の接続情報をレジストリとして用いる場合は、現在実行時に環境変数JSFORCE_CONNECTION_REGISTRYsfdxと指定しておく必要があります。

$ export JSFORCE_CONNECTION_REGISTRY=sfdx
$ node index.js

なお、JavaScriptプログラムからだけではなく、JSforceのREPLでもsfdxレジストリを利用することができます。Scratch組織に接続してゴニョゴニョしたいときにとても便利です。

$ export JSFORCE_CONNECTION_REGISTRY=sfdx
$ jsforce -c MyScratchOrg
Logged in as : test-abcdefghijkl@example.com
> query('SELECT count() FROM Account')
{ totalSize: 78, done: true, records: [] }

Promiseスタイルの呼び出しがデフォルトに。Callbackスタイルのコードは書き換え必要(かも?)

JavaScript(およびNode.js)での非同期関数呼び出しの慣習として、コールバック関数を非同期関数の末尾引数として渡し、非同期の返値(およびエラー)をそのコールバック関数の引数で受け取る形式が主流でした。しかしながら、ES6(ES2015)以降にPromiseがJavaScriptでも標準化されたことで、PromiseでのAPIコールが2.0ではデフォルトとなっています(なお1.xでもPromise形式でのAPIコールはサポートされています)。

Promiseは従来のPromise#then()によるチェーン呼び出しで非同期処理を連結しますが、ES2017より導入されたasync/awaitを利用するのがよりモダンな書き方でしょう。

async function main() {
  const conn = new jsforce.Connection();
  const { id: userId } = await conn.login(
    'username@example.org',
    'password123'
  );
  const recs = await conn
    .sobject('Account')
    .find()
    .limit(10);
  const urecs = recs.map(r => ({
    Id: r.Id,
    OwnerId: userId
  }));
  const rets = await conn.sobject('Account').update(urecs);
  for (const ret of rets) {
    if (ret.success) {
      console.log(ret.id);
    } else {
      console.error(ret.errors.map(e => e.message).join('\n'));
    }
  }
}

現在の2.0αでは、コールバック形式の呼び出しはサポートされていません。正式リリースの際にコールバック形式が残されるかどうかは現時点では不明です。

Semantic Versioningに正式対応(予定)

今までJSforceのversionは、実はSemantic Versioningにはなっていませんでした。例えば1.3から1.4になる際に、Metadata.create() のAPIインターフェースは変わっているのですが、メジャーバージョンを上げていません。2.0リリース以降はインターフェースの変更についてはメジャーバージョン(2.0 => 3.0)で対応することになります。

まとめ

以上、まだalphaバージョンですが、coreの実装は固まりつつあるので、一度触ってみてフィードバックいただければと思います(特にTypeScriptユーザにぜひ)。

なお、2020年1月のJapan Dreamin' にてJSforce 2.0が初お目見え?するらしいです。Authorから直接JSforceの内容をセッション形式で聞けるのはグローバル含めて初とのことなので、参加してみてはいかがでしょうか。

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む