20200623のJavaScriptに関する記事は21件です。

Vue CLI と kintone CLI の共存を探る(JavaScript 編)

Vue CLI でセットアップしたプロジェクトで kintone CLI を利用したカスタマイズ環境を作る目論見です。
どうにかこうにか形になりました。
ざっくり書いていますので読みづらいかも知れません。

こちらは JavaScript 編です。
TypeScript 編は以下をご覧ください。

Vue CLI と kintone CLI の共存を探る(TypeScript 編)

目的

Vue CLI で作られるプロジェクトの整然さと kintone CLI で行えるカスタマイズの開発やデプロイの簡易性を両立させる。
@kintone/rest-api-client を利用する。
kintone UI Component は諦める。(React でしか動かないので)
場合により Vuetify の利用を検討する。

フォーマッティング・lint に関しては以下の方針。
Vetur はフォーマットを実行させず、シンタックスハイライトなどの役割に専念。
eslint は静的解析のみ担当。コードフォーマットはしない。
コードフォーマットは Prettier が実行。

前提

Vue CLI および kintone CLI がグローバルインストールされている事。

npm install -g @vue/cli
npm install -g git://github.com/kintone/kintone-cli.git

プロジェクト作成手順

Vue.js プロジェクトをセットアップする

Vue CLI でプロジェクトを開始する。

vue create vue-cli-kintone-cli-js

以下の選択肢で進める。

? Check the features needed for your project:

  • Babel
  • Vuex
  • CSS Pre-processors
  • Linter / Formatter
  • Unit Testing

? Pick a CSS pre-processor (PostCSS, Autoprefixer and CSS Modules are supported by default): Sass/SCSS (with dart-sass)
? Pick a linter / formatter config: ESLint + Prettier
? Pick additional lint features: Lint on save
? Pick a unit testing solution: Jest
? Where do you prefer placing config for Babel, ESLint, etc.? In dedicated config files

kintone CLI でアプリを作成する

作成されたプロジェクトを VS Code で開き、ターミナルを起動する。
kintone CLI でアプリをセットアップする。

kintone-cli create-template

? What type of app you want to create ? Customization
? Do you want to set authentication credentials ? Yes
? What is your kintone domain ? (サブドメイン).cybozu.com
? What is your kintone username ? (ユーザー名)
? What is your kintone password ? (パスワード)
? Do you use proxy ? No
? Do you want to use React ? No
? Do you want to use TypeScript ? No
? Do you want to use webpack ? Yes
? What is the entry for webpack ? main.js
? What is the app name ? app
? Do you want to use @cybozu/eslint-config for syntax checking ? No
? What is the app ID ? (アプリ ID)
? What is the scope of customization ? ALL

app ディレクトリの下にソースが作成される。

開発サーバーをインストールする

この手順だとローカルプレビュー用の local-web-server がインストールされないため、手動で追加する。
現時点(2020 年 6 月)での最新版は 4.2 だが、このバージョンでは正しく動作しない模様。
kintone-cli でインストールされるものと同じバージョンの 2.6.1 をインストールする。

yarn add -D local-web-server@2.6.1

その他の関連ツールをインストールする

core-js が二重に入っている。
いったんアンインストールして最新版を追加。

yarn remove core-js
yarn add core-js

@kintone/rest-api-client をインストールする。

yarn add @kintone/rest-api-client

ESLint・Prettier の設定

以下を参考にする。
続・VSCode 上で vue ファイルに対して ESLint と Prettier が快適に動作する設定
VS Code で ESLint × Prettier のベストかも知れないプラクティス

yarn add -D eslint-config-prettier

プロジェクトルート直下の .eslintrc.js を修正

module.exports = {
  extends: [
    // --- 省略
    "prettier",
  ],
  // --- 省略
  globals: {
    kintone: true,
    event: true,
  },
};

プロジェクトルート直下に .eslintignore ファイルを作成。
dist フォルダを追跡から除外する。

.eslintignore
**/dist

標準のフォーマッティングを無効化し、Prettier が全て担当するようにする。
.vscode/settings.json に設定を加える。

.vscode/settings.json
{
  "javascript.format.enable": false,
  "typescript.format.enable": false,
  "vetur.format.enable": false,
  "editor.codeActionsOnSave": {
    "source.fixAll.eslint": true
  }
}

.prettierrc を作る。
設定はお好みで。

.prettierrc
{
  "semi": false,
  "singleQuote": true
}

ディレクトリ構成

プロジェクトルート直下の src 以下のファイルを app/source に移動。
app/tests/unit フォルダを作成。
残った srcpublictests は不要なので削除。

app/source/main.js をとりあえず以下のようにする

app/source/main.js
import Vue from "vue";
import App from "./App.vue";
import store from "./store";

Vue.config.productionTip = false;

kintone.events.on("app.record.index.show", (event) => {
  console.log("Hello from kintone CLI");

  const elem = kintone.app.getHeaderSpaceElement();
  new Vue({
    store,
    render: (h) => h(App),
  }).$mount(elem);

  return event;
});

babel の設定

プロジェクトルート直下に babel.config.js が、 app の下に .babelrc が存在する。
プロジェクトルート直下の babel.config.jsapp/.babelrc の内容をマージする。
以下のようになる。

babel.config.js
{
  "plugins": [
    "@babel/plugin-proposal-class-properties",
    "@babel/plugin-syntax-dynamic-import"
  ],
  "presets": [
    "@vue/cli-plugin-babel/preset",
    [
      "@babel/preset-env",
      {
        "useBuiltIns": "usage",
        "corejs": {
          "version": 3,
          "proposals": true
        }
      }
    ]
  ]
}

その後 app/.babelrc は削除ないしリネーム。

webpack の設定

.vue ファイルをビルドするために vue-loader, vue-template-compiler, url-loader, file-loader をインストールする。
また webpack の設定は開発ビルドとプロダクションビルドで設定を変えたいので、webpack-merge もインストールする。
加えて、プロダクションビルドではミニファイも行うので、 terser-webpack-plugin もインストールする。

yarn add -D vue-loader vue-template-compiler url-loader file-loader webpack-merge terser-webpack-plugin

webpack の設定は app 以下のものを使用する。
app/webpack.config.jsapp/webpack.common.js にリネームし、内容を以下のようにする。

app/webpack.common.js
const path = require("path");
const VueLoaderPlugin = require("vue-loader/lib/plugin");

module.exports = {
  entry: path.resolve(__dirname, "./source/main.js"),
  resolve: {
    extensions: [".ts", ".tsx", ".vue", ".js"],
  },
  output: {
    path: path.resolve(__dirname, "./dist"),
    filename: `${path.basename(__dirname)}.min.js`,
  },
  module: {
    rules: [
      {
        test: /\.vue$/,
        use: {
          loader: "vue-loader",
          options: {
            loaders: {
              js: {
                loader: "babel-loader",
              },
            },
          },
        },
      },
      {
        test: /.js?$/,
        exclude: /node_modules/,
        use: {
          loader: "babel-loader",
        },
      },
      {
        test: /.css$/,
        use: ["style-loader", "css-loader"],
      },
      {
        test: /\.(jpe?g|png|gif|svg|ico)(\?.+)?$/,
        use: {
          loader: "url-loader",
          options: {
            esModule: false,
            limit: 10000,
            fallback: {
              loader: "file-loader",
              options: {
                name: "img/[name].[ext]",
              },
            },
          },
        },
      },
    ],
  },
  performance: {
    maxEntrypointSize: 10000000,
    maxAssetSize: 10000000,
  },
  plugins: [new VueLoaderPlugin()],
};

開発ビルドの設定 app/webpack.dev.js は以下の通りにする。

app/webpack.dev.js
const merge = require("webpack-merge");
const common = require("./webpack.common.js");

module.exports = merge(common, {
  mode: "development",
});

プロダクションビルドの設定 app/webpack.prod.js は以下の通りにする。

app/webpack.prod.js
const merge = require("webpack-merge");
const common = require("./webpack.common.js");
const TerserPlugin = require("terser-webpack-plugin");

module.exports = merge(common, {
  mode: "production",
  optimization: {
    minimize: true,
    minimizer: [
      new TerserPlugin({
        terserOptions: { compress: { drop_console: true } },
      }),
    ],
  },
});

新たに app/webpack.config.js を作り、以下のようにする。
このファイルでビルドターゲットを判定して、適切なファイルをロードするようにする。

app/webpack.config.js
module.exports = (env, argv) => {
  const mode = argv.mode.includes("development") ? "dev" : "prod";
  return require(`./webpack.${mode}.js`);
};

Jest の設定

特にない。
必要に応じてプロジェクトルート直下の jest.config.js を編集すればよい。

package.json の設定

コマンドの入力を簡略化するため scripts を以下のようにする。
webpack の設定ファイルもここでコントロールする。

package.json
  "scripts": {
    "dev": "ws --port ****",
    "serve-app": "kintone-cli dev --app-name app --watch --localhost",
    "devel-app": "webpack --mode development --config app/webpack.config.js",
    "build-app": "webpack --mode production --config app/webpack.config.js",
    "deploy-app": "kintone-cli deploy --app-name app",
    "test:unit": "jest"
  },

ws --port **** は任意のポート番号。
以下の記事で解説している。
kintone CLI をざっくり試す

.gitignore の設定

これまでの流れで app/auth.json は含まれているはず。
app/dist も弾くようにする。
その上で、今後仮に app が増えても大丈夫なようにする。

以下の通り修正。

.gitignre
*/auth.json
*/dist

.vscode/settings.json は共有したいので、.vscode の行は削除する。

ここまでで開発準備は終了。
あとはひたすらに開発に注力する。

開発フェーズ

開発・テスト

実際の開発は app/source/ 以下のファイルに対して行っていく事になる。

開発モード(ローカルサーバ)で起動

yarn serve-app

ローカルサーバへの参照が kintone アプリに適用され、テストが行えるようになる。

単体テストは以下のように実行。

yarn test:unit

テストファイルは app/tests/unit/ 以下に **.spec.js のファイル名パターンで記述する。

ビルド

yarn build-app

app フォルダの下に dist フォルダが作成されファイルがビルドされる。
ビルドしたファイルを手動で運用環境に適用したい場合に使う。
webpack.prod.js では TerserPlugin によりミニファイと console の除去が行われる。

デプロイ

yarn deploy-app

ビルドとデプロイを同時に実行するコマンド。
ビルドが実行され、終了後に運用環境にデプロイされる。

2 つめ以降のアプリのカスタマイズを作る場合

プロジェクトが複数のアプリカスタマイズで構成される場合、いくつかアプローチがある。

kintone-cli create-template を使う

kintone CLI 的には王道パターンではある。

kintone-cli create-template --app-name (新しいアプリ名)

と実行し、カスタマイズを新規で作成する。

作成されたフォルダに、既存のアプリカスタマイズのファイルをかぶせてやる。(要微修正)

この方法だと改めて依存モジュール類を再取得しに行く動作をするため、時間が掛かる上にせっかく整えた package.json がまた汚れてしまう。
core-js が二重になってしまうなど)
従って、決して良いやり方ではないと思われるが、以下の方法の方が簡単。

既存のアプリをコピーする

既に作成済みのアプリカスタマイズの構成ファイルをコピーして整えていくパターン。
以下、作成済みのアプリカスタマイズを app、 新しいアプリカスタマイズを second-app として説明する。

フォルダ・ファイル構成

プロジェクトルート直下に新しいフォルダ second-app を作成する。
その下に、source フォルダ、 tests フォルダを作成する。
second-app/source/ 以下に必要に応じて下記フォルダを作る。

  • assets
  • components
  • css
  • js
  • store

app/ の以下のファイルを second-app/ フォルダにコピーする。

  • auth.json
  • config.json
  • webpack.common.js
  • webpack.config.js
  • webpack.dev.js
  • webpack.prod.js

認証情報

新しいアプリカスタマイズの認証情報が異なる場合は second-app/auth.json を編集する。

アプリカスタマイズ情報

second-app/config.json を開き、以下の部分を編集する。

second-app/config.json
{
  "appID": **, // カスタマイズを適用するアプリID
  "appName": "second-app",
  "type": "Customization",
  "scope": "ALL",
  "uploadConfig": {
    "desktop": {
      "js": ["second-app/dist/second-app.min.js"],
      "css": []
    },
    "mobile": {
      "js": ["second-app/dist/second-app.min.js"]
    }
  }
}

基本的には編集前に app となっていたものを second-app に置き換えれば良い。

package.json の修正

package.jsonscripts にコマンドを追加する。

作成済みの serve-app, devel-app,build-app,deploy-app をコピーし、app の部分を second-app に変えてやる。
合わせると以下の通りになる。

package.json
  "scripts": {
    "dev": "ws --port 8800",
    "serve-app": "kintone-cli dev --app-name app --watch --localhost",
    "devel-app": "webpack --mode development --config app/webpack.config.js",
    "build-app": "webpack --mode production --config app/webpack.config.js",
    "deploy-app": "kintone-cli deploy --app-name app",
    "serve-second-app": "kintone-cli dev --app-name second-app --watch --localhost",
    "devel-second-app": "webpack --mode development --config second-app/webpack.config.js",
    "build-second-app": "webpack --mode production --config second-app/webpack.config.js",
    "deploy-second-app": "kintone-cli deploy --app-name second-app",
    "test:unit": "jest"
  },

あとは元のアプリカスタマイズを参考に main.jsApp.vuecomponents/**.vue 等を作り込んでいく。

それなりに作業が多くオペミスが起こりやすいので注意が必要。

Vuetify を利用する場合

Vue CLI でインストールし、その成果を app 以下に移してやると言う流れ。
※この流れでは IE11 で正しく動作しない。IE11 が要件に入らない場合のみ採用できる。

vue add vuetify

インストールが始まる。
道中でプリセットの選択を求められる。
デフォルトで良い。

? Choose a preset: Deafult (recommended)

public/index.html が存在していないと最後にエラーが出るが、インストール自体は正常に行われているので問題ない。

プロジェクトルート直下の src 以下にファイルが作成される。
src/App.vueapp/source/App.vue に上書き。
src/components/HelloWorld.vueapp/source/components/HelloWorld.vue に上書き。
src/assets/Logo.svgapp/source/assets/ に移動。
src/plugins フォルダを app/source/ に移動。
src フォルダは不要なので削除。

app/plugins/vuetify.js を以下のように修正。

app/plugins/vuetify.js
import "@mdi/font/css/materialdesignicons.css";
import Vue from "vue";
import Vuetify from "vuetify/lib";

Vue.use(Vuetify);

export default new Vuetify({
  icons: {
    iconfont: "mdi",
  },
});

app/source/main.js を以下のように修正する。

app/source/main.js
import Vue from "vue";
import App from "./App.vue";
import store from "./store";
import vuetify from "./plugins/vuetify";

Vue.config.productionTip = false;

kintone.events.on("app.record.index.show", (event) => {
  console.log("Hello from kintone CLI");

  const elem = kintone.app.getHeaderSpaceElement();
  new Vue({
    store,
    vuetify,
    render: (h) => h(App),
  }).$mount(elem);

  return event;
});

app/webpack.coomon.js に以下の記述を追加。

app/source/webpack.common.js
const VuetifyLoaderPlugin = require("vuetify-loader/lib/plugin");

module.exports = {
  // 省略
  module: {
    rules: [
      // 省略
      // CSS に対するルールの次に記述する
      {
        test: /\.s(c|a)ss$/,
        use: [
          'vue-style-loader',
          'css-loader',
          {
            loader: 'sass-loader',
            options: {
              implementation: require('sass'),
              sassOptions: {
                fiber: require('fibers'),
                indentedSyntax: true // optional
              }
            }
          }
        ]
      },
      // 省略
      // いちばん下に追加する
      {
        test: /\.(woff|woff2|eot|ttf)(\?.+)?$/,
        use: {
          loader: 'url-loader',
          options: {
            esModule: false,
            limit: 1000000
          }
        }
      }
    ]
  }
  plugins: [
    new VueLoaderPlugin(), // 既に記述済み
    new VuetifyLoaderPlugin(),
  ],
};

追加のライブラリをインストールする。

yarn add -D vue-style-loader fibers deepmerge @mdi/font

これで Vuetify が表示できるようになる。

雑感

Vue CLIkintone CLI を良い感じに共存させようと言う考えから始めたが、あとでライブラリを追加したり多くの設定ファイルに手を入れたりファイルを移動させたりと、結局のところ両者の良さを良い感じに活かすと言うよりはどうにかこうにか妥協点を見出したと言う形になったように思う。
とは言え、Vue.js の環境をゼロから(Vue CLI を使わずに)作成するのは今となってはもう面倒この上ないし、kintone CLI のコマンド一発でデプロイできる便利さは開発工数の削減に効果があるのは間違いなく、無理っくり感が否めない構成とは言えテンプレート化できたのは十分なメリットをもたらすのではなかろうか。

※今回の成果は近日中に GitHub にアップする予定です。

参考

VS Code に Prettier・ESLint・Stylelint を導入してファイル保存時にコードを自動整形させる方法
続・VSCode 上で vue ファイルに対して ESLint と Prettier が快適に動作する設定
VS Code で ESLint × Prettier のベストかも知れないプラクティス
kintone CLI をざっくり試す

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

【JavaScript】For文 VS While文 処理速度はどっちが早い?まさかの結果に...

こんにちは!
今回、Quita初投稿です。
ブログ書いたり、Noteに投稿したことはあったんですが、なんとなくQuitaは敷居が高くて、避けていました(言い訳です...笑)
どうせ書くなら、読者の方にとって価値ある情報を提供していきたいと思っていますので、ご指摘・ご感想いただけると嬉しいです!

本記事ではJavaScript処理速度の違いについて、調べてみました。

JavaScriptにおける処理速度の計測方法

JavaScriptには処理速度を計測する方法がいくつかあります。
代表的なものは以下の3つ

1. performance.now()を使った方法

今回はこちらの関数を使って計測します。
performance.now()はタイムスタンプを返します。
単位はミリ秒で、精度はブラウザによって異なるようですが、Firefoxでは2ミリ秒と書かれています。
▶︎詳しくはこちら
どのように計測するかというと、ざっくりとこんなイメージです。

script.js
//タイムスタンプ(開始時間)を取得
const startTime = performance.now();

//(計測対象の処理を記述)
//・・・

//タイムスタンプ(終了時間)を取得
const endTime = performance.now();

//処理時間 = タイムスタンプ(終了時間) - タイムスタンプ(開始時間)
const result = (endTime - startTime);

2. ブラウザの開発者ツール(Chromeなど)

単位が1ミリ秒。とても簡単に計測することができます。
image.png
(イメージ)

3. Date.now()を使った方法

Date.now()performance.now()の下位互換のような存在です。(処理速度の計測において)

For文とWhile文を比較

ForWhile 2大ループ文の比較です。
計測条件を合わせるため、ループ文の中の処理は同じにしてあります。
(処理内容に深い意味はないので、突っ込まないでください...)

script.js
// 処理速度を計測・コンソール出力するための関数
function measure(name, func) { //第一引数:関数名、第二引数:関数
  const startTime = performance.now(); //タイムスタンプ(開始時間)
  func(); //引数で渡された関数を実行
  const endTime = performance.now(); //タイムスタンプ(終了時間)

  const result = (endTime - startTime);
  const resultStr = result.toPrecision(4); //有効桁数を4桁に変換
  console.log(`${name}${resultStr}`); //コンソールに出力
}

// For文
const funcFor = () => {
  const data = [];
  for(let i=1; i< 100000; i++) {
    data.push(Math.random()); //ランダムな値を配列に追加
    if(i > 1) {
      if(data[i] < data[i - 1]) {
        data[i] = data[i - 1];
      }
    }
  }
};

// While文
const funcWhile = () => {
  let i = 1;
  const data = [];
  while(i< 100000) {
    data.push(Math.random());
    if(i > 1) {
      if(data[i] < data[i - 1]) {
        data[i] = data[i - 1];
      }
    }
    i++;
  }
}

// 関数呼び出し
measure('For文', funcFor);
measure('While文', funcWhile);

結果

image.png
実行ごとにブレがあるので、5回の平均値をとってみました。

1回目 2回目 3回目 4回目 5回目 平均値
For文 7.305 10.71 8.690 9.995 7.645 8.869
While文 9.680 11.84 10.81 8.390 7.480 9.640

単位:ミリ秒

For文の勝利!!

For文とWhile文の処理速度を比較した結果
For文(平均8.869) < While文(平均9.640)
For文の方が若干処理速度が早いことが分かった。

※単純な処理速度の比較です。
※素人の測定結果です

なぜFor文の方が早いのかはまだ分かっていません。。

これを機に処理速度を意識したコーディングをしていきたいと思いました。

皆さんも興味があれば、色々処理速度を測って、遊んでみてください。

参考にさせてもらった記事
https://sbfl.net/blog/2017/12/01/javascript-measure-time/

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

JavaScript で複数ファイルを無圧縮 zip にまとめてダウンロードする

JavaScript で複数ファイルを zip にまとめてダウンロードする方法になります。

サーバーから取得したファイルでも、JavaScript で動的に生成したコンテンツでも、どちらも同様に保存できます。

既に類似記事はあるかと思いますが、自分にとって使いやすいコードを載せようと思います。

1. はじめに

画像ファイルやなんらかのバイナリファイルなど、既に圧縮されているファイルをさらに zip に圧縮しようとすると、圧縮・解凍にとても時間がかかったり、ファイルサイズもほぼ減らないことがあるため、どんなファイルでも扱いやすいようにするためここでは「無圧縮」にしています。

テキストファイルしか扱わないような場合には圧縮した方が良くなります。

2. ブラウザ上の場合

クライアントサイドの JavaScript での例です。

ここでは JSZip という外部ライブラリを使用して zip にします。

参考「JSZip

2.1. コード

サーバーから取得したファイルを zip にまとめます。

ここではファイルの元のディレクトリは考えず、全て同じフォルダに入れて zip にします。

※モダンな JavaScript の書き方をしているため、IE などではトランスコンパイルや Polyfill が必要です。

(async () => {

    // 動的 import が使用できない前提
    const importInNoModule = src => new Promise(resolve => {
        const s = document.createElement('script');
        s.onload = () => { resolve(); };
        s.src = src;
        document.head.append(s);
    });

    await importInNoModule('https://cdnjs.cloudflare.com/ajax/libs/jszip/3.5.0/jszip.min.js');

    // 
    const getNameContentPairsFrom = async urls => {

        const promises = urls.map(async url => {

            // メモ: クロスオリジンで通信したい場合、サーバー側で許可の設定がされていないなければ、
            //       クライアントサイドの JavaScript ではどう記述してもアクセス不可
            const response = await fetch(url);

            const content = await response.blob(); // new Uint8Array(await response.arrayBuffer()) も可
            // メモ: 本当はこれだけでは不十分。クエリパラメータにスラッシュが含まれる可能性がある
            const name = url.slice(url.lastIndexOf('/') + 1);

            return { name, content };

        });

        // 
        const pairs = [];

        for (const promise of promises) {
            pairs.push(await promise);
        }

        return pairs;

    };

    const generateZipBlob = (nameContentPairs, name) => {

        const zip = new JSZip();

        const folder = zip.folder(restrictFileNames(name));

        nameContentPairs.forEach(nameContentPair => {

            const name = restrictFileNames(nameContentPair.name);
            const content = nameContentPair.content;

            folder.file(name, content);

        });

        return zip.generateAsync({ type: 'blob' }); // デフォルトで無圧縮

    };

    const saveBlob = (blob, name) => {

        const a = document.createElement('a');
        a.href = URL.createObjectURL(blob);
        a.download = restrictFileNames(name) + '.zip';

        a.style.display = 'none';
        document.body.appendChild(a);
        a.click();
        document.body.removeChild(a);

    };

    /**
     * Windows のファイル名に使用できない文字をエスケープ
     * Mac や Linux より Windows の方がファイル名の制限が厳しいため、Windows に合わせる
     */
    const restrictFileNames = name => name.replace(/[\\/:*?"<>|]/g, c => '%' + c.charCodeAt(0).toString(16));

    // 
    const name = 'test';

    const urls = [/* ... URL 文字列配列 */];

    const zipBlob = await generateZipBlob(await getNameContentPairsFrom(urls), name);

    saveBlob(zipBlob, name);

})();
  • getNameContentPairsFrom()
    • URL の一覧からファイルを取得し、ファイル名とファイル内容 (Blob 形式) を配列で返します
    • (Uint8Array も可)
  • generateZipBlob()
    • JSZip を用いて、複数ファイルの Blob を zip の Blob にします
  • saveBlob()
    • Blob からファイルに保存します

2.2. 注意点

JavaScript が実行されるページのドメインと、取得しようとしているファイルの URL のドメインが異なると、ブラウザの Cross-Origin Resource Sharing のセキリティの制限ではじかれるため、サーバー側の設定で許可していない限り Blob を取得することはできません。

もし、取得しようとしているファイルが画像ファイルの場合、WEB ページ上で <img> を使った表示はできますが、そこから <canvas> を介して Blob を取得しようとしても、セキリティではじかれます。

参考「画像とキャンバスをオリジン間で利用できるようにする - HTML: HyperText Markup Language | MDN

3. Deno の場合

サーバーサイドやローカルで実行可能な Deno の JavaScript での例です。

Deno 向けのラッパーがあるのでそれを利用します。

参考「GitHub - hayd/deno-zip: A JSZip wrapper for handling zipfiles in deno

3.1. コード

Deno 版
import { JSZip } from 'https://deno.land/x/jszip/mod.ts';

// 
const getNameContentPairsFrom = async urls => {

    const promises = urls.map(async url => {

        const response = await fetch(url);

        const content = new Uint8Array(await response.arrayBuffer());
        // メモ: 本当はこれだけでは不十分。クエリパラメータにスラッシュが含まれる可能性がある
        const name = url.slice(url.lastIndexOf('/') + 1);

        return { name, content };

    });

    // 
    const pairs = [];

    for (const promise of promises) {
        pairs.push(await promise);
    }

    return pairs;

};

const generateZipUint8Array = (nameContentPairs, name) => {

    const zip = new JSZip();

    const folder = zip.folder(restrictFileNames(name));

    nameContentPairs.forEach(nameContentPair => {

        const name = restrictFileNames(nameContentPair.name);
        const content = nameContentPair.content;

        // メモ: Deno 版 JSZip では、file() でなく addFile()
        // メモ: Deno 版 JSZip では、Blob を使用できない (ver.0.6.0 現在)
        folder.addFile(name, content);

    });

    return zip.generateAsync({ type: 'uint8Array' }); // デフォルトで無圧縮

};

const saveUint8Array = (uint8Array, name) => Deno.writeFile(restrictFileNames(name) + '.zip', uint8Array);

/**
 * Windows のファイル名に使用できない文字をエスケープ
 * Mac や Linux より Windows の方がファイル名の制限が厳しいため、Windows に合わせる
 */
const restrictFileNames = name => name.replace(/[\\/:*?"<>|]/g, c => '%' + c.charCodeAt(0).toString(16));

// 
const name = 'test';

const urls = [/* ... URL 文字列配列 */];

const zipUint8array = await generateZipUint8Array(await getNameContentPairsFrom(urls), name);

await saveUint8Array(zipUint8array, name);
  • getNameContentPairsFrom()
    • URL の一覧からファイルを取得し、ファイル名とファイル内容 (Uint8Array 形式) を配列で返します
  • generateZipUint8array()
    • JSZip を用いて、複数ファイルの Uint8Array を zip の Uint8Array にします
  • saveUint8Array()
    • Uint8Array からファイルに保存します

3.2. 注意点

Deno 版の JSZip では (ver.0.6.0 現在) Blob を使うことができないため、Uint8Array を使用します。

4. その他

実用的には、URL からファイル名を取り出す処理や、保存ファイル名を決める処理をもっとしっかりする必要があります。

本記事のサンプルコードでは 1 つのディレクトリに複数ファイルをまとめているため、異なるディレクトリの同一ファイル名が扱えない問題があります。

保存ファイル名にクエリパラメータが含まれる場合、拡張子を自動で補正する機能を追加するとより実用的かと思います。

JavaScript で動的に生成したコンテンツを保存したい場合は、getNameBlobPairsFrom() の代わりに自前で Blob を生成してください。

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

ブラウザ JS で複数ファイルを無圧縮 zip にまとめてダウンロードする

クライアントサイドの JavaScript で、複数ファイルを zip にまとめてダウンロードする方法になります。

サーバーから取得したファイルでも、JavaScript で動的に生成したコンテンツでも、どちらも同様に保存できます。

既に類似記事はあるかと思いますが、自分にとって使いやすいコードを載せようと思います。

1. コード

サーバーから取得したファイルを zip にまとめる例です。

ここでは、JSZip という外部ライブラリを使用して zip にします。

ここではファイルの元のディレクトリは考えず、全て同じフォルダに入れて zip にします。

※モダンな JavaScript の書き方をしているため、IE などではトランスコンパイルや Polyfill が必要です。

(async () => {

    // 動的 import が使えない状況の想定
    await new Promise(resolve => {

        const s = document.createElement('script');
        s.onload = () => { resolve(); };
        s.src = 'https://cdnjs.cloudflare.com/ajax/libs/jszip/3.5.0/jszip.min.js';
        document.head.append(s);

    });

    // 
    const getNameBlobPairsFrom = async urls => {

        const promises = urls.map(async url => {

            // メモ: クロスオリジンで通信したい場合、サーバー側で許可の設定がされていないなければ、
            //       クライアントサイドの JavaScript ではどう記述してもアクセス不可
            const response = await fetch(url);

            const blob = await response.blob();
            // メモ: 本当はこれだけでは不十分。クエリパラメータにスラッシュが含まれる可能性がある
            const name = url.slice(url.lastIndexOf('/') + 1);

            return { name, blob };

        });

        // ここではサーバーの負荷を考えて、直列実行
        // 並列実行したい場合は const blobs = await Promise.all(promises);
        const blobs = [];

        // await を使いたいため、forEach を使わない (他に直列実行の実装法あり)
        for (const promise of promises) {
            blobs.push(await promise);
        }

        return blobs;

    };

    const generateZipBlob = (nameBlobPairs, name) => {

        const zip = new JSZip();

        const folder = zip.folder(name);

        nameBlobPairs.forEach(nameBlobPair => {
            // TODO: ファイル名が OS で使用できるかチェック
            folder.file(nameBlobPair.name, nameBlobPair.blob);
        });

        return zip.generateAsync({ type: 'blob' }); // デフォルトで無圧縮

    };

    const saveBlob = (blob, name) => {

        const a = document.createElement('a');
        a.href = URL.createObjectURL(blob);
        a.download = name + '.zip';

        a.style.display = 'none';
        document.body.appendChild(a);
        a.click();
        document.body.removeChild(a);

    };

    // 
    const name = 'test';

    const urls = [/* ... URL 文字列配列 */];

    const zipBlob = await generateZipBlob(await getNameBlobPairsFrom(urls), name);

    saveBlob(zipBlob, name);

})();
  • getNameBlobPairsFrom()
    • URL の一覧からファイルを取得し、ファイル名とファイル内容 (Blob 形式) を配列で返します
  • generateZipBlob()
    • JSZip を用いて、複数ファイルの Blob を zip の Blob にします
  • saveBlob()
    • Blob からファイルに保存します

参考「JSZip

2. 注意点

JavaScript が実行されるページのドメインと、取得しようとしているファイルの URL のドメインが異なると、ブラウザの Cross-Origin Resource Sharing のセキリティの制限ではじかれるため、サーバー側の設定で許可していない限り Blob を取得することはできません。

もし、取得しようとしているファイルが画像ファイルの場合、WEB ページ上で <img> を使った表示はできますが、そこから <canvas> を介して Blob を取得しようとしても、セキリティではじかれます。

参考「画像とキャンバスをオリジン間で利用できるようにする - HTML: HyperText Markup Language | MDN

3. その他

実用的には、URL からファイル名を取り出す処理や、保存ファイル名を決める処理をもっとしっかりする必要があります。

zip 内のファイル名に OS によって使用できない文字が含まれていたり、クエリパラメータを表現できなかったり、異なるディレクトリの同一ファイル名が扱えないなどの問題があります。

JavaScript で動的に生成したコンテンツを保存したい場合は、getNameBlobPairsFrom() の代わりに自前で Blob を生成してください。

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

kintoneでWebカメラの映像を表示して添付ファイルとして保管

概要

今回調べもので canvas の表示を画像として kintone の添付ファイルに保管できないか調べる機会がありました。試しに kintone アプリを作る際に折角なら canvas に Web カメラ画像を取り込んで保管するまでを実装を思い立ち、今回試してみました。
WebCam11.png

Web カメラ画像を canvas に表示する

先ず kintone に実装する前に、html ファイルでWebカメラの表示試験を行います。
id が contents の div 内に、video と canvas を追加します。

<body>
<h1>Webカメラの映像をvideoとcanvasに表示</h1>
<div id="contents"></div>
<div><a id="download" href="#" download="canvas.png" onClick="downloadImage()">画像ダウンロード</a></div>
</body>

webカメラの表示

Webカメラを video に表示します。
今回は JavaScript から video を追加していますが、HTML に直接タグを記述しても問題ありません。
navigator.mediaDevices.getUserMedia() メソッドで、Webカメラを使用する許可をユーザーに求め、許可を得た場合に動画をストリームで受信できるようになり、then で取得できた際に video.srcObject に代入しています。これだけで、Webカメラの画像がブラウザに表示されます。

    // video にWebカメラの映像を表示
    let videoPreview    = document.createElement('div');

    let video      = document.createElement('video');
    video.id       = 'video';
    video.width    = cameraSize.w;
    video.height   = cameraSize.h;
    video.autoplay = true;
    videoPreview.appendChild(video);

    document.getElementById('contents').appendChild(videoPreview);

    // Webカメラの映像を video にセット
    let media = navigator.mediaDevices.getUserMedia({
        audio: false,
        video: {
            width:  { ideal: resolution.w },
            height: { ideal: resolution.h }
        }
    }).then(function(stream) {
        video.srcObject = stream;
    });

結果
WebCam02.png
PC内蔵のカメラ動画を表示しました。
WebCam03a.png

Webカメラを表示を canvas に複写する

video の webカメラ表示を canvas に複写表示します。
こちらも今回は JavaScript から canvas を追加していますが、HTML に直接タグを記述しても問題ありません。
キャンバスの drawImage() メソッドで、video からフレーム画像取得を繰り返さないと canvas に同じ動画を表示できないのですが、requestAnimationFrame() メソッドを使うと、ブラウザが次の再描画を行う前に canvasUpdate() を実行するようで、canvas に video と同じ動画が表示できました。ブラウザの JavaScript の進化には驚きますね!

    // canvas にvideoの映像を表示
    let canvasPreview    = document.createElement('div');

    let canvas    = document.createElement('canvas');
    canvas.id     = 'canvas';
    canvas.width  = canvasSize.w;
    canvas.height = canvasSize.h;
    canvasPreview.appendChild(canvas);

    document.getElementById('contents').appendChild(canvasPreview);

    // video の映像を canvas にセット
    let canvasCtx = canvas.getContext('2d');
    canvasUpdate();

    function canvasUpdate() {
      canvasCtx.drawImage(video, 0, 0, canvas.width, canvas.height);
      requestAnimationFrame(canvasUpdate);
    };

結果
canvas に video と同じWebカメラの動画が表示されています。
WebCam03b.png

以下が全てのコードです。

webcam.html
<html>
<head><title>Webカメラの映像をvideoとcanvasに表示</title></head>

<body>
<h1>Webカメラの映像をvideoとcanvasに表示</h1>
<div id="contents"></div>
<div><a id="download" href="#" download="canvas.png" onClick="downloadImage()">画像ダウンロード</a></div>
</body>

<script type="text/javascript">

    const cameraSize = { w: 640,  h: 400 };
    const canvasSize = { w: 640,  h: 400 };
    const resolution = { w: 1280, h: 720 };

    // video にWebカメラの映像を表示
    let videoPreview    = document.createElement('div');
    let videoText       = document.createElement('p');
    videoText.innerHTML = 'videoにWebカメラ表示';
    videoPreview.appendChild(videoText);

    let video      = document.createElement('video');
    video.id       = 'video';
    video.width    = cameraSize.w;
    video.height   = cameraSize.h;
    video.autoplay = true;
    videoPreview.appendChild(video);

    document.getElementById('contents').appendChild(videoPreview);

    // Webカメラの映像を video にセット
    let media = navigator.mediaDevices.getUserMedia({
        audio: false,
        video: {
            width:  { ideal: resolution.w },
            height: { ideal: resolution.h }
        }
    }).then(function(stream) {
        video.srcObject = stream;
    });

    // canvas にvideoの映像を表示
    let canvasPreview    = document.createElement('div');
    let canvasText       = document.createElement('p');
    canvasText.innerHTML = 'canvasにvideoのWebカメラ画像を表示';
    canvasPreview.appendChild(canvasText);

    let canvas    = document.createElement('canvas');
    canvas.id     = 'canvas';
    canvas.width  = canvasSize.w;
    canvas.height = canvasSize.h;
    canvasPreview.appendChild(canvas);

    document.getElementById('contents').appendChild(canvasPreview);

    // video の映像を canvas にセット
    let canvasCtx = canvas.getContext('2d');
    canvasUpdate();

    function canvasUpdate() {
      canvasCtx.drawImage(video, 0, 0, canvas.width, canvas.height);
      requestAnimationFrame(canvasUpdate);
    };

    // canvasの画像をダウンロード
    function downloadImage() {
      var base64 = canvas.toDataURL();
      document.getElementById("download").href = base64;
    }
</script>
</html>

kintone でWebカメラを表示し保管するアプリを作る

前座が長くなりましたが、いよいよ kintone に Webカメラの画像を保管する方法を試します。
今回「Webカメラ写真保存」というアプリを追加しました。
WebCam01.png

フィールドの設定

アプリのフィールドは以下のように設定しています。(レコード番号などはお好みで。)

フィールド名 タイプ フィードコート・要素ID その他
添付ファイル 添付ファイル 添付ファイル サムネイルの大きさは 250x250
Webカメラ画像 グループ Webカメラ画像 明細画面ではWebカメラフィールドを非表示に
        スペース videoPreview Webカメラ画像グループ内に配置
        スペース canvasPreview Webカメラ画像グループ内に配置
ファイルID 文字列(1行) ファイルID Webカメラ画像グループ内に配置

JavaScript プログラムの作成

アプリに以下のJavaScriptのプログラムファイルを追加します。
JavaScript でWebカメラをcanvasに表示する方法についてはすでに述べたので、kintone に実装する際のポイントのみを説明します。

・レコード追加画面でWebカメラ画像を表示する
先ず kintone に video や canvas を表示するには、kintone のスペースを利用します。
kintone のレコード追加画面のイベント処理 kintone.events.on(eventsCreateShow, function(event) {}) 内ではそれぞれ showWebCamera() で video にWebカメラの表示、makeCanvas() で canvas の準備を行っています。スペースの要素IDを利用してgetSpaceElement() で Elementを取得し、必要な DOM を追加します。DOM の追加については、先に説明しましたので省略します。

        let video    = showWebCamera();
        let canvas   = makeCanvas(isMobile, video);

        if(!isMobile) {
            kintone.app.record.setFieldShown('添付ファイル', false);
            kintone.app.record.getSpaceElement('videoPreview').appendChild(video);
            kintone.app.record.getSpaceElement('canvasPreview').appendChild(canvas);

・「撮影」ボタン押下で canvas に画像を表示、kintone にファイルをアップロード
makeCanvas() のメソッドでは、kintone のアプリでは video のWebカメラ動画から「撮影」ボタンを押したタイミングで canvas に表示すると同時に、画像をkintoneにアップロードし、その返信の fileKey をフィールドの「ファイルID」にセットしています。

    // 撮影用画像をcanvasに表示
    function makeCanvas(isMobile, video) {

        if(video == null) return;
        let canvasPreview = document.createElement('div');

        let takeButton       = document.createElement('button');
(中略)
        // 撮影ボタンが押されたら canvas に画像を転送
        takeButton.onclick   = function() {
            let canvasCtx = canvas.getContext('2d');
            canvasCtx.drawImage(video, 0, 0, canvas.width, canvas.height);
            let blob = convertBlobImage(canvas);
            saveImage(isMobile, blob);
        };
        canvasPreview.appendChild(takeButton);
(中略)
        return canvasPreview;
    }

「撮影」ボタンを押したタイミングでアップロードするのは、kintone の添付ファイルフィールドを直接 JavaScript で操作できないため、JavaScript APIをコールして事前にファイルをアップロードし、全ての入力を終えて submit で kintone のレコードが追加された後(無論添付ファイルは空の状態)に、先のアップロードで返ってきた fileKey をセットしAPIで再度更新を行うことで、やっと添付ファイルが紐づけられます。

    // 画像をkintoneに保存
    function saveImage(isMobile, blob){

        var record;
        if (isMobile) {
            record = kintone.mobile.app.record.get();
(中略)    
        // ファイルアップロード
        var key = "";
        var formData = new FormData();
        formData.append('__REQUEST_TOKEN__', kintone.getRequestToken());
        formData.append('file', blob, 'image.png');

        var xmlHttp = new XMLHttpRequest();
        xmlHttp.open('POST', kintone.api.url('/k/v1/file', true), false);
        xmlHttp.setRequestHeader('X-Requested-With', 'XMLHttpRequest');
        xmlHttp.send(formData);
        if (xmlHttp.status === 200) {
            key = JSON.parse(xmlHttp.responseText).fileKey;
        }
        record.record["ファイルID"].value = key;

        if (isMobile) {
            kintone.mobile.app.record.set(record);
(中略)    
    }

さらに面倒なのは、canvas のデータは Base64 には簡単に変換できるのですが、ファイルとしてアップロードするために blob データでなければならない点です。以下は canvas の画像を blob データに変換する処理です。最後の参照先でも紹介していますが、qiita に大変有難い例がありましたので、参考にさせていただきました。(感謝!)

    // canvas画像をblobデータに変換
    function convertBlobImage(canvas) {

        if(canvas == null) return;

        var base64 = canvas.toDataURL("image/jpeg");
        // Base64からバイナリへ変換
        var bin = atob(base64.replace(/^.*,/, ''));
        var buffer = new Uint8Array(bin.length);
        for (var i = 0; i < bin.length; i++) {
            buffer[i] = bin.charCodeAt(i);
        }
        // Blobを作成
        var blob = new Blob([buffer.buffer], {
            type: 'image/png'
        });

        return blob;
    }

・レコードが追加された後に、先にアップロードしたファイルと紐づけの更新を行う
先にも説明したとおり、添付ファイルフィールドは javaScript から直接操作できないため、事前にアップロードした際の fileKey を、レコード追加後に行う必要があります。kintone.events.on(eventsEditSuccess, function(event) {}) 内で、以下のようにAPI経由で更新します。本来は promise で同期させた方が良いのですが、今回は XMLHttpRequest() の同期処理で実装しています。

        var json = {
            app: kintone.app.getId(),
            id: event.record["$id"].value,
            record: {
                "添付ファイル": {
                    value: [{ fileKey: event.record["ファイルID"].value }]
                }
            },
            "__REQUEST_TOKEN__": kintone.getRequestToken()
        };

        var xmlHttp = new XMLHttpRequest();
        xmlHttp.open('PUT', kintone.api.url('/k/v1/record', true), false);
        xmlHttp.setRequestHeader('X-Requested-With', 'XMLHttpRequest');
        xmlHttp.setRequestHeader('Content-Type', 'application/json');
        xmlHttp.send(JSON.stringify(json));

実行結果

・追加画面でWebカメラ表示
WebCam05.png

・追加画面で「撮影」
WebCam06.png

・追加画面で「保存」した後の明細画面
WebCam07.png

・変更画面で「撮影」した場合
WebCam08.png

以上、予定していた実装が一通りできました。
以下が全てのコードです。

webcam.js
(function() {
    "use strict";

    const cameraSize = { w: 320,  h: 240 };
    const canvasSize = { w: 320,  h: 240 };
    const resolution = { w: 1080, h: 720 };

    // レコード追加時表示イベント
    var eventsCreateShow = [
        'app.record.create.show',
        'mobile.app.record.create.show'];
    kintone.events.on(eventsCreateShow, function(event) {

        let isMobile = false;
        if(event.type === 'mobile.app.record.create.show'){
            isMobile = true;
        }

        let video    = showWebCamera();
        let canvas   = makeCanvas(isMobile, video);

        if(!isMobile) {
            kintone.app.record.setFieldShown('添付ファイル', false);
            kintone.app.record.getSpaceElement('videoPreview').appendChild(video);
            kintone.app.record.getSpaceElement('canvasPreview').appendChild(canvas);
        }else{
            kintone.mobile.app.record.setFieldShown('添付ファイル', false);
            kintone.mobile.app.record.getSpaceElement('videoPreview').appendChild(video);
            kintone.app.record.getSpaceElement('canvasPreview').appendChild(canvas);
        }
        event.record["ファイルID"].disabled = true;

        return event;
    });

    // レコード編集時表示イベント
    var eventsEditShow = [
        'app.record.edit.show',
        'mobile.app.record.edit.show'];
    kintone.events.on(eventsEditShow, function(event) {

        let isMobile = false;
        if(event.type === 'mobile.app.record.edit.show'){
            isMobile = true;
        }
        let video    = showWebCamera();
        let canvas   = makeCanvas(isMobile, video);

        if(!isMobile) {
            kintone.app.record.getSpaceElement('videoPreview').appendChild(video);
            kintone.app.record.getSpaceElement('canvasPreview').appendChild(canvas);
        }else{
            kintone.mobile.app.record.getSpaceElement('videoPreview').appendChild(video);
            kintone.app.record.getSpaceElement('canvasPreview').appendChild(canvas);
        }
        event.record["添付ファイル"].disabled = true;
        event.record["ファイルID"].disabled = true;

        return event;
    });

    // レコード追加・編集後イベント
    var eventsEditSuccess = [
        'app.record.create.submit.success',
        'app.record.edit.submit.success', 
        'mobile.app.record.create.submit.success',
        'mobile.app.record.edit.submit.success'];
    kintone.events.on(eventsEditSuccess, function(event) {

        var json = {
            app: kintone.app.getId(),
            id: event.record["$id"].value,
            record: {
                "添付ファイル": {
                    value: [{ fileKey: event.record["ファイルID"].value }]
                }
            },
            "__REQUEST_TOKEN__": kintone.getRequestToken()
        };

        var xmlHttp = new XMLHttpRequest();
        xmlHttp.open('PUT', kintone.api.url('/k/v1/record', true), false);
        xmlHttp.setRequestHeader('X-Requested-With', 'XMLHttpRequest');
        xmlHttp.setRequestHeader('Content-Type', 'application/json');
        xmlHttp.send(JSON.stringify(json));

    });

    // レコード詳細表示時イベント
    var eventsDetailShow = [
        'app.record.detail.show',
        'mobile.app.record.detail.show'];
    kintone.events.on(eventsDetailShow, function(event) {

        let isMobile = false;
        if(event.type === 'mobile.app.record.edit.show'){
            isMobile = true;
        }
        if(!isMobile) {
            kintone.app.record.setFieldShown('Webカメラ画像', false);
        }else{
            kintone.mobile.app.record.setFieldShown('Webカメラ画像', false);
        }

    });

    // Webカメラ画像をvideoに表示
    function showWebCamera() {
        let video      = document.createElement('video');
        video.id       = 'video';
        video.width    = cameraSize.w;
        video.height   = cameraSize.h;
        video.autoplay = true;

        let media = navigator.mediaDevices.getUserMedia({
            audio: false,
            video: {
                width:  { ideal: resolution.w },
                height: { ideal: resolution.h }
            }
        }).then(function(stream) {
            video.srcObject = stream;
        });

        return video;
    }

    // 撮影用画像をcanvasに表示
    function makeCanvas(isMobile, video) {

        if(video == null) return;
        let canvasPreview = document.createElement('div');

        let takeButton       = document.createElement('button');
        takeButton.id        = 'takeButton';
        takeButton.innerText = ' 撮 影 ';
        takeButton.className = "gaia-ui-actionmenu-save";
        takeButton.onclick   = function() {
            let canvasCtx = canvas.getContext('2d');
            canvasCtx.drawImage(video, 0, 0, canvas.width, canvas.height);
            let blob = convertBlobImage(canvas);
            saveImage(isMobile, blob);
        };
        canvasPreview.appendChild(takeButton);
        canvasPreview.appendChild(document.createElement('br'));

        let canvas    = document.createElement('canvas');
        canvas.id     = 'canvas';
        canvas.width  = canvasSize.w;
        canvas.height = canvasSize.h;
        canvasPreview.appendChild(canvas);

        return canvasPreview;
    }

    // 画像をkintoneに保存
    function saveImage(isMobile, blob){

        var record;
        if (isMobile) {
            record = kintone.mobile.app.record.get();
        }else{
            record = kintone.app.record.get();
        }

        // ファイルアップロード
        var key = "";
        var formData = new FormData();
        formData.append('__REQUEST_TOKEN__', kintone.getRequestToken());
        formData.append('file', blob, 'image.png');

        var xmlHttp = new XMLHttpRequest();
        xmlHttp.open('POST', kintone.api.url('/k/v1/file', true), false);
        xmlHttp.setRequestHeader('X-Requested-With', 'XMLHttpRequest');
        xmlHttp.send(formData);
        if (xmlHttp.status === 200) {
            key = JSON.parse(xmlHttp.responseText).fileKey;
        }
        record.record["ファイルID"].value = key;

        if (isMobile) {
            kintone.mobile.app.record.set(record);
        }else{
            kintone.app.record.set(record);
        }
    }

    // canvas画像をblobデータに変換
    function convertBlobImage(canvas) {

        if(canvas == null) return;

        var base64 = canvas.toDataURL("image/jpeg");
        // Base64からバイナリへ変換
        var bin = atob(base64.replace(/^.*,/, ''));
        var buffer = new Uint8Array(bin.length);
        for (var i = 0; i < bin.length; i++) {
            buffer[i] = bin.charCodeAt(i);
        }
        // Blobを作成
        var blob = new Blob([buffer.buffer], {
            type: 'image/png'
        });

        return blob;
    }

})();

まとめ

今回は kintone でWebカメラの映像を表示、canvas に「撮影」した画像を表示して添付ファイルとして保管できることを確認しました。Web カメラ以外でも canvas に表示したグラフや解析画像などもこの手順を応用して、kintone の添付ファイルとして保管できます。
添付ファイルで保存できれば、毎回グラフィックなどの表示にリソースを取られなくて済みますし、一覧で便利な添付ファイルの表示機能なども利用できるようになるので、今後はこの手法を活用するつもりです。

参照先

Webカメラの映像をcanvasに表示させる
https://qiita.com/chelcat3/items/02c77b55d080d770530a
canvasの画像をBlobに変換
https://qiita.com/0829/items/a8c98c8f53b2e821ac94
ファイルアップロードで必須となる3つの手順
https://developer.cybozu.io/hc/ja/articles/200724665
MediaDevices.getUserMedia()
https://developer.mozilla.org/ja/docs/Web/API/MediaDevices/getUserMedia
Window.requestAnimationFrame()
https://developer.mozilla.org/ja/docs/Web/API/Window/requestAnimationFrame
HTMLCanvasElement.toDataURL()
https://developer.mozilla.org/ja/docs/Web/API/HTMLCanvasElement/toDataURL
FormData オブジェクトの利用
https://developer.mozilla.org/ja/docs/Web/Guide/Using_FormData_Objects

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

kintoneでWebカメラの映像を表示して添付ファイルに保管

概要

今回調べもので canvas の表示を画像として kintone の添付ファイルに保管できないか調べる機会がありました。試しに kintone アプリを作る際に折角なら canvas に Web カメラ画像を取り込んで保管するまでを実装を思い立ち、今回試してみました。
WebCam11.png

Web カメラ画像を canvas に表示する

先ず kintone に実装する前に、html ファイルでWebカメラの表示試験を行います。
id が contents の div 内に、video と canvas を追加します。

<body>
<h1>Webカメラの映像をvideoとcanvasに表示</h1>
<div id="contents"></div>
<div><a id="download" href="#" download="canvas.png" onClick="downloadImage()">画像ダウンロード</a></div>
</body>

webカメラの表示

Webカメラを video に表示します。
今回は JavaScript から video を追加していますが、HTML に直接タグを記述しても問題ありません。
navigator.mediaDevices.getUserMedia() メソッドで、Webカメラを使用する許可をユーザーに求め、許可を得た場合に動画をストリームで受信できるようになり、then で取得できた際に video.srcObject に代入しています。これだけで、Webカメラの画像がブラウザに表示されます。

    // video にWebカメラの映像を表示
    let videoPreview    = document.createElement('div');

    let video      = document.createElement('video');
    video.id       = 'video';
    video.width    = cameraSize.w;
    video.height   = cameraSize.h;
    video.autoplay = true;
    videoPreview.appendChild(video);

    document.getElementById('contents').appendChild(videoPreview);

    // Webカメラの映像を video にセット
    let media = navigator.mediaDevices.getUserMedia({
        audio: false,
        video: {
            width:  { ideal: resolution.w },
            height: { ideal: resolution.h }
        }
    }).then(function(stream) {
        video.srcObject = stream;
    });

結果
WebCam02.png
PC内蔵のカメラ動画を表示しました。
WebCam03a.png

Webカメラを表示を canvas に複写する

video の webカメラ表示を canvas に複写表示します。
こちらも今回は JavaScript から canvas を追加していますが、HTML に直接タグを記述しても問題ありません。
キャンバスの drawImage() メソッドで、video からフレーム画像取得を繰り返さないと canvas に同じ動画を表示できないのですが、requestAnimationFrame() メソッドを使うと、ブラウザが次の再描画を行う前に canvasUpdate() を実行するようで、canvas に video と同じ動画が表示できました。ブラウザの JavaScript の進化には驚きますね!

    // canvas にvideoの映像を表示
    let canvasPreview    = document.createElement('div');

    let canvas    = document.createElement('canvas');
    canvas.id     = 'canvas';
    canvas.width  = canvasSize.w;
    canvas.height = canvasSize.h;
    canvasPreview.appendChild(canvas);

    document.getElementById('contents').appendChild(canvasPreview);

    // video の映像を canvas にセット
    let canvasCtx = canvas.getContext('2d');
    canvasUpdate();

    function canvasUpdate() {
      canvasCtx.drawImage(video, 0, 0, canvas.width, canvas.height);
      requestAnimationFrame(canvasUpdate);
    };

結果
canvas に video と同じWebカメラの動画が表示されています。
WebCam03b.png

以下が全てのコードです。

webcam.html
<html>
<head><title>Webカメラの映像をvideoとcanvasに表示</title></head>

<body>
<h1>Webカメラの映像をvideoとcanvasに表示</h1>
<div id="contents"></div>
<div><a id="download" href="#" download="canvas.png" onClick="downloadImage()">画像ダウンロード</a></div>
</body>

<script type="text/javascript">

    const cameraSize = { w: 640,  h: 400 };
    const canvasSize = { w: 640,  h: 400 };
    const resolution = { w: 1280, h: 720 };

    // video にWebカメラの映像を表示
    let videoPreview    = document.createElement('div');
    let videoText       = document.createElement('p');
    videoText.innerHTML = 'videoにWebカメラ表示';
    videoPreview.appendChild(videoText);

    let video      = document.createElement('video');
    video.id       = 'video';
    video.width    = cameraSize.w;
    video.height   = cameraSize.h;
    video.autoplay = true;
    videoPreview.appendChild(video);

    document.getElementById('contents').appendChild(videoPreview);

    // Webカメラの映像を video にセット
    let media = navigator.mediaDevices.getUserMedia({
        audio: false,
        video: {
            width:  { ideal: resolution.w },
            height: { ideal: resolution.h }
        }
    }).then(function(stream) {
        video.srcObject = stream;
    });

    // canvas にvideoの映像を表示
    let canvasPreview    = document.createElement('div');
    let canvasText       = document.createElement('p');
    canvasText.innerHTML = 'canvasにvideoのWebカメラ画像を表示';
    canvasPreview.appendChild(canvasText);

    let canvas    = document.createElement('canvas');
    canvas.id     = 'canvas';
    canvas.width  = canvasSize.w;
    canvas.height = canvasSize.h;
    canvasPreview.appendChild(canvas);

    document.getElementById('contents').appendChild(canvasPreview);

    // video の映像を canvas にセット
    let canvasCtx = canvas.getContext('2d');
    canvasUpdate();

    function canvasUpdate() {
      canvasCtx.drawImage(video, 0, 0, canvas.width, canvas.height);
      requestAnimationFrame(canvasUpdate);
    };

    // canvasの画像をダウンロード
    function downloadImage() {
      var base64 = canvas.toDataURL();
      document.getElementById("download").href = base64;
    }
</script>
</html>

kintone でWebカメラを表示し保管するアプリを作る

前座が長くなりましたが、いよいよ kintone に Webカメラの画像を保管する方法を試します。
今回「Webカメラ写真保存」というアプリを追加しました。
WebCam01.png

フィールドの設定

アプリのフィールドは以下のように設定しています。(レコード番号などはお好みで。)

フィールド名 タイプ フィードコート・要素ID その他
添付ファイル 添付ファイル 添付ファイル サムネイルの大きさは 250x250
Webカメラ画像 グループ Webカメラ画像 明細画面ではWebカメラフィールドを非表示に
        スペース videoPreview Webカメラ画像グループ内に配置
        スペース canvasPreview Webカメラ画像グループ内に配置
ファイルID 文字列(1行) ファイルID Webカメラ画像グループ内に配置

JavaScript プログラムの作成

アプリに以下のJavaScriptのプログラムファイルを追加します。
JavaScript でWebカメラをcanvasに表示する方法についてはすでに述べたので、kintone に実装する際のポイントのみを説明します。

・レコード追加画面でWebカメラ画像を表示する
先ず kintone に video や canvas を表示するには、kintone のスペースを利用します。
kintone のレコード追加画面のイベント処理 kintone.events.on(eventsCreateShow, function(event) {}) 内ではそれぞれ showWebCamera() で video にWebカメラの表示、makeCanvas() で canvas の準備を行っています。スペースの要素IDを利用してgetSpaceElement() で Elementを取得し、必要な DOM を追加します。DOM の追加については、先に説明しましたので省略します。

        let video    = showWebCamera();
        let canvas   = makeCanvas(isMobile, video);

        if(!isMobile) {
            kintone.app.record.setFieldShown('添付ファイル', false);
            kintone.app.record.getSpaceElement('videoPreview').appendChild(video);
            kintone.app.record.getSpaceElement('canvasPreview').appendChild(canvas);

・「撮影」ボタン押下で canvas に画像を表示、kintone にファイルをアップロード
makeCanvas() のメソッドでは、kintone のアプリでは video のWebカメラ動画から「撮影」ボタンを押したタイミングで canvas に表示すると同時に、画像をkintoneにアップロードし、その返信の fileKey をフィールドの「ファイルID」にセットしています。

    // 撮影用画像をcanvasに表示
    function makeCanvas(isMobile, video) {

        if(video == null) return;
        let canvasPreview = document.createElement('div');

        let takeButton       = document.createElement('button');
(中略)
        // 撮影ボタンが押されたら canvas に画像を転送
        takeButton.onclick   = function() {
            let canvasCtx = canvas.getContext('2d');
            canvasCtx.drawImage(video, 0, 0, canvas.width, canvas.height);
            let blob = convertBlobImage(canvas);
            saveImage(isMobile, blob);
        };
        canvasPreview.appendChild(takeButton);
(中略)
        return canvasPreview;
    }

「撮影」ボタンを押したタイミングでアップロードするのは、kintone の添付ファイルフィールドを直接 JavaScript で操作できないため、JavaScript APIをコールして事前にファイルをアップロードし、全ての入力を終えて submit で kintone のレコードが追加された後(無論添付ファイルは空の状態)に、先のアップロードで返ってきた fileKey をセットしAPIで再度更新を行うことで、やっと添付ファイルが紐づけられます。

    // 画像をkintoneに保存
    function saveImage(isMobile, blob){

        var record;
        if (isMobile) {
            record = kintone.mobile.app.record.get();
(中略)    
        // ファイルアップロード
        var key = "";
        var formData = new FormData();
        formData.append('__REQUEST_TOKEN__', kintone.getRequestToken());
        formData.append('file', blob, 'image.png');

        var xmlHttp = new XMLHttpRequest();
        xmlHttp.open('POST', kintone.api.url('/k/v1/file', true), false);
        xmlHttp.setRequestHeader('X-Requested-With', 'XMLHttpRequest');
        xmlHttp.send(formData);
        if (xmlHttp.status === 200) {
            key = JSON.parse(xmlHttp.responseText).fileKey;
        }
        record.record["ファイルID"].value = key;

        if (isMobile) {
            kintone.mobile.app.record.set(record);
(中略)    
    }

さらに面倒なのは、canvas のデータは Base64 には簡単に変換できるのですが、ファイルとしてアップロードするために blob データでなければならない点です。以下は canvas の画像を blob データに変換する処理です。最後の参照先でも紹介していますが、qiita に大変有難い例がありましたので、参考にさせていただきました。(感謝!)

    // canvas画像をblobデータに変換
    function convertBlobImage(canvas) {

        if(canvas == null) return;

        var base64 = canvas.toDataURL("image/jpeg");
        // Base64からバイナリへ変換
        var bin = atob(base64.replace(/^.*,/, ''));
        var buffer = new Uint8Array(bin.length);
        for (var i = 0; i < bin.length; i++) {
            buffer[i] = bin.charCodeAt(i);
        }
        // Blobを作成
        var blob = new Blob([buffer.buffer], {
            type: 'image/png'
        });

        return blob;
    }

・レコードが追加された後に、先にアップロードしたファイルと紐づけの更新を行う
先にも説明したとおり、添付ファイルフィールドは javaScript から直接操作できないため、事前にアップロードした際の fileKey を、レコード追加後に行う必要があります。kintone.events.on(eventsEditSuccess, function(event) {}) 内で、以下のようにAPI経由で更新します。本来は promise で同期させた方が良いのですが、今回は XMLHttpRequest() の同期処理で実装しています。

        var json = {
            app: kintone.app.getId(),
            id: event.record["$id"].value,
            record: {
                "添付ファイル": {
                    value: [{ fileKey: event.record["ファイルID"].value }]
                }
            },
            "__REQUEST_TOKEN__": kintone.getRequestToken()
        };

        var xmlHttp = new XMLHttpRequest();
        xmlHttp.open('PUT', kintone.api.url('/k/v1/record', true), false);
        xmlHttp.setRequestHeader('X-Requested-With', 'XMLHttpRequest');
        xmlHttp.setRequestHeader('Content-Type', 'application/json');
        xmlHttp.send(JSON.stringify(json));

実行結果

・追加画面でWebカメラ表示
WebCam05.png

・追加画面で「撮影」
WebCam06.png

・追加画面で「保存」した後の明細画面
WebCam07.png

・変更画面で「撮影」した場合
WebCam08.png

以上、予定していた実装が一通りできました。
以下が全てのコードです。

webcam.js
(function() {
    "use strict";

    const cameraSize = { w: 320,  h: 240 };
    const canvasSize = { w: 320,  h: 240 };
    const resolution = { w: 1080, h: 720 };

    // レコード追加時表示イベント
    var eventsCreateShow = [
        'app.record.create.show',
        'mobile.app.record.create.show'];
    kintone.events.on(eventsCreateShow, function(event) {

        let isMobile = false;
        if(event.type === 'mobile.app.record.create.show'){
            isMobile = true;
        }

        let video    = showWebCamera();
        let canvas   = makeCanvas(isMobile, video);

        if(!isMobile) {
            kintone.app.record.setFieldShown('添付ファイル', false);
            kintone.app.record.getSpaceElement('videoPreview').appendChild(video);
            kintone.app.record.getSpaceElement('canvasPreview').appendChild(canvas);
        }else{
            kintone.mobile.app.record.setFieldShown('添付ファイル', false);
            kintone.mobile.app.record.getSpaceElement('videoPreview').appendChild(video);
            kintone.app.record.getSpaceElement('canvasPreview').appendChild(canvas);
        }
        event.record["ファイルID"].disabled = true;

        return event;
    });

    // レコード編集時表示イベント
    var eventsEditShow = [
        'app.record.edit.show',
        'mobile.app.record.edit.show'];
    kintone.events.on(eventsEditShow, function(event) {

        let isMobile = false;
        if(event.type === 'mobile.app.record.edit.show'){
            isMobile = true;
        }
        let video    = showWebCamera();
        let canvas   = makeCanvas(isMobile, video);

        if(!isMobile) {
            kintone.app.record.getSpaceElement('videoPreview').appendChild(video);
            kintone.app.record.getSpaceElement('canvasPreview').appendChild(canvas);
        }else{
            kintone.mobile.app.record.getSpaceElement('videoPreview').appendChild(video);
            kintone.app.record.getSpaceElement('canvasPreview').appendChild(canvas);
        }
        event.record["添付ファイル"].disabled = true;
        event.record["ファイルID"].disabled = true;

        return event;
    });

    // レコード追加・編集後イベント
    var eventsEditSuccess = [
        'app.record.create.submit.success',
        'app.record.edit.submit.success', 
        'mobile.app.record.create.submit.success',
        'mobile.app.record.edit.submit.success'];
    kintone.events.on(eventsEditSuccess, function(event) {

        var json = {
            app: kintone.app.getId(),
            id: event.record["$id"].value,
            record: {
                "添付ファイル": {
                    value: [{ fileKey: event.record["ファイルID"].value }]
                }
            },
            "__REQUEST_TOKEN__": kintone.getRequestToken()
        };

        var xmlHttp = new XMLHttpRequest();
        xmlHttp.open('PUT', kintone.api.url('/k/v1/record', true), false);
        xmlHttp.setRequestHeader('X-Requested-With', 'XMLHttpRequest');
        xmlHttp.setRequestHeader('Content-Type', 'application/json');
        xmlHttp.send(JSON.stringify(json));

    });

    // レコード詳細表示時イベント
    var eventsDetailShow = [
        'app.record.detail.show',
        'mobile.app.record.detail.show'];
    kintone.events.on(eventsDetailShow, function(event) {

        let isMobile = false;
        if(event.type === 'mobile.app.record.edit.show'){
            isMobile = true;
        }
        if(!isMobile) {
            kintone.app.record.setFieldShown('Webカメラ画像', false);
        }else{
            kintone.mobile.app.record.setFieldShown('Webカメラ画像', false);
        }

    });

    // Webカメラ画像をvideoに表示
    function showWebCamera() {
        let video      = document.createElement('video');
        video.id       = 'video';
        video.width    = cameraSize.w;
        video.height   = cameraSize.h;
        video.autoplay = true;

        let media = navigator.mediaDevices.getUserMedia({
            audio: false,
            video: {
                width:  { ideal: resolution.w },
                height: { ideal: resolution.h }
            }
        }).then(function(stream) {
            video.srcObject = stream;
        });

        return video;
    }

    // 撮影用画像をcanvasに表示
    function makeCanvas(isMobile, video) {

        if(video == null) return;
        let canvasPreview = document.createElement('div');

        let takeButton       = document.createElement('button');
        takeButton.id        = 'takeButton';
        takeButton.innerText = ' 撮 影 ';
        takeButton.className = "gaia-ui-actionmenu-save";
        takeButton.onclick   = function() {
            let canvasCtx = canvas.getContext('2d');
            canvasCtx.drawImage(video, 0, 0, canvas.width, canvas.height);
            let blob = convertBlobImage(canvas);
            saveImage(isMobile, blob);
        };
        canvasPreview.appendChild(takeButton);
        canvasPreview.appendChild(document.createElement('br'));

        let canvas    = document.createElement('canvas');
        canvas.id     = 'canvas';
        canvas.width  = canvasSize.w;
        canvas.height = canvasSize.h;
        canvasPreview.appendChild(canvas);

        return canvasPreview;
    }

    // 画像をkintoneに保存
    function saveImage(isMobile, blob){

        var record;
        if (isMobile) {
            record = kintone.mobile.app.record.get();
        }else{
            record = kintone.app.record.get();
        }

        // ファイルアップロード
        var key = "";
        var formData = new FormData();
        formData.append('__REQUEST_TOKEN__', kintone.getRequestToken());
        formData.append('file', blob, 'image.png');

        var xmlHttp = new XMLHttpRequest();
        xmlHttp.open('POST', kintone.api.url('/k/v1/file', true), false);
        xmlHttp.setRequestHeader('X-Requested-With', 'XMLHttpRequest');
        xmlHttp.send(formData);
        if (xmlHttp.status === 200) {
            key = JSON.parse(xmlHttp.responseText).fileKey;
        }
        record.record["ファイルID"].value = key;

        if (isMobile) {
            kintone.mobile.app.record.set(record);
        }else{
            kintone.app.record.set(record);
        }
    }

    // canvas画像をblobデータに変換
    function convertBlobImage(canvas) {

        if(canvas == null) return;

        var base64 = canvas.toDataURL("image/jpeg");
        // Base64からバイナリへ変換
        var bin = atob(base64.replace(/^.*,/, ''));
        var buffer = new Uint8Array(bin.length);
        for (var i = 0; i < bin.length; i++) {
            buffer[i] = bin.charCodeAt(i);
        }
        // Blobを作成
        var blob = new Blob([buffer.buffer], {
            type: 'image/png'
        });

        return blob;
    }

})();

まとめ

今回は kintone でWebカメラの映像を表示、canvas に「撮影」した画像を表示して添付ファイルとして保管できることを確認しました。Web カメラ以外でも canvas に表示したグラフや解析画像などもこの手順を応用して、kintone の添付ファイルとして保管できます。
添付ファイルで保存できれば、毎回グラフィックなどの表示にリソースを取られなくて済みますし、一覧で便利な添付ファイルの表示機能なども利用できるようになるので、今後はこの手法を活用するつもりです。

参照先

Webカメラの映像をcanvasに表示させる
https://qiita.com/chelcat3/items/02c77b55d080d770530a
canvasの画像をBlobに変換
https://qiita.com/0829/items/a8c98c8f53b2e821ac94
ファイルアップロードで必須となる3つの手順
https://developer.cybozu.io/hc/ja/articles/200724665
MediaDevices.getUserMedia()
https://developer.mozilla.org/ja/docs/Web/API/MediaDevices/getUserMedia
Window.requestAnimationFrame()
https://developer.mozilla.org/ja/docs/Web/API/Window/requestAnimationFrame
HTMLCanvasElement.toDataURL()
https://developer.mozilla.org/ja/docs/Web/API/HTMLCanvasElement/toDataURL
FormData オブジェクトの利用
https://developer.mozilla.org/ja/docs/Web/Guide/Using_FormData_Objects

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

javascript学習(2020/06/23)

位置情報の取得

sample.js
navigator.geolocation.getCurrentPosition(success, fail);

navigatorオブジェクトのgeolocationオブジェクトのgetCurrentPositionメソッド。
このメソッド内の()内には2つのパラメータを含める。1つ目には位置情報が取得できたときに呼び出すファンクション名を、2つ目には失敗したときに呼び出すファンクション名を指定する。

エラーコード

sample.js
error.code
エラーコード 説明
1 位置情報を取得する許可がない
2 何らかのエラーが発生して位置情報が取得できなかった
3 タイムアウト

Web API(Application Programming Interface)

Wec APIとは特定のデータを取得したり、画像をアップロードしたり、Webサイトが提供する何らかの機能をプログラムから利用する仕組みのこと。
一般的には機能ごとに専用のURLが用意されている。機能を使うときはJavaScriptのAjaxを使ってそのURLにアクセスする。

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

【Nuxt.js】Nuxt文法編:@click

前置き

「すごく勉強になる!?」
とコメントいただけた
人気の文法編です?
今回は@click

❓どんな時に使うか
クリックでJSを実行したい時に使います?
使用率は高いのでぜひマスターしてください?

TODOリストをクリックで削除したり
https://note.com/aliz/n/nda7438249ca8
クリックでページ遷移したり
https://note.com/aliz/n/nd9f344e4686f
modalをクリックで開閉したり…!
https://note.com/aliz/n/nd6e771724cd7

@click

@click
v-on:click

どちらでも同じ意味です。
@はv-on:の省略です?

v-on:hoge
hogeイベントが起きた時

v-on:
DOMイベントの発火時に
JavaScriptを実行?

v-on:click
クリックイベントが起きた時に
JavaScriptを実行?

実行式を直接記載

@click="実行式"
実行式を直接書きます✍️
ただ通常は次のmethodsを呼び出すのが一般的です!
それが何故かを理解するために、把握する程度でOK!

ここでdataのcounterを使う場合は
thisが不要です!
methods内で呼び出す場合は
必要になります?

index.vue
<template>
 <div class="page">
   <p>{{ counter }}</p>
   <button @click="counter += 1">
     click!
   </button>
 </div>
</template>

<script>
export default {
 data () {
   return {
     counter: 0,
   }
 },
}
</script>

?+=
 演算子です。
 変数の値(counter: 0)と
 式の値(1)を加算して
 その和を変数(counter)に代入します♠️
 counterの0に1を足して
 counter自体が1になります。
 そしてまた1を足すとcounterが2, 次は3…
 と続いていきます?

結果
@click.gif

メソッドイベントハンドラ

@click="methods名"
実行式が長くなることが多いため
methods名を呼び出せます?‍♀️

?パターン1?

index.vue
<template>
 <div class="page">
   <p>{{ counter }}</p>
   <button @click="countUp">
     click!
   </button>
 </div>
</template>

<script>
export default {
 data () {
   return {
     counter: 0,
   }
 },
 methods: {
   countUp () {
     this.counter += 1
   },
 },
}
</script>

結果
先ほどと同じです?‍♀️

?パターン2?

index.vue
<template>
 <div class="page">
   <button @click="alertMessage">
     alertMessage
   </button>
   <button @click="alertEvent">
     alertEvent
   </button>
 </div>
</template>

<script>
export default {
 data () {
   return {
     name: 'Nuxt.js'
   }
 },
 methods: {
   alertMessage () {
     alert (`Hello ${this.name}!`)
   },
   alertEvent (event) {
     alert (event.target.tagName)
   },
 },
}
</script>

?テキスト${変数名}
 methodsのalertMessageで使用。
 テンプレートリテラルです。
 通常は文字列を''で囲み
 +で変数を連結しますが…
 いちいち''で囲むのは面倒?
 'テキスト' + 変数名 + 'テキスト'

 テンプレートリテラルを使えば
 いちいち文字を''で区切らず
 ``(バッククォート)で全体を囲み
 変数の部分だけ${変数名}にすれば
 簡単に連結できます?

?event.target.tagName
 methodsのalertEventで使用。
 event.target
 クリックイベントが起きている物
 [object HTMLButtonElement]
 event.target.tagName
 buttonタグの名前
 buttonが表示されます?
 大文字で返されます。
 https://developer.mozilla.org/ja/docs/Web/API/Element/tagName

結果
@click2.gif

インラインメソッドハンドラ

@click="methods名(引数に代入する物)"
インラインでJavaScript実行式を書けます!
文字列'hi'を表示させてます?

index.vue
<template>
 <div class="page">
   <button @click="say('hi')">
     Say hi
   </button>
 </div>
</template>

<script>
export default {
 methods: {
   say (message) {
     alert (message)
   },
 },
}
</script>

結果
@click3.gif

?say(変数)
 文字列ではなく変数でも⭕️
 say(name)にした場合は
 dataのnameが呼び出され
 表示は文字列'myName!'になります?
 data () {
  return {
   name: 'myName!'
  }
 },

?ちなみにsayが変数、'hi'が変数名です!

インラインステートメントハンドラ

@click="methods名($event)"
$event変数でDOMイベントの参照ができます!

❓DOMイベントとは
 DOM:文字の色を変えたり、
    プログラムからhtmlなどを操作できる仕組みのこと
 DOMイベント:操作するためのイベント

【イベントの種類】
 いくつか記載しましたが
 他にもまだまだ沢山あります!
 気になる方は調べてみてください?

イベントハンドラ
JavaScriptイベントハンドラ/
--| mouseイベント(DOMイベント)/
-----| @click(type)
-----| @mousedown(type)
-----| @mouseup(type)
--| keyboardベント/
-----| @click
-----| @mousedown
-----| @mouseup
--| inputイベント/
-----| @input
-----| @change

【解説/index.vue】
・textChange (event)

 イベントを受け取る際は$が不要です?
・event.target.value
 今回のMouseEventが起きている
 target(button)のvalue(りんご、みかん)

index.vue
<template>
 <div class="page">
   <p>{{ text }} が好き</p>
   <button
     value="りんご"
     @click="textChange($event)"
   >
     りんご
   </button>
   <button
     value="みかん"
     @click="textChange($event)"
   >
     みかん
   </button>
 </div>
</template>

<script>
export default {
 data () {
   return {
     text: 'りんご or みかん',
   }
 },
 methods: {
   textChange (event) {
     console.log(event)
     this.text = event.target.value
   },
 },
}
</script>

?$eventは省略可能
 @click="methods名"だけでもOKです!

?@click="textChange($event.target.value)"
 もちろんここで直接値を渡してもOKです!
 consoleの表示が変わるので見てみてください?
 methodsの変更もお忘れなく〜?
 textChange (event) {
  console.log(event)
  this.text = event
 },

結果
@click4.gif

?console.log(event)
 今回はMouseEventが表示されています?
 中身を見るとtype: "click"が出てきますよ?

イベント修飾子

@click.修飾子="methods名"
様々な修飾子(event modifiers)が使えます!

・stop
 ネストされた子要素のmethodsが
 親要素に伝搬するのを防ぎます

【index.vue】
親要素、子要素共に同じmethodsを使用してます。
stopなし:親と子の2回のalertが表示される
stopあり:子の1回のみalertが表示される

index.vue
<template>
 <div @click="handler">
   <button @click.stop="handler">
     イベント実行ボタン
   </button>
 </div>
</template>

<script>
export default {
 methods: {
   handler (element) {
     alert(element)
   },
 },
}
</script>

他にも色々あります!
・prevent
 Event.preventDefault()と同じです。
 @submit.preventでよく使います。   https://note.com/aliz/n/n5b9bd618399e

・capture
・self
・once
・passive

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

create-react-appでhot reloadが効かなくなった話

会津大学でプログラム書いてる学生です。

備忘録代わりにcreate-react-appでreact書いてた際にhot reloadが効かなくなった話をします。

もくじ


かんきょう

  • ubuntu 18.04.4 LTS
  • create-react-app 3.3.1
  • react 16.13.1 くらい

あらすじ

久々にcreate-react-appでなんか作るか!と思いコーディング。
この際typescriptも入れよう!と思い途中からTypescriptを導入。
日をまたいで、さぁコーディングするかとnpm run startしたらホットリロードが効かないことに気づく。
なんかわからんけど効かない。
おそらく途中からtypescriptを導入したので、そのへんで色々あるのかなぁと予想。

しこうさくご

ためしたもの

react-app-rewire-hot-loaderを導入

  • react-app-rewire
  • react-hot-loaderみたいなのも導入する必要があったっぽい

結果として「react-dev-utils/crossspawnがないぞ」って怒られてlocalhost立てられなかった。
解決方法は不明。

create-react-appのプロジェクトを削除し、--typescriptオプションで最初からtypescript対応プロジェクトにした

ウキウキでnpm run startしてもホットリロードは効かず。
プロジェクトファイル消してもっかいつくれば大丈夫でしょ!とか思ったらそうでもなかった。

たいおうさく

上手くいったのは環境変数を追加したらなんとかなりました。
参考記事に感謝。

echo CHOKIDAR_USEPOLLING=true >> .env

参考

Is it possible to get hot-reload with typescript working? #6503
react-app-rewire-hot-loader
vagrant上でcreate-react-appのhot-reloadが効かない場合の対応

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

【JavaScript】Photon + PlayCanvasを使ってモバイル・デスクトップで動く一人称視点のマルチプレイができる空間を作る【WebGL】

PhotonのJavaScript SDKとPlayCanvasを組み合わせて利用するためのリポジトリです。
大体30分ほどで、ブラウザー上で動く、リアルタイムで位置と回転が同期されているゲームを作ることができます。PlayCanvasとネットワークエンジン PhotonのJavaScript SDKを使用してリアルタイム通信のできる空間を作っていきます。

今回作るもの

前回の記事【JavaScript】 PlayCanvasの公式サンプルを使ってモバイル・デスクトップで動く一人称視点の空間を作る【WebGL】
の続きとして、リアルタイムで動ける3Dのゲームを作ります。

※ こちらは一つのURLを3画面で開いたものを録画したものです。
今回の完成品となるプロジェクトはこちらです
https://playcanv.as/p/f6yr7YSW/

それでは早速作っていきます

準備するもの

準備 アカウント作成 (PlayCanvasとPhotonアカウントの作成)

PlayCanvasについて

PlayCanvas Editorを使用して開発をしていきますので、
今回のプロジェクトを実装する上で必要なPlayCanvasアカウントについてはこちらから作成ください。
https://playcanvas.com/

Photonについて

マルチプレイを簡単に実現!世界No.1の独立系ネットワーキングエンジンかつマルチプレイヤープラットフォーム— 迅速で信頼性が高く、拡張可能です。インディーかプロかを問わず、あらゆる用途に対応します。
https://www.photonengine.com/ja/photon

Photonは、Unity多くで使われているようですが、PhotonのREALTIMEにはJavaScriptのSDKも存在しており、20CCU(同時接続)までのプロジェクトでは無料で使用できます。

準備 プロジェクトの準備

こちらの手順は前回のプロジェクトを作成してる方はスキップして頂いても構いません。

前回の記事
【JavaScript】 PlayCanvasの公式サンプルを使ってモバイル・デスクトップで動く一人称視点の空間を作る【WebGL】
の続きですが、こちらを作成されている方は、そのまま利用できます。
この記事で初めてPlayCanvasを触る方は、フォーク機能を使って前回までのプロジェクトをコピーしましょう。

プロジェクトをフォークする


こちらは前回作成したプロジェクトをのASSETSを一部フォルダを整理したプロジェクトです。
このプロジェクトをフォークして使います。

1. フォークするプロジェクトへアクセス

フォークするためのプロジェクトにアクセスをします。

https://playcanvas.com/project/696633/

2. Forkをクリックしてプロジェクトを複製する

3. フォークしたプロジェクトからEDITORへアクセス

4. シーンを選択

PlayCanvas エディタ

PlayCanvas エディタはゲームを構成するシーンやエンティティを作成および編集するために使用する視覚的な編集ツールです。
ブラウザー内で実行できるのでどこでも利用可能。
PlayCanvas エンジンを使ってシーンをレンダリングします。WYSIWYG。
ボタン一つでゲームを新規タブでプレイ。
ライブ編集によりゲーム実行時にもイテレート可能。
https://developer.playcanvas.com/ja/user-manual/designer/

起動

プロジェクトの作成はこれで完了しました。
起動すると、一人称視点で動き回れる事がわかります。
このプロジェクトからPhotonとPlayCanvasを組み合わせて作っていきましょう。

Photonの準備 プロジェクト作成 ~SDKの準備

1.管理画面から「新しくアプリを作成する」をクリック

Photonの管理画面にアクセスをします。

https://dashboard.photonengine.com/ja-JP/PublicCloud

2.Photon Realtimeを選択してプロジェクトを作成

3. アプリケーションIDのコピー

プロジェクトを作成することができました、こちらのアプリケーションIDを使用しますので保存をしておきましょう。

4. SDKをダウンロード

こちらからPhotonのJavaScript SDKをダウンロードします。

使用するのは、v4.1.0.1のバージョンのSDKを使用します。

SDKはZIP形式でダウンロードされますので解凍します。

5. SDKをPlayCanvasへドラッグアンドドロップ

解凍したPhotonのSDKをドラッグアンドドロップドロップで配置します

ファイルはphoton-javascript-sdk_v4-1-0-1/libディレクトリにある。Photon-Javascript_Emscripten_SDK.min.jsを使用します。

6. scriptsフォルダーへ移動

ディレクトリの構造によって動作は変わりませんが、管理しやすくするために、scriptsフォルダーにPhotonのSDKをドラッグアンドドロップで移動させましょう。

実装

PlayCanvasでPhotonのSDKを利用するためのスクリプトを書きます。
今回は、3つのファイルを作成します。

スクリプトファイルを3つ作成する

3つのファイルをエディタ上から保存します。

  1. photon-playcanvas.js
  2. photon-loadbalancing.js
  3. photon-playcanvas-connect.js

1. photon-playcanvas.jsを作成

a. ASSETSで右クリック→ New Asset → Script

b. ファイル名photon-playcanvas.jsを入力

2. 同じ手順でphoton-loadbalancing.js,photon-playcanvas-connect.jsを作成

先程と同様の手順でphoton-playcanvas.jsphoton-playcanvas-connect.jsを作成します。

この手順を終えたときにこの3つのファイルが新規に追加されていれば大丈夫です。

スクリプトファイルをコードエディターで編集する

作成した3つのスクリプトファイルをコードエディタから編集をします。

それぞれのコードについては、GitHub こちらのリポジトリにも公開しております。

コードエディタで編集する方法は、ASSETSの中にあるスクリプトファイルをダブルクリックすることでコードエディタを開くことができます。コードエディタを開くことができたらそれぞれのファイルにソースコードをコピーしましょう。

コードエディタについて

コードエディタについて、ショートカットキー詳しく知りたい方は公式ドキュメントが参考になります。

スクリプトアセットはPlayCanvasのコードエディタを使用して編集されます。エディタでファイルを開くにはスクリプトアセットをダブルクリックします。コード編集の権限を持つ全てのユーザはエディタでリアルタイムに共同編集することができます。コードエディタ画面の右下に他のユーザーのアバターが表示されます。
https://developer.playcanvas.com/ja/user-manual/scripting/code-editor/

それぞれのスクリプトをファイルにコピーしていきます。

こちらのコードになります。

1. photon-playcanvas.js

PlayCanvasのゲームが起動されたときにPhotonを利用するためのスクリプトになります。

photon-playcanvas.js
/*jshint esversion: 6, asi: true, laxbreak: true*/

class PhotonPlayCanvas extends pc.ScriptType {
    initialize () {
      const options = {
        app: this.app,
        appId: this.appId,
        appVersion: this.appVersion,
        wss: this.wss ? 1 : 0
      }

      // Photonのセットアップ
      this.app.photon = new LoadBalancing(options)

      // Connect to Photon Server
      this.app.photon.connectToRegionMaster(this.region)

      this.app.on('lobby:join', () => {
        if (this.app.photon.roomInfos.length === 0) {
          try {
            this.createRoom(this.roomName)
          } catch (e) {
            this.joinRoom(this.roomName)
          }
        } else {
          this.joinRoom(this.roomName)
        }
      })
    }

    createRoom (roomName) {
      this.app.photon.createRoom(roomName)
    }

    joinRoom (roomName) {
      this.app.photon.joinRoom(roomName)
    }
  }

  pc.registerScript(PhotonPlayCanvas)
  // LoadBalancing options
  PhotonPlayCanvas.attributes.add('appId', { type: 'string' })
  PhotonPlayCanvas.attributes.add('appVersion', {
    type: 'string',
    default: '1.0'
  })
  PhotonPlayCanvas.attributes.add('wss', { type: 'boolean', default: true })

  // Photon realtime options
  PhotonPlayCanvas.attributes.add('region', {
    type: 'string',
    default: 'jp',
    description:
      'Photon Cloud has servers in several regions, distributed across multiple hosting centers over the world.You can choose optimized region for you.',
    enum: [
      { 'Select Region': 'default' },
      { 'Asia, Singapore': 'asia' },
      { 'Australia, Melbourne': 'au' },
      { 'Chinese Mainland (See Instructions)    Shanghai': 'cn' },
      { 'Canada, East Montreal': 'cae' },
      { 'Europe, Amsterdam': 'eu' },
      { 'India, Chennai': 'in' },
      { 'Japan, Tokyo': 'jp' },
      { 'South America, Sao Paulo': 'sa' },
      { 'South Korea, Seoul': 'kr' },
      { 'USA, East Washington': 'us' },
      { 'USA, West San José': 'usw' }
    ]
  })

  // Room options
  PhotonPlayCanvas.attributes.add('roomName', { type: 'string', default: 'room' })

2. photon-loadbalancing.jsを作成

Photon SDK内のLoadBalancingClientを利用して、Photonを使用するためのスクリプトになります。

photonloadbalancing.js
class LoadBalancing extends Photon.LoadBalancing.LoadBalancingClient {
  constructor (props) {
    // Photon Settings
    const wss = props.wss
    const appId = props.appId
    const appVersion = props.appVersion
    super(wss, appId, appVersion)
    // pc.Application
    this.app = props.app
    this.setLogLevel(4)
  }

  onRoomList (e) {
    this.app.fire('lobby:join')
  }

  onJoinRoom (e) {
    this.app.fire('room:join')
  }

  onEvent (code, content, actorNr) {
    const payload = { code, content, actorNr }
    if (code === 0) {
      this.app.fire('sync:position', payload)
      return
    }
    if (code === 1) {
      this.app.fire('sync:rotation', payload)
    }
  }

  onActorJoin (e) {
    const payload = Object.assign({}, this.myRoomActors())
    this.app.fire('sync:players', payload)
  }

  onActorLeave (e) {
    const payload = Object.assign({}, this.myRoomActors())
    this.app.fire('sync:players', payload)
  }
}

3. photon-playcanvas-connect.jsを作成

Photonからイベントが発火された際にPlayCanvasに変更を加えるスクリプトになります。

photon-playcanvas-connect.js
/*jshint esversion: 6, asi: true, laxbreak: true*/
const EVENT_LIST = {
    POSITION: 0,
    ROTATION: 1,
    COMMAND: 2
  }
  class PhotonPlaycanvasConnect extends pc.ScriptType {
    raiseEvent (event, data) {
      // Photonを使用
      this.app.photon.raiseEvent(event, data)
    }
    // スクリプト読み込まれた際に一度実行される
    initialize () {
      this.isSync = false
      this.app.on('room:join', () => {
        this.isSync = true
        if (pc.platform.mobile) {
          //モバイル端末だった場合の処理

          this.app.touch.on(pc.EVENT_TOUCHMOVE, () => {
            // get current Player position & rotation
            const position = this.Player.getLocalPosition()
            const rotation = this.Player.findByName('Camera').getLocalRotation()

            this.raiseEvent(EVENT_LIST.ROTATION, Object.assign({}, rotation))
            this.raiseEvent(EVENT_LIST.POSITION, Object.assign({}, position))
          })
        } else {
          // KEYDOWNイベントが発火されたら同期をする
          this.app.keyboard.on(pc.EVENT_KEYDOWN, () => {
            // get current Player position & rotation
            const position = this.Player.getLocalPosition()

            this.raiseEvent(EVENT_LIST.POSITION, Object.assign({}, position))
          })
          // MOUSEMOVEイベントが発火されたら同期をする
          this.app.mouse.on(pc.EVENT_MOUSEMOVE, () => {
            const rotation = this.Player.findByName('Camera').getLocalRotation()
            this.raiseEvent(EVENT_LIST.ROTATION, Object.assign({}, rotation))
          })
        }
      })

      this.app.on('sync:players', actors => {
        const prefix = 'player_'
        const tagName = 'player'
        const actorNums = Object.values(actors).map(actor => actor.actorNr)
        // Playerを同期する
        for (let actor of Object.values(actors)) {
          const { isLocal, actorNr, name } = actor
          if (!isLocal) {
            const otherPlayer = this.PlayerTemplate.clone()
            otherPlayer.setName(`${prefix}${actorNr}`)
            otherPlayer.tags.add(tagName)
            otherPlayer.enabled = true
            this.app.root.addChild(otherPlayer)
            otherPlayer.setLocalPosition(0, 0, 0)
          }
        }
        // Playerのタグタグから現在いないPlayerを消す
        {
          const players = this.app.root.findByTag(tagName)
          for (let player of players) {
            const actorNr = player.name.replace(prefix, '')
            if (!actorNums.includes(Number(actorNr))) {
              player.destroy()
            }
          }
        }
      })

      this.app.on('sync:rotation', ({ content, actorNr }) => {
        const { y, w } = content
        const entity = this.app.root.findByName(`player_${actorNr}`)
        if (!entity) return

        entity.setLocalRotation(0, y, 0, w)
      })

      this.app.on('sync:position', ({ content, actorNr }) => {
        const { x, y, z } = content
        const entity = this.app.root.findByName(`player_${actorNr}`)
        if (!entity) return

        entity.setPosition(x, y + this.yOffset, z)
      })
    }
  }

  pc.registerScript(PhotonPlaycanvasConnect)

  // Room Option
  PhotonPlaycanvasConnect.attributes.add('roomName', {
    type: 'string',
    default: 'room'
  })

  // Sync option
  PhotonPlaycanvasConnect.attributes.add('Player', { type: 'entity' })
  PhotonPlaycanvasConnect.attributes.add('PlayerTemplate', { type: 'entity' })
  PhotonPlaycanvasConnect.attributes.add('yOffset', {
    type: 'number',
    default: -0.7
  })

  PhotonPlaycanvasConnect.attributes.add('reverse', {
    title: "逆向き",
    type: 'boolean',
    default: true
  })


設置

スクリプトをPlayCanvas Editorを使って適用していきます。

スクリプトの読み込み順を変える

PlayCanvasではSCRIPTを読み込む順番をエディタから制御します。
今回PhotonのSDKを使用しているのですが、SDKが読み込まれる前に、PhotonのAPIを使用するとエラーが出てしまいますので、スクリプトの読み込み順序を変更していきます。

1. 設定ボタンをクリック

2. ASSET LOAD TASKを変更する

スクリプトの読み込み順序を、PhotonのSDK → photon-loadbalancing → その他の順に変更します。

変更前

変更後

スクリプトの追加

1. Rootにスクリプトコンポーネントを追加

2. ADD SCRIPTSからphoton-playcanvas, photon-playcanvas-connectを追加

a. パースボタンをクリック

追加ができたらパースボタンをクリックします。
スクリプトのパースが行われると、roomNamePlayerなどの属性を追加できる要素が追加されます。

!マークなどが出た際には、パースボタンを押してから一度、パースボタンの横からスクリプトを削除してから、もう一度、ADD SCRIPTを押してください。

3. PhotonのアプリケーションIDを追加する

a. アプリケーションIDを取得

こちらのページからPhotonのアプリケーションIDを取得します。

b. 属性にアプリケーションIDをペースト

https://dashboard.photonengine.com/ja-JP/PublicCloud

これでPhotonとPlayCanvasのつなぎこみの設定は終わりました。
次にプレイヤーを追加していきます。

その他の属性の設定

1. Playerを選択して追加

a. PhotonPlaycanvasConnectスクリプトのPlayer属性をクリック

b. ヒエラルキーからPlayerエンティティを選択

スクリプト属性について

これで、スクリプトのPlayer属性にPlayerエンティティが追加されました。このような形でPlayCanvasでは、ソースコードを変更せず値を変更したい場合などについてはスクリプト属性を使用して値を設定します。

スクリプト属性
スクリプトのアトリビュート機能は、スクリプト内で使用する変数をPlayCanvasエディタ内で編集することができるようにする便利な機能です。この機能を使うことで、一度コードを書いた後にエンティティごと作られるインスタンスにそれぞれ違うパラメータを設定する調整ができるようになります。これにより、アーティスト、デザイナーやその他のプログラマーではないチームメンバーがコードを書かずに値を変更できるにプロパティを露出させることができます。
https://developer.playcanvas.com/ja/user-manual//script-attributes/

自分以外を表示するためのEntityを追加

今回はFPS視点なので、自分のキャラクターは追加をしないで、他の人が入ってきたときにきつねのアバターで参加をしてもらうことにします。今ヒエラルキーを上にあるFoxを使用して、他の人のアバターを設定しましょう。

1. ヒエラルキーからエンティティを追加する

ヒエラルキーの+ボタンをクリックし、Entityを追加します。

2. エンティティの名前をPlayerTemplateという名前に変更します。

3. ヒエラルキー上のFoxPlayerTemplateヒエラルキーの配下に配置

ヒエラルキー上から、FoxエンティティをPlayerTemplateの配下にドラッグアンドドロップをして配置します。

4. Rootのスクリプトコンポーネントに適用する

5. PlayerTemplateスクリプトを非表示にする

このPlayerTemplateとしたエンティティは他のプレイヤーが入ってきた際にスクリプトから制御をして表示を切り替えますので、ページを起動した際には、非表示にしておきます。

ここまでできれば終わりです。あとは起動するだけでマルチプレイのゲームができているかと思います。

起動

これで起動をしてみます。

これでPlayCanvasで動くマルチプレイのできるゲームを作成することができました。

ルームの機能がありますので、ルーム名を変えたりすることで参加するユーザーを区切ることもできます。
本日使用したPhotonのドキュメントと、PlayCanvasのドキュメントはそれぞれこちらになります。

Photon LoadBalancingClient
https://doc-api.photonengine.com/en/javascript/current/Photon.LoadBalancing.LoadBalancingClient.html

PlayCanvas ユーザーマニュアル
https://developer.playcanvas.com/ja/user-manual/

補足

向きを変更する

逆向きになっていますのでヒエラルキーをからFoxを選択してyrotate180度に設定してあげるといい感じになると思います。

ゲームを公開する

PlayCanvasでは作成したゲームをウェブ上ですぐに公開できます。

1. 左のMEBUからPUBLISH/DOWNLOADをクリック

publish.png

2. PUBLISH TO PLAYCANVASから公開

oc.png

3. BUILDSを確認

PUBLISHが成功するとBUILDSに共有できるURLが生成されます。
こちらを共有することで、第三者に完成したプロジェクトを公開できます。

URL.png

今回の完成品となるプロジェクトはこちらになります。 

https://playcanv.as/p/f6yr7YSW/

今回のプロジェクトで質問や意見がありましたら。GitHubのIssueか@mxcn3まで連絡をお願いします。


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

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

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

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

適切な型情報を書く

TL;DR

型情報はこのように書こう。

NG

type User = {
    id: number;
    name: string;
    role: string; // ここがダメ
}

OK

enum UserRole {
    Admin,
    User,
    Guest,
}

type User = {
    id: number;
    name: string;
    role: UserRole; // 入ってくる値の情報がわかりやすい!
}

stringとかnumberとか入れるのはやめたい。

TypeScriptを真面目に活用したいならここは意識しておきたい、というメモです。

実際に開発していると、stringやnumberに何が入るのかコードから読み取れない、と思うことがある。

例えば、誰かがUser型を作りroleにstringを入れたとします。

type User = {
    id: number;
    name: string;
    role: string; // userTypeにはstringが入る。そうか、では""だったりundefinedだったりとかありえる???どういう情報が入るのか意図が読み取れないぞ...
}

もしuserTypeに'Admin'や'Guest'を入れたいならこのような書き方ができます。

type UserRole = 'Admin' | 'User' | 'Guest'

type User = {
    id: number;
    name: string;
    role: UserRole;
}

numberやstringだけだと情報が読み取れないので、
このようにstringからどの値に何が入るのかわかるように型情報を作りましょう。

さらに発展すると、文字列からenumでよいことに気がつきます。

enum UserRole {
    Admin,
    User,
    Guest,
}

type User = {
    id: number;
    name: string;
    role: UserRole; // enumの3種類の値が入ることがわかり、変な値が入らないことを保証している
}

これですっきりしました^^

活用方法メモ

日付検索の条件を型で書くと

type WeekDay = 'Sun' | 'Mon' | 'Tue' | 'Wed' | 'Thu' | 'Fri' | 'Sat';

type SearchOption = {
    start: Date;
    end: Date;
    weekDay: WeekDay[]; // 曜日を検索したい
}


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

クリックしたところがおしゃれに光るナビゲーションバー 8日目【WEBサイトを作る30日チャレンジ】

ナビゲーションバーをクリックした箇所にスライドさせてグラデーションをかける

■ポイント
・CSSでグラデーションの処理(linear-gradientを使用)
・JavaScriptのdocument.querySelectorで要素を取得
・offsetLeftでクリックした対象要素の左からの幅をpx単位で(左)へ移動させる
・offsetWidthでクリックした対象要素のWidth(幅)を決定
(説明がわかりずらいかもしれません・・・検証ツールで幅の変化等を見て頂くとわかりやすいです)
offsetLeftの説明はこちらのサイト様を参考にいたしました(わかりやすかった、ありがとうございます)
https://syncer.jp/javascript-reference/element/offsetleft
念の為こちらも載せときます↓
https://developer.mozilla.org/en-US/docs/Web/API/HTMLElement/offsetLeft
・addEventListenerでクリックされた場所に反応するように指定
・そしてトップバーだけじゃ寂しいのでビデオ背景載せてます

■出来たサイトの動き

ezgif.com-video-to-gif (5).gif

■コード

HTML

<html lang="ja">
<head>
    <meta charset="UTF-8">
    <title>グラデーションナビバー</title>
    <link rel="stylesheet" href="30_8.css">
</head>
<body>
    <div class="nav-top">
        <nav>
            <div id="marker"></div>
            <a href="#">Home</a>
            <a href="#">Company</a>
            <a href="#">Work</a>
            <a href="#">Sample</a>
            <a href="#">Team</a>
            <a href="#">Contact</a>
        </nav>
    </div>
    <script type="text/javascript">
        const marker = document.querySelector('#marker');
        const item = document.querySelectorAll('nav a');

        function indicator(e){
            marker.style.left = e.offsetLeft+"px";
            marker.style.width = e.offsetWidth+"px";
        }

        item.forEach(link => {
            link.addEventListener('click', (e)=>{
                indicator(e.target);
            })
        })
    </script>

<video src="goomalling-storm.mp4" loop="" autoplay="" muted=""  width="100%" class="bgv"></video>
</body>
</html>

CSS

*
{
    margin: 0;
    padding: 0;
    box-sizing: border-box;
}
.nav-top
{
    display: flex;
    justify-content: center;
    background-color:black;
}
nav
{
    position: relative;
    display: flex;
}
nav a
{
    position: relative;
    margin: 10px 20px;
    padding: 5px;
    font-size: 2em;
    color: white;
    text-decoration: none;
}
nav #marker
{
    position: absolute;
    left: 0;
    height: 100%;
    width: 0;
    opacity: 0.5;
    background: linear-gradient(
    black,
    rgb(128, 0, 113),
    blue,
    black);
    bottom: 0px;
    transition: 0.5s;
    border-radius: 4px;
}

■苦労した点
 ・querySelectorとgetElementByIdの使い分け

■疑問点とやりたかったこと
 ・レスポンシブ最適化までいけませんでした。
 ・レスポンシブ最適化のため、少しいじりましたが、今の知識レベルでは時間がかかりそうだったため、タイミングを改めて実装してみようと思います。

以上となります。

ここは違う、ここはこうしたほうが良いかも?といったものございましたらご指摘いただけますと幸いです。
最後までみていただき、ありがとうございます。

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

mongooseを使って、dbを一括で消す方法

概要

❯ mongo
MongoDB shell version v4.0.3
connecting to: mongodb://127.0.0.1:27017
WARNING: No implicit session: Logical Sessions are only supported on server versions 3.6 and greater.
Implicit session: dummy session
MongoDB server version: 3.4.20
WARNING: shell and server versions do not match
Server has startup warnings:
2020-06-23T01:59:36.690+0000 I STORAGE  [initandlisten]
2020-06-23T01:59:36.690+0000 I STORAGE  [initandlisten] ** WARNING: Using the XFS filesystem is strongly recommended with the WiredTiger storage engine
2020-06-23T01:59:36.690+0000 I STORAGE  [initandlisten] **          See http://dochub.mongodb.org/core/prodnotes-filesystem
2020-06-23T01:59:39.381+0000 I CONTROL  [initandlisten]
2020-06-23T01:59:39.381+0000 I CONTROL  [initandlisten] ** WARNING: Access control is not enabled for the database.
2020-06-23T01:59:39.381+0000 I CONTROL  [initandlisten] **          Read and write access to data and configuration is unrestricted.
2020-06-23T01:59:39.381+0000 I CONTROL  [initandlisten]
> show dbs
admin                                            0.000GB
my_db                                            0.881GB
my_db_test_4e95cefc_26c9_42ea_801e_872f86090ade  0.000GB
my_db_test_5e95cefc_26c9_42ea_801e_872f86090ade  0.000GB
my_db_test_6e95cefc_26c9_42ea_801e_872f86090ade  0.000GB
local                                            0.000GB

このように、 my_db_hogehoge のように、testを実行するたびにmongoにdbが作られてしまっていて困っていました。
このmy_db_hogehogeを一括で削除するコマンドを作りました

実装

環境

❯ node --version
v10.16.3
❯ npm --version
6.9.0
package.json
{
  "dependencies": {
    "mongodb": "2.2.10",
    "mongoose": "5.2.10",
  },
  "devDependencies": {
    "@types/mongodb": "2.2.x",
    "@types/mongoose": "5.2.x",
  },
}

コード

removeTestDB.js
'use strict';

const mongoose = require('mongoose');
const { execSync } = require('child_process');

const dbName = 'my_db_test';
mongoose.connect('mongodb://localhost:27017').then(async () => {
  mongoose.connection.db.admin().command({ listDatabases: 1 }, (err, result) => {
    if (err) {
      console.log(err);
      throw err;
    }
    const testDBs = result.databases.map(d => d.name).filter(d => d.includes(dbName));
    testDBs.forEach(d => {
      execSync(`mongo ${d} --eval "db.dropDatabase()"`);
      console.log(`${d} is deleted`);
    });
    console.log(`all ${dbName} is deleted`);
    mongoose.disconnect();
  });
}).catch(err => {
  console.log(err);
  throw err;
});
  1. mongoose.connection.db.admin().command({ listDatabases: 1 } (err, result) => {})
    • こちらで、dbのリストを取得。(show dbs と同じ効果)
  2. execSync(mongo ${d} --eval "db.dropDatabase()");
    • こちらで、my_db_testの名前を含むdbを削除

おわりに

限定的かつ実用性はあまりなさそうなコードですが、メモがてら。
execSyncで実行しているのが少し微妙な気がするので、mongooseの機能で出来るならやりたいですね。

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

tusdとtus-js-client, uppyを使ったファイルアップロードをapache2.4のCentOS7で構築する(http編)

目的

  • 音の処理をするwebアプリ作っています。
  • ユーザの音源をfile uploadしたいので、レジューム可能なMITライセンスのライブラリのtus.ioを使いたいと思っていました。そのテストです。本当はhttpsとクッキーによるユーザ管理を前提にしているのですが、まずは慣れるためにhttpで動かしてみました。
  • tus.ioは日本語の情報が少ないのですが頑張ってChromeの日本語変換機能を活用しながら動かしてみました。
  • WebアプリはLATS (Linux, Apache, Textfile and ShellScript)で作っているので、tusのデーモンはtusdを使います。

環境

  • CentOS7
  • apache 2.4.41
  • browser Firefox77 (MacOS)など

参考

  1. 【tus】動画などの大容量ファイルアップロードに嬉しい「Resumable Upload」が簡単に実現できる。
  2. tus.io
  3. CentoOS7にyumでGoを入れてHello Worldするまで - Qiita
  4. uppy

インストール

goのインストール

  • tusdはgo言語で書かれています。そのためgoコンパイラが必要です。これは参考サイトに書いてあるとおりにすればそのとおりにインストールできました。$GOPATHの設定も書いてあるとおりにしました。問題なし。
$ sudo yum install -y golang
$ go version
go version go1.13.11 linux/amd64

tusdのインストール

$ git clone git@github.com:tus/tusd.git
$ cd tusd/
$ sudo go build -o tusd cmd/tusd/main.go

tusdを動かしてみる

  • 巣のままで動かしてみます。下記の様に動いています。
$ /usr/local/go/bin/tusd
[tusd] 2020/06/18 20:40:43 Using '/home/hoge/data' as directory storage.
[tusd] 2020/06/18 20:40:43 Using 0.00MB as maximum size.
[tusd] 2020/06/18 20:40:43 Using 0.0.0.0:1080 as address to listen.
[tusd] 2020/06/18 20:40:43 Using /files/ as the base path.
[tusd] 2020/06/18 20:40:43 Using /metrics as the metrics path.
[tusd] 2020/06/18 20:40:43 Supported tus extensions: creation,creation-with-upload,termination,concatenation,creation-defer-length
[tusd] 2020/06/18 20:40:43 You can now upload files to: http://0.0.0.0:1080/files/
  • ログの意味ですが、tusdはhttpのドキュメントルートから見た/filesをデフォルトディレクトリとしてファイルを格納します。モニターしているオリジン(?)はhttp://0.0.0.0:1080/files/です。プロトコルがhttpでドメインの0.0.0.0は全てのネットワークインタフェースを意味するそうです(参考:127.0.0.1とlocalhostと0.0.0.0の違い)。ポートは1080がデフォルトです。tusdはクライアントからのアップロードをこのURL(?)で受け付けると言っています。

tus-js-clientのインストール

  • ブラウザでファイルアップロードするためのJavaScriptとデモサンプルを試します。
  • こちらのサイトの通りにインストールします。
$ cd /tus-js-clientをインストールするディレクトリに.../
$ npm install --save tus-js-client

サーバをhttpを受け付ける様に設定をする

  • tusはhttpベースを基本にしているのでサーバもhttpで動く様にします。
  • apacheの制御の指示は/etc/httpd/conf/http.confでなされます。
  • httpd.confをオリジナルに戻してサーバ名だけ変えます。
# diff httpd.conf.org httpd.conf
95c95
< #ServerName www.example.com:80
---
> ServerName your-site.net:80
  • 他のモジュールはhttpd.confから/etc/httpd/conf.d/*confを読むことでロードされる設定になっています。
  • 今の設定はhttps用にopensslが動いているのですが、この設定を読まない様にしておきます。
# cd /etc/httpd/conf.d/
# mv ssl.conf _ssl.conf
# systemctl restart httpd

サーバの設定に合わせてtusdを動かす。

  • logも残しておきたいため、tusdを起動するためのスクリプトを書いきました。
  • uploadのデータを書き込める様にApacheを動しているユーザ名apacheで起動するようにしています。
$ cat /usr/local/bin/run_tusd.bash 
#!/bin/bash -vxeu
# tusdをオプション込みで起動する
# 2020-06-23; (C) @woodie2wopper

# logを残す
exec &> /var/www/log/$(basename $0).$(date '+%y%m%d_%H%M%S').$$.bash
sudo -u apache /usr/local/go/bin/tusd -upload-dir /var/www/files &

$ /usr/local/bin/run_tusd.bash &
passwordを入力

インストールしたデモ用のサイトを試す

  • デモのhtmlはインストールしたtus-js-clientのフォルダの中にあります。無編集でこれを立ち上げます。
  • サイト -> http://your-site.net/vendors/node_modules/tus-js-client/demos/browser/index.html
  • ポイントはUploadのendpointをhttpにすることと、1080のポート番号を入れることです。
  • サイトのドメインはテストサイト名で、画像ではURLを変えています。
  • ファイルを選択しただけでuploadが始まります。こんな感じです。

スクリーンショット 2020-06-23 13.02.45.png

  • 成功しています。パチパチ。

ドラッグアンドドロップできるuppyを動かす

  • tusのクライアントでtus-js-clientのラッパーのuppyも試してみます。
  • サーバサイドの設定は変えていません。
  • uppyを試すhtmlはURLだけ変えています。httpであること、ポート番号が1080であること、URLがきちんと"/"で終わっていること、がポイントです。
  • サンプルのソースコードはここに書いてあります。
  • これを適切ななまえに変えて変更点はendpoint:だけです。
$ cat html/test_uppy.html
...
        .use(Uppy.Tus, {
            endpoint: 'http://your-site.net:1080/files/',
...
  • 試すとD&Dでuploadできました。

スクリーンショット 2020-06-23 12.54.12.png

サーバを確認する

  • /files/に格納したファイルはユニークIDです。
  • UUIDと拡張子がinfoのファイルがあり、infoのファイルをjqで整形するとこんな感じです。
$ cd ./files
$ ll
合計 15524
-rw-r--r-- 1 apache apache     363 2020-06-23 10:34:56 896a0eb08e3c24558c9e8c7022fa2144.info
-rw-r--r-- 1 apache apache 3653046 2020-06-23 10:35:00 896a0eb08e3c24558c9e8c7022fa2144
...
$ cat 896a0eb08e3c24558c9e8c7022fa2144.info |jq
{
  "ID": "896a0eb08e3c24558c9e8c7022fa2144",
  "Size": 3653046,
  "SizeIsDeferred": false,
  "Offset": 0,
  "MetaData": {
    "filename": "IMG_4579.jpeg",
    "filetype": "image/jpeg",
    "name": "IMG_4579.jpeg",
    "relativePath": "null",
    "type": "image/jpeg"
  },
  "IsPartial": false,
  "IsFinal": false,
  "PartialUploads": null,
  "Storage": {
    "Path": "/your-site-directory/files/896a0eb08e3c24558c9e8c7022fa2144",
    "Type": "filestore"
  }
}

  • lockファイルもできます。処理中を意味するのでしょう。

どなたかのお役に立てれば嬉しいです。何か間違いやコメントいただけますと嬉しいです。

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

ApolloClient(React Hooks)でMutation後にQueryをRefetchする。

ApolloClientにて、useMutationでデータを更新した後にuseQueryのデータを再取得する方法に関して
ざっと調べた結果です。

useQueryのrefetch関数を利用する。

useQuery関数の戻り値に含まれるrefetch関数を実行することで、データの再取得ができます。
useMutation関数が同一コンポーネント内、もしくはすぐ配下のコンポーネントならば、
こちらの関数を利用すれば事足ります。

const { data, loading, error, refetch } = useQuery(ANY_GET_QUERY);

const [update, { loading, error }] = useMutation(ANY_SET_QUERY, {
  onCompleted() {
    refetch();
  },
});

FETCHING DATA > Queries > Refetching - APOLLO DOCS
API > @apollo/react-hooks > useQuery > Result

useMutationのrefetchQueriesオプションを利用する。

いくつかのWidgetを組み合わせた構成の画面などで、兄弟関係のコンポーネントのQueryをRefetchさせたい場合は、
useMutation関数のオプション引数refetchQueriesが便利です。

更新したいクエリ名を配列で渡すと、Mutation完了後に該当Queryを実行してくれます。
更新対象の可能性があるクエリ名を最大数記載しても、該当コンポーネントが無ければ
余分なクエリが実行されることはありません。

/**
 * Widget A : リストの表示
 */
const QUERY_A = gql`
  query QueryA {
    list(id: $id) {
      id
      name
    }
  } 
`;

const { data, loading, error } = useQuery(QUERY_A);

/**
 * Widget B : リストの表示
 */
const QUERY_B = gql`
  query QueryB($id: ID!) {
    list(id: $id) {
      id
      date
    }
  } 
`;

const { data, loading, error } = useQuery(QUERY_B);

/**
 * Widget C : リストの更新
 */
const [update, { loading, error }] = useMutation(QUERY_B, {
  refetchQueries: [
    'QueryA',
    'QueryB'
  ],
});

単にクエリ名を渡すのみの場合、クエリの引数は前回のものがそのまま活用されます。
名前文字列の代わりにvariablesを指定したオブジェクトを渡すことも可能です。
配列の代わりにuseMutationの結果を受け取る関数を渡すこともできます。

const [update, { loading, error }] = useMutation(QUERY_B, {
  refetchQueries: ( mutationResult ) => {
    return [
      'QueryA',
      {
        query: 'QueryB',
        variables: { id: mutationResult.id }
      }
    ]
  },
});

Updating after a mutation - APOLLO DOCS
useMutation API > Options - APOLLO DOCS
react-hoc API > options.refetchQueries - APOLLO DOCS

onCompleat関数が呼ばれない

refetchされたいくつかのクエリのうち、onCompleat関数が呼ばれるものと呼ばれないものがありました。
今のところ原因は不明です。。。
useQueryの戻り値のdataはちゃんと新しくなっていたので、取り急ぎonCompleatを利用せず、
代わりにReactのuseMemoを活用することで対処しました。
(情報求ム)

参考情報

https://github.com/apollographql/apollo-client/issues/1831

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

【JavaScript】html要素の基準フォントサイズを取得する

html要素の基準フォントサイズ取得

WEBサイトのレスポンシブル対応などで、html要素の基準フォントサイズが知りたくなったときにそれを取得することがあったのでメモ。

let root = document.documentElement; //htmlのルート要素を取得
let style = window.getComputedStyle(root).getPropertyValue('font-size'); //ルート要素のcssプロパティを全て取得し、その中からフォントサイズを取得
let stFontSize = parseFloat(style); //float型の数値に変換

window.getComputedStyleでcssプロパティを取得

let style = window.getComputedStyle(root).getPropertyValue('font-size');

この部分がポイントで、 window.getComputedStyle()の引数に入れたelementのcssプロパティを全て返してくれます。
https://developer.mozilla.org/ja/docs/Web/API/Window/getComputedStyle

なので、基準フォントサイズに限らず、引数を変えれば好きなelementのcssプロパティを取得出来ます。

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

個人開発?1週間で区切ると爆速で開発できるぞ と

(内心)
作ったゲームを沢山の人に触ってもらってリアクションが欲しいなぁ~
そうだ、記事投稿して宣伝しよう!

といった下心で書いた記事です。すみませんでした 遊んでください?

ペンギンがサメさんを倒すゲーム
敵さんを吹っ飛ばして 1,000 G 以上を目指してみてね?
https://games.westa.io/

1️⃣ この記事の対象読者

この記事では1週間で Web ゲームを作る為に何を考え何をしたのかを書きました。
以下に当てはまる人達に参考になれば嬉しいです。

  • 好きなことへのこだわりが強くいつまでも作品が完成しない
  • いつもダラダラ期限を伸ばしてしまう
  • このゲームがどんなライブラリ使っているか知りたい人
  • 奇特な人

2️⃣ なぜ1週間なの?

個人開発の一番の敵っていかにモチベーションを保つかだと思いませんか?

私の場合1ヶ月もたてば他に興味あることが出てきてしまうのでモチベーションが移ってしまいます。
でも、1週間なら全力で頑張れるちょうどよい長さかな~と考えました。

3⃣ 1週間という短い期間で終わらせるために意識したこと

(1) 期限を守る意識

いつもズルズル伸ばしてしまう癖があるので Twitter で公開日を宣言しました。
そして公開日までのカウントダウンタイマーを常に目に入る位置に設置しておきました。

このカウントダウンタイマー、すっごく効果があったように思います。度々目に入るので程よい緊張感と無機質な圧力をくれます。
さらにシレっと周囲の人たちに公開日を宣言できるのでかなりお勧めです。

⚠ 補足 ⚠
さも予定を守ったように言ってますが結局半日ほど遅く公開してしまいました
ほ ん と す み ま せ ん で し た ?

(2) 適当な企画書 or 仕様書を作って全体を把握する

企画書などが無い状態でコーディングすると目に付いた場所から手を付けてしまいがちで視野が局所的になりやすく作業の優先度決めが難しくなります。

簡単なものでも全体像を把握できるものがあると、次にどの部分を実装するか考えるときに『最低でもここを実装しないとだから優先度高めで』みたいなスケジュール管理が容易になります。

今回は以下の様な簡易仕様書をあらかじめ書いておりました。

これのおかげで実装段階での仕様変更が減り、一直線にゴールに向かって実装できるので結構大切なものだと思います。

(3) 冒険は1個まで

初めて使う道具が多いと勉強することが増えたり些細なことにはまったりして時間を消費しがちなので、使い慣れた開発環境使った事のあるライブラリを中心に選定しました。

使い慣れたもの

  • VS-Code ... 開発環境
  • TypeScript ... 開発言語
  • UIKit ... UI コンポーネント

以前から実験レベルで遊んでいたもの

  • Three.js ... 3D 描画エンジン
  • Cannon.js ... 3D 物理エンジン

初めて使うもの

  • Audio API ... BGM や効果音の再生

今回は遊べる Web ゲームを短期間で作ることが目的だったので冒険をしない制約を設けましたが、完成度が低くても未知なる技術を沢山学びたいならどんどん新しい技術を使ってみたらよいと思います。

目的次第でやり方を臨機応変にすることが大事なのかなと?

(4) こだわりを捨てる覚悟

こだわりが強いと味のあるイイものができるけど、その代わり完成が遅くなる傾向にあると思います。

今回は『ローポリキャラ達が物理演算で予想外の挙動をする』部分だけこだわりましたが、操作 UI の見た目などはブラウザ標準のプログレスバーを使っていたりと大部分はかなり適当です。

個人的にはゲームの中にブラウザの DOM を混ぜることに違和感とアレルギーを感じるのですが『こだわる部分を絞って他は適当』と大胆に割り切っちゃいました。
image.png

他にも、ペンギン/サメ/コイン/木 の当たり判定はすべて立方体(正六面体)で手抜きをしましたが、これは予想に反して『サメさんやコインが地面に刺さってる』ように見えたり『木々があらぶってる』感じになったりヘンテコな世界観の演出に一役買ったように思います。
2020-06-18_00h32_18.png

4⃣ おまけ: 実装 Tips

唐突なおまけの実装 Tips です。

(1) Cannon.js / サメさんの Z 位置(奥行)を固定

Cannon.js は 3 次元物理演算なので XYZ 軸分の動きがあるのですが、サメさん(敵)に関しては奥行方向に動かれると攻撃を当てられなくなるので Z 軸を固定しています。

// CannonJs: サメさんの物理演算用の剛体を生成
const sharkBody = new CANNON.Body({ <省略> });

// ThreeJs: アニメーションループを開始
renderer.setAnimationLoop(() => {
    // CannonJs: 1フレーム分の物理演算を実行
    world.step(<省略>);

    // ★★ サメさんのZ位置を固定する ★★
    sharkBody.position.set(
        sharkBody.position.x,
        sharkBody.position.y,
        0); // Z軸を0に変更
});

(2) Cannon.js / 摩擦設定・反発設定

初期値だとほとんど滑ることは無く、滑らせた方が面白そうだったので摩擦と反発の設定をいじっています。

モデルごとに摩擦係数や反発係数を設定できれば直感的だったのですが、Cannon.js では『モデル1とモデル2に対しての摩擦・反発を設定』といった具合に設定が必要でした。

// 
// 物理演算ワールドを初期化
// 

const world = new CANNON.World();
world.gravity.set(0, -9.82, 0); // m/s²

const floorBodyMaterial = new CANNON.Material(`FloorModel`);
const penguinBodyMaterial = new CANNON.Material(`PenguinModel`);
const sharkBodyMaterial = new CANNON.Material(`SharkModel`);

// 摩擦反発設定: 床とペンギン
world.addContactMaterial(new CANNON.ContactMaterial(
    floorBodyMaterial,
    penguinBodyMaterial,
    {
        friction: 0.01, // 摩擦設定 (ペンギンが床を滑るように)
        restitution: 0.8, // 反発設定
    }
));

// 摩擦反発設定: 床とサメ
world.addContactMaterial(new CANNON.ContactMaterial(
    floorBodyMaterial,
    sharkBodyMaterial,
    {
        friction: 0.05, // 摩擦設定
        restitution: 0.3, // 反発設定
        contactEquationStiffness: 1e8,
        contactEquationRelaxation: 3,
    }
));

// 摩擦反発設定: ペンギンとサメ
world.addContactMaterial(new CANNON.ContactMaterial(
    penguinBodyMaterial,
    sharkBodyMaterial,
    {
        friction: 0.01, // 摩擦設定
        restitution: 2.0, // 反発設定 (攻撃を受けたサメが吹っ飛びやすいように)
    }
));

(3) Three.js / 同じモデルは使いまわして効率化

今回ゲーム内では同じモデルが大量に出現します。その際に毎回モデルをロードしていると実行効率が悪いので、モデル管理クラスが必要になります。

こういった目的の管理クラス系にはシングルトン実装が最適かと思いますが、TypeScript では module を使うと簡単にシングルトン実装が可能です。

/** モデル名の型定義 */
export type ModelName = `PENGUIN` | `SHARK` | `TREE` | `COIN`;

/** 
 * ゲーム内のモデルを管理するモジュール
 *  シングルトン実装
 *  モデルをあらかじめロードして、ロード済みのモデルを使いまわすことで効率化
 */
export module ModelManager {
    /** ロード済みのモデルを保持 */
    const modelMap = new Map<ModelName, THREE.Object3D>();

    /** 各種モデルをロードする */
    export async function load() {
        await loadModel(`PENGUIN`, `models/PenguinJumping.glb`);
        await loadModel(`SHARK`, `models/Shark.glb`);
        await loadModel(`TREE`, `models/Tree.glb`);
        await loadModel(`COIN`, `models/Coin.glb`);
    }

    /** モデルを複製して取得する */
    export function getModel(modelName: ModelName) {
        return modelMap.get(modelName)!.clone();
    }

    /** ロード済みモデルを開放する */
    export function dispose() {
        // ThreeJs: ロードしたモデルをすべて解放
        modelMap.forEach((obj3D, key) => {
            GameUtils.disposeObject3D(obj3D);
        });
    }

    async function loadModel(modelName: ModelName, path: string) {
        // ThreeJs: モデル読み込み
        const obj3D = await GameUtils.loadGltfModel(path);

        // リストに追加
        modelMap.set(modelName, obj3D);
    }
}

使い方

// あらかじめすべてのモデルをロードする
await ModelManager.load();

// ペンギンモデルを取得する (内部的にはロード済みのモデルを複製しているので効率的)
const penguin1 = ModelManager.getModel(`PENGUIN`);
const penguin2 = ModelManager.getModel(`PENGUIN`);
const penguin3 = ModelManager.getModel(`PENGUIN`);

最後に

ゲーム開発中に Twitter でいいねやコメントなどでリアクションをくれた方、開発中のゲームを試してヒントをくれた友人方、本当にありがとうございました。
大変励みになりモチベーションになりました、重ねてお礼申し上げます?

あと、ここまで読んでまだプレイしていない人!!
↓ やってからリアクションをクレクレ厨?

ペンギンがサメさんを倒すゲーム
敵さんを吹っ飛ばして 1,000 G 以上を目指してみてね?
https://games.westa.io/

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

個人開発?1週間で区切ると思いのほか爆速で開発できるぞ と

(内心)
作ったゲームを沢山の人に触ってもらってリアクションが欲しいなぁ~
そうだ、記事投稿して宣伝しよう!

といった下心で書いた記事です。すみませんでした 遊んでください?

ペンギンがサメさんを倒すゲーム
敵さんを吹っ飛ばして 1,000 G 以上を目指してみてね?
https://games.westa.io/

1️⃣ この記事の対象読者

この記事では1週間で Web ゲームを作る為に何を考え何をしたのかを書きました。
以下に当てはまる人達に参考になれば嬉しいです。

  • 好きなことへのこだわりが強くいつまでも作品が完成しない
  • いつもダラダラ期限を伸ばしてしまう
  • このゲームがどんなライブラリ使っているか知りたい人
  • 奇特な人

2️⃣ なぜ1週間なの?

個人開発の一番の敵っていかにモチベーションを保つかだと思いませんか?

私の場合1ヶ月もたてば他に興味あることが出てきてしまうのでモチベーションが移ってしまいます。
でも、1週間なら全力で頑張れるちょうどよい長さかな~と考えました。

(それと、1週間ならクオリティ低くても言い訳になるかな と)

3⃣ 1週間という短い期間で終わらせるために意識したこと

(1) 期限を守る意識

いつもズルズル伸ばしてしまう癖があるので Twitter で公開日を宣言しました。
そして公開日までのカウントダウンタイマーを常に目に入る位置に設置しておきました。

このカウントダウンタイマー、すっごく効果があったように思います。度々目に入るので程よい緊張感と無機質な圧力をくれます。
さらにシレっと周囲の人たちに公開日を宣言できるのでかなりお勧めです。

⚠ 補足 ⚠
さも予定を守ったように言ってますが結局半日ほど遅く公開してしまいました
ほ ん と す み ま せ ん で し た ?

(2) 適当な企画書 or 仕様書を作って全体を把握する

企画書などが無い状態でコーディングすると目に付いた場所から手を付けてしまいがちで視野が局所的になりやすく作業の優先度決めが難しくなります。

簡単なものでも全体像を把握できるものがあると、次にどの部分を実装するか考えるときに『最低でもここを実装しないとだから優先度高めで』みたいなスケジュール管理が容易になります。

今回は以下の様な簡易仕様書をあらかじめ書いておりました。

これのおかげで実装段階での仕様変更が減り、一直線にゴールに向かって実装できるので結構大切なものだと思います。

(3) 冒険は1個まで

初めて使う道具が多いと勉強することが増えたり些細なことにはまったりして時間を消費しがちなので、使い慣れた開発環境使った事のあるライブラリを中心に選定しました。

使い慣れたもの

  • VS-Code ... 開発環境
  • TypeScript ... 開発言語
  • UIKit ... UI コンポーネント

以前から実験レベルで遊んでいたもの

  • Three.js ... 3D 描画エンジン
  • Cannon.js ... 3D 物理エンジン

初めて使うもの

  • Audio API ... BGM や効果音の再生

今回は遊べる Web ゲームを短期間で作ることが目的だったので冒険をしない制約を設けましたが、完成度が低くても未知なる技術を沢山学びたいならどんどん新しい技術を使ってみたらよいと思います。

目的次第でやり方を臨機応変にすることが大事なのかなと?

(4) こだわりを捨てる覚悟

こだわりが強いと味のあるイイものができるけど、その代わり完成が遅くなる傾向にあると思います。

今回は『ローポリキャラ達が物理演算で予想外の挙動をする』部分だけこだわりましたが、操作 UI の見た目などはブラウザ標準のプログレスバーを使っていたりと大部分はかなり適当です。

個人的にはゲームの中にブラウザの DOM を混ぜることに違和感とアレルギーを感じるのですが『こだわる部分を絞って他は適当』と大胆に割り切っちゃいました。
image.png

他にも、ペンギン/サメ/コイン/木 の当たり判定はすべて立方体(正六面体)で手抜きをしましたが、これは予想に反して『サメさんやコインが地面に刺さってる』ように見えたり『木々があらぶってる』感じになったりヘンテコな世界観の演出に一役買ったように思います。
2020-06-18_00h32_18.png

4⃣ おまけ: 実装 Tips

唐突なおまけの実装 Tips です。

(1) Cannon.js / サメさんの Z 位置(奥行)を固定

Cannon.js は 3 次元物理演算なので XYZ 軸分の動きがあるのですが、サメさん(敵)に関しては奥行方向に動かれると攻撃を当てられなくなるので Z 軸を固定しています。

// CannonJs: サメさんの物理演算用の剛体を生成
const sharkBody = new CANNON.Body({ <省略> });

// ThreeJs: アニメーションループを開始
renderer.setAnimationLoop(() => {
    // CannonJs: 1フレーム分の物理演算を実行
    world.step(<省略>);

    // ★★ サメさんのZ位置を固定する ★★
    sharkBody.position.set(
        sharkBody.position.x,
        sharkBody.position.y,
        0); // Z軸を0に変更
});

(2) Cannon.js / 摩擦設定・反発設定

初期値だとほとんど滑ることは無く、滑らせた方が面白そうだったので摩擦と反発の設定をいじっています。

モデルごとに摩擦係数や反発係数を設定できれば直感的だったのですが、Cannon.js では『モデル1とモデル2に対しての摩擦・反発を設定』といった具合に設定が必要でした。

// 
// 物理演算ワールドを初期化
// 

const world = new CANNON.World();
world.gravity.set(0, -9.82, 0); // m/s²

const floorBodyMaterial = new CANNON.Material(`FloorModel`);
const penguinBodyMaterial = new CANNON.Material(`PenguinModel`);
const sharkBodyMaterial = new CANNON.Material(`SharkModel`);

// 摩擦反発設定: 床とペンギン
world.addContactMaterial(new CANNON.ContactMaterial(
    floorBodyMaterial,
    penguinBodyMaterial,
    {
        friction: 0.01, // 摩擦設定 (ペンギンが床を滑るように)
        restitution: 0.8, // 反発設定
    }
));

// 摩擦反発設定: 床とサメ
world.addContactMaterial(new CANNON.ContactMaterial(
    floorBodyMaterial,
    sharkBodyMaterial,
    {
        friction: 0.05, // 摩擦設定
        restitution: 0.3, // 反発設定
        contactEquationStiffness: 1e8,
        contactEquationRelaxation: 3,
    }
));

// 摩擦反発設定: ペンギンとサメ
world.addContactMaterial(new CANNON.ContactMaterial(
    penguinBodyMaterial,
    sharkBodyMaterial,
    {
        friction: 0.01, // 摩擦設定
        restitution: 2.0, // 反発設定 (攻撃を受けたサメが吹っ飛びやすいように)
    }
));

(3) Three.js / 同じモデルは使いまわして効率化

今回ゲーム内では同じモデルが大量に出現します。その際に毎回モデルをロードしていると実行効率が悪いので、モデル管理クラスが必要になります。

こういった目的の管理クラス系にはシングルトン実装が最適かと思いますが、TypeScript では module を使うと簡単にシングルトン実装が可能です。

/** モデル名の型定義 */
export type ModelName = `PENGUIN` | `SHARK` | `TREE` | `COIN`;

/** 
 * ゲーム内のモデルを管理するモジュール
 *  シングルトン実装
 *  モデルをあらかじめロードして、ロード済みのモデルを使いまわすことで効率化
 */
export module ModelManager {
    /** ロード済みのモデルを保持 */
    const modelMap = new Map<ModelName, THREE.Object3D>();

    /** 各種モデルをロードする */
    export async function load() {
        await loadModel(`PENGUIN`, `models/PenguinJumping.glb`);
        await loadModel(`SHARK`, `models/Shark.glb`);
        await loadModel(`TREE`, `models/Tree.glb`);
        await loadModel(`COIN`, `models/Coin.glb`);
    }

    /** モデルを複製して取得する */
    export function getModel(modelName: ModelName) {
        return modelMap.get(modelName)!.clone();
    }

    /** ロード済みモデルを開放する */
    export function dispose() {
        // ThreeJs: ロードしたモデルをすべて解放
        modelMap.forEach((obj3D, key) => {
            GameUtils.disposeObject3D(obj3D);
        });
    }

    async function loadModel(modelName: ModelName, path: string) {
        // ThreeJs: モデル読み込み
        const obj3D = await GameUtils.loadGltfModel(path);

        // リストに追加
        modelMap.set(modelName, obj3D);
    }
}

使い方

// あらかじめすべてのモデルをロードする
await ModelManager.load();

// ペンギンモデルを取得する (内部的にはロード済みのモデルを複製しているので効率的)
const penguin1 = ModelManager.getModel(`PENGUIN`);
const penguin2 = ModelManager.getModel(`PENGUIN`);
const penguin3 = ModelManager.getModel(`PENGUIN`);

最後に

ゲーム開発中に Twitter でいいねやコメントなどでリアクションをくれた方、開発中のゲームを試してヒントをくれた友人方、本当にありがとうございました。
大変励みになりモチベーションになりました、重ねてお礼申し上げます?

あと、ここまで読んでまだプレイしていない人!!
↓ やってからリアクションをクレクレ厨?

ペンギンがサメさんを倒すゲーム
敵さんを吹っ飛ばして 1,000 G 以上を目指してみてね?
https://games.westa.io/

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

【1週間】Web ゲームを爆速で開発するために実践したこと?個人開発

(内心)
作ったゲームを沢山の人に触ってもらってリアクションが欲しいなぁ~
そうだ、記事投稿して宣伝しよう!

といった下心で書いた記事です。すみませんでした 遊んでください?

ペンギンがサメさんを倒すゲーム
敵さんを吹っ飛ばして 1,000 G 以上を目指してみてね?
https://games.westa.io/

0⃣ 結論 / 爆速で開発するために実践したこと

  • 1週間のカウントダウンタイマーを設置する
  • 適当な企画書を作って全体を把握しならが作業する
  • 新しいことは1個までに制限して知っているものを使う
  • こだわりたい部分を絞り他を捨てる覚悟を持つ

1️⃣ この記事の対象読者

この記事では1週間で Web ゲームを作る為に何を考え何をしたのかを書きました。
以下に当てはまる人達に参考になれば嬉しいです。

  • 好きなことへのこだわりが強くいつまでも作品が完成しない
  • いつもダラダラ期限を伸ばしてしまう
  • このゲームがどんなライブラリ使っているか知りたい人
  • 奇特な人

2️⃣ なぜ1週間なの?

個人開発の一番の敵っていかにモチベーションを保つかだと思いませんか?

私の場合1ヶ月もたてば他に興味あることが出てきてしまうのでモチベーションが移ってしまいます。
でも、1週間なら全力で頑張れるちょうどよい長さかな~と考えました。

(それと、1週間ならクオリティ低くても言い訳になるかな と)

3⃣ 1週間という短い期間で終わらせるために意識したこと

(1) 期限を守る意識

いつもズルズル伸ばしてしまう癖があるので Twitter で公開日を宣言しました。
そして公開日までのカウントダウンタイマーを常に目に入る位置に設置しておきました。

このカウントダウンタイマー、すっごく効果があったように思います。度々目に入るので程よい緊張感と無機質な圧力をくれます。
さらにシレっと周囲の人たちに公開日を宣言できるのでかなりお勧めです。

⚠ 補足 ⚠
さも予定を守ったように言ってますが結局半日ほど遅く公開してしまいました
ほ ん と す み ま せ ん で し た ?

(2) 適当な企画書 or 仕様書を作って全体を把握する

企画書などが無い状態でコーディングすると目に付いた場所から手を付けてしまいがちで視野が局所的になりやすく作業の優先度決めが難しくなります。

簡単なものでも全体像を把握できるものがあると、次にどの部分を実装するか考えるときに『最低でもここを実装しないとだから優先度高めで』みたいなスケジュール管理が容易になります。

今回は以下の様な簡易仕様書をあらかじめ書いておりました。

これのおかげで実装段階での仕様変更が減り、一直線にゴールに向かって実装できるので結構大切なものだと思います。

(3) 冒険は1個まで

初めて使う道具が多いと勉強することが増えたり些細なことにはまったりして時間を消費しがちなので、使い慣れた開発環境使った事のあるライブラリを中心に選定しました。

使い慣れたもの

  • VS-Code ... 開発環境
  • TypeScript ... 開発言語
  • UIKit ... UI コンポーネント

以前から実験レベルで遊んでいたもの

  • Three.js ... 3D 描画エンジン
  • Cannon.js ... 3D 物理エンジン

初めて使うもの

  • Audio API ... BGM や効果音の再生

今回は遊べる Web ゲームを短期間で作ることが目的だったので冒険をしない制約を設けましたが、完成度が低くても未知なる技術を沢山学びたいならどんどん新しい技術を使ってみたらよいと思います。

目的次第でやり方を臨機応変にすることが大事なのかなと?

(4) こだわりを捨てる覚悟

こだわりが強いと味のあるイイものができるけど、その代わり完成が遅くなる傾向にあると思います。

今回は『ローポリキャラ達が物理演算で予想外の挙動をする』部分だけこだわりましたが、操作 UI の見た目などはブラウザ標準のプログレスバーを使っていたりと大部分はかなり適当です。

個人的にはゲームの中にブラウザの DOM を混ぜることに違和感とアレルギーを感じるのですが『こだわる部分を絞って他は適当』と大胆に割り切っちゃいました。
image.png

他にも、ペンギン/サメ/コイン/木 の当たり判定はすべて立方体(正六面体)で手抜きをしましたが、これは予想に反して『サメさんやコインが地面に刺さってる』ように見えたり『木々があらぶってる』感じになったりヘンテコな世界観の演出に一役買ったように思います。
2020-06-18_00h32_18.png

4⃣ おまけ: 実装 Tips

唐突なおまけの実装 Tips です。

(1) Cannon.js / サメさんの Z 位置(奥行)を固定

Cannon.js は 3 次元物理演算なので XYZ 軸分の動きがあるのですが、サメさん(敵)に関しては奥行方向に動かれると攻撃を当てられなくなるので Z 軸を固定しています。

// CannonJs: サメさんの物理演算用の剛体を生成
const sharkBody = new CANNON.Body({ <省略> });

// ThreeJs: アニメーションループを開始
renderer.setAnimationLoop(() => {
    // CannonJs: 1フレーム分の物理演算を実行
    world.step(<省略>);

    // ★★ サメさんのZ位置を固定する ★★
    sharkBody.position.set(
        sharkBody.position.x,
        sharkBody.position.y,
        0); // Z軸を0に変更
});

(2) Cannon.js / 摩擦設定・反発設定

初期値だとほとんど滑ることは無く、滑らせた方が面白そうだったので摩擦と反発の設定をいじっています。

モデルごとに摩擦係数や反発係数を設定できれば直感的だったのですが、Cannon.js では『モデル1とモデル2に対しての摩擦・反発を設定』といった具合に設定が必要でした。

// 
// 物理演算ワールドを初期化
// 

const world = new CANNON.World();
world.gravity.set(0, -9.82, 0); // m/s²

const floorBodyMaterial = new CANNON.Material(`FloorModel`);
const penguinBodyMaterial = new CANNON.Material(`PenguinModel`);
const sharkBodyMaterial = new CANNON.Material(`SharkModel`);

// 摩擦反発設定: 床とペンギン
world.addContactMaterial(new CANNON.ContactMaterial(
    floorBodyMaterial,
    penguinBodyMaterial,
    {
        friction: 0.01, // 摩擦設定 (ペンギンが床を滑るように)
        restitution: 0.8, // 反発設定
    }
));

// 摩擦反発設定: 床とサメ
world.addContactMaterial(new CANNON.ContactMaterial(
    floorBodyMaterial,
    sharkBodyMaterial,
    {
        friction: 0.05, // 摩擦設定
        restitution: 0.3, // 反発設定
        contactEquationStiffness: 1e8,
        contactEquationRelaxation: 3,
    }
));

// 摩擦反発設定: ペンギンとサメ
world.addContactMaterial(new CANNON.ContactMaterial(
    penguinBodyMaterial,
    sharkBodyMaterial,
    {
        friction: 0.01, // 摩擦設定
        restitution: 2.0, // 反発設定 (攻撃を受けたサメが吹っ飛びやすいように)
    }
));

(3) Three.js / 同じモデルは使いまわして効率化

今回ゲーム内では同じモデルが大量に出現します。その際に毎回モデルをロードしていると実行効率が悪いので、モデル管理クラスが必要になります。

こういった目的の管理クラス系にはシングルトン実装が最適かと思いますが、TypeScript では module を使うと簡単にシングルトン実装が可能です。

/** モデル名の型定義 */
export type ModelName = `PENGUIN` | `SHARK` | `TREE` | `COIN`;

/** 
 * ゲーム内のモデルを管理するモジュール
 *  シングルトン実装
 *  モデルをあらかじめロードして、ロード済みのモデルを使いまわすことで効率化
 */
export module ModelManager {
    /** ロード済みのモデルを保持 */
    const modelMap = new Map<ModelName, THREE.Object3D>();

    /** 各種モデルをロードする */
    export async function load() {
        await loadModel(`PENGUIN`, `models/PenguinJumping.glb`);
        await loadModel(`SHARK`, `models/Shark.glb`);
        await loadModel(`TREE`, `models/Tree.glb`);
        await loadModel(`COIN`, `models/Coin.glb`);
    }

    /** モデルを複製して取得する */
    export function getModel(modelName: ModelName) {
        return modelMap.get(modelName)!.clone();
    }

    /** ロード済みモデルを開放する */
    export function dispose() {
        // ThreeJs: ロードしたモデルをすべて解放
        modelMap.forEach((obj3D, key) => {
            GameUtils.disposeObject3D(obj3D);
        });
    }

    async function loadModel(modelName: ModelName, path: string) {
        // ThreeJs: モデル読み込み
        const obj3D = await GameUtils.loadGltfModel(path);

        // リストに追加
        modelMap.set(modelName, obj3D);
    }
}

使い方

// あらかじめすべてのモデルをロードする
await ModelManager.load();

// ペンギンモデルを取得する (内部的にはロード済みのモデルを複製しているので効率的)
const penguin1 = ModelManager.getModel(`PENGUIN`);
const penguin2 = ModelManager.getModel(`PENGUIN`);
const penguin3 = ModelManager.getModel(`PENGUIN`);

最後に

ゲーム開発中に Twitter でいいねやコメントなどでリアクションをくれた方、開発中のゲームを試してヒントをくれた友人方、本当にありがとうございました。
大変励みになりモチベーションになりました、重ねてお礼申し上げます?

あと、ここまで読んでまだプレイしていない人!!
↓ やってからリアクションをクレクレ厨?

ペンギンがサメさんを倒すゲーム
敵さんを吹っ飛ばして 1,000 G 以上を目指してみてね?
https://games.westa.io/

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

【1週間】Web ゲームを爆速で開発するために実践したこと?個人開発

(内心)
作ったゲームを沢山の人に触ってもらってリアクションが欲しいなぁ~
そうだ、記事投稿して宣伝しよう!

といった下心で書いた記事です。すみませんでした 遊んでください?

ペンギンがサメさんを倒すゲーム
敵さんを吹っ飛ばして 1,000 G 以上を目指してみてね?
https://games.westa.io/

0⃣ 結論 / 爆速で開発するために実践したこと

  • 1週間のカウントダウンタイマーを設置する
  • 適当な企画書を作って全体を把握しならが作業する
  • 新しいことは1個までに制限して知っているものを使う
  • こだわりたい部分を絞り他を捨てる覚悟を持つ

1️⃣ この記事の対象読者

この記事では1週間で Web ゲームを作る為に何を考え何をしたのかを書きました。
以下に当てはまる人達に参考になれば嬉しいです。

  • 好きなことへのこだわりが強くいつまでも作品が完成しない
  • いつもダラダラ期限を伸ばしてしまう
  • このゲームがどんなライブラリ使っているか知りたい人
  • 奇特な人

2️⃣ なぜ1週間なの?

個人開発の一番の敵っていかにモチベーションを保つかだと思いませんか?

私の場合1ヶ月もたてば他に興味あることが出てきてしまうのでモチベーションが移ってしまいます。
でも、1週間なら全力で頑張れるちょうどよい長さかな~と考えました。

(それと、1週間ならクオリティ低くても言い訳になるかな と)

3⃣ 1週間という短い期間で終わらせるために意識したこと

(1) 1週間のカウントダウンタイマーを設置する

いつもズルズル伸ばしてしまう癖があるので Twitter で公開日を宣言しました。
そして公開日までのカウントダウンタイマーを常に目に入る位置に設置しておきました。

このカウントダウンタイマー、すっごく効果があったように思います。度々目に入るので程よい緊張感と無機質な圧力をくれます。
さらにシレっと周囲の人たちに公開日を宣言できるのでかなりお勧めです。

⚠ 補足 ⚠
さも予定を守ったように言ってますが結局半日ほど遅く公開してしまいました
ほ ん と す み ま せ ん で し た ?

(2) 適当な企画書を作って全体を把握しならが作業する

企画書などが無い状態でコーディングすると目に付いた場所から手を付けてしまいがちで視野が局所的になりやすく作業の優先度決めが難しくなります。

簡単なものでも全体像を把握できるものがあると、次にどの部分を実装するか考えるときに『最低でもここを実装しないとだから優先度高めで』みたいなスケジュール管理が容易になります。

今回は以下の様な簡易仕様書をあらかじめ書いておりました。

これのおかげで実装段階での仕様変更が減り、一直線にゴールに向かって実装できるので結構大切なものだと思います。

(3) 新しいことは1個までに制限して知っているものを使う

初めて使う道具が多いと勉強することが増えたり些細なことにはまったりして時間を消費しがちなので、使い慣れた開発環境使った事のあるライブラリを中心に選定しました。

使い慣れたもの

  • VS-Code ... 開発環境
  • TypeScript ... 開発言語
  • UIKit ... UI コンポーネント

以前から実験レベルで遊んでいたもの

  • Three.js ... 3D 描画エンジン
  • Cannon.js ... 3D 物理エンジン

初めて使うもの

  • Audio API ... BGM や効果音の再生

今回は遊べる Web ゲームを短期間で作ることが目的だったので冒険をしない制約を設けましたが、完成度が低くても未知なる技術を沢山学びたいならどんどん新しい技術を使ってみたらよいと思います。

目的次第でやり方を臨機応変にすることが大事なのかなと?

(4) こだわりたい部分を絞り他を捨てる覚悟を持つ

こだわりが強いと味のあるイイものができるけど、その代わり完成が遅くなる傾向にあると思います。

今回は『ローポリキャラ達が物理演算で予想外の挙動をする』部分だけこだわりましたが、操作 UI の見た目などはブラウザ標準のプログレスバーを使っていたりと大部分はかなり適当です。

個人的にはゲームの中にブラウザの DOM を混ぜることに違和感とアレルギーを感じるのですが『こだわる部分を絞って他は適当』と大胆に割り切っちゃいました。
image.png

他にも、ペンギン/サメ/コイン/木 の当たり判定はすべて立方体(正六面体)で手抜きをしましたが、これは予想に反して『サメさんやコインが地面に刺さってる』ように見えたり『木々があらぶってる』感じになったりヘンテコな世界観の演出に一役買ったように思います。
2020-06-18_00h32_18.png

4⃣ おまけ: 実装 Tips

唐突なおまけの実装 Tips です。

(1) Cannon.js / サメさんの Z 位置(奥行)を固定

Cannon.js は 3 次元物理演算なので XYZ 軸分の動きがあるのですが、サメさん(敵)に関しては奥行方向に動かれると攻撃を当てられなくなるので Z 軸を固定しています。

// CannonJs: サメさんの物理演算用の剛体を生成
const sharkBody = new CANNON.Body({ <省略> });

// ThreeJs: アニメーションループを開始
renderer.setAnimationLoop(() => {
    // CannonJs: 1フレーム分の物理演算を実行
    world.step(<省略>);

    // ★★ サメさんのZ位置を固定する ★★
    sharkBody.position.set(
        sharkBody.position.x,
        sharkBody.position.y,
        0); // Z軸を0に変更
});

(2) Cannon.js / 摩擦設定・反発設定

初期値だとほとんど滑ることは無く、滑らせた方が面白そうだったので摩擦と反発の設定をいじっています。

モデルごとに摩擦係数や反発係数を設定できれば直感的だったのですが、Cannon.js では『モデル1とモデル2に対しての摩擦・反発を設定』といった具合に設定が必要でした。

// 
// 物理演算ワールドを初期化
// 

const world = new CANNON.World();
world.gravity.set(0, -9.82, 0); // m/s²

const floorBodyMaterial = new CANNON.Material(`FloorModel`);
const penguinBodyMaterial = new CANNON.Material(`PenguinModel`);
const sharkBodyMaterial = new CANNON.Material(`SharkModel`);

// 摩擦反発設定: 床とペンギン
world.addContactMaterial(new CANNON.ContactMaterial(
    floorBodyMaterial,
    penguinBodyMaterial,
    {
        friction: 0.01, // 摩擦設定 (ペンギンが床を滑るように)
        restitution: 0.8, // 反発設定
    }
));

// 摩擦反発設定: 床とサメ
world.addContactMaterial(new CANNON.ContactMaterial(
    floorBodyMaterial,
    sharkBodyMaterial,
    {
        friction: 0.05, // 摩擦設定
        restitution: 0.3, // 反発設定
        contactEquationStiffness: 1e8,
        contactEquationRelaxation: 3,
    }
));

// 摩擦反発設定: ペンギンとサメ
world.addContactMaterial(new CANNON.ContactMaterial(
    penguinBodyMaterial,
    sharkBodyMaterial,
    {
        friction: 0.01, // 摩擦設定
        restitution: 2.0, // 反発設定 (攻撃を受けたサメが吹っ飛びやすいように)
    }
));

(3) Three.js / 同じモデルは使いまわして効率化

今回ゲーム内では同じモデルが大量に出現します。その際に毎回モデルをロードしていると実行効率が悪いので、モデル管理クラスが必要になります。

こういった目的の管理クラス系にはシングルトン実装が最適かと思いますが、TypeScript では module を使うと簡単にシングルトン実装が可能です。

/** モデル名の型定義 */
export type ModelName = `PENGUIN` | `SHARK` | `TREE` | `COIN`;

/** 
 * ゲーム内のモデルを管理するモジュール
 *  シングルトン実装
 *  モデルをあらかじめロードして、ロード済みのモデルを使いまわすことで効率化
 */
export module ModelManager {
    /** ロード済みのモデルを保持 */
    const modelMap = new Map<ModelName, THREE.Object3D>();

    /** 各種モデルをロードする */
    export async function load() {
        await loadModel(`PENGUIN`, `models/PenguinJumping.glb`);
        await loadModel(`SHARK`, `models/Shark.glb`);
        await loadModel(`TREE`, `models/Tree.glb`);
        await loadModel(`COIN`, `models/Coin.glb`);
    }

    /** モデルを複製して取得する */
    export function getModel(modelName: ModelName) {
        return modelMap.get(modelName)!.clone();
    }

    /** ロード済みモデルを開放する */
    export function dispose() {
        // ThreeJs: ロードしたモデルをすべて解放
        modelMap.forEach((obj3D, key) => {
            GameUtils.disposeObject3D(obj3D);
        });
    }

    async function loadModel(modelName: ModelName, path: string) {
        // ThreeJs: モデル読み込み
        const obj3D = await GameUtils.loadGltfModel(path);

        // リストに追加
        modelMap.set(modelName, obj3D);
    }
}

使い方

// あらかじめすべてのモデルをロードする
await ModelManager.load();

// ペンギンモデルを取得する (内部的にはロード済みのモデルを複製しているので効率的)
const penguin1 = ModelManager.getModel(`PENGUIN`);
const penguin2 = ModelManager.getModel(`PENGUIN`);
const penguin3 = ModelManager.getModel(`PENGUIN`);

最後に

ゲーム開発中に Twitter でいいねやコメントなどでリアクションをくれた方、開発中のゲームを試してヒントをくれた友人方、本当にありがとうございました。
大変励みになりモチベーションになりました、重ねてお礼申し上げます?

あと、ここまで読んでまだプレイしていない人!!
↓ やってからリアクションをクレクレ厨?

ペンギンがサメさんを倒すゲーム
敵さんを吹っ飛ばして 1,000 G 以上を目指してみてね?
https://games.westa.io/

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

【1週間】Web ゲームを爆速で開発するために実践したこと?個人開発向け

(内心)
作ったゲームを沢山の人に触ってもらってリアクションが欲しいなぁ~
そうだ、記事投稿して宣伝しよう!

といった下心で書いた記事です。すみませんでした 遊んでください?

ペンギンがサメさんを倒すゲーム
敵さんを吹っ飛ばして 1,000 G 以上を目指してみてね?
https://games.westa.io/

0⃣ 結論 / 爆速で開発するために実践したこと

  • 1週間のカウントダウンタイマーを設置する
  • 適当な企画書を作って全体を把握しならが作業する
  • 新しいことは1個までに制限して知っているものを使う
  • こだわりたい部分を絞り他を捨てる覚悟を持つ

1️⃣ この記事の対象読者

この記事では1週間で Web ゲームを作る為に何を考え何をしたのかを書きました。
以下に当てはまる人達に参考になれば嬉しいです。

  • 好きなことへのこだわりが強くいつまでも作品が完成しない
  • いつもダラダラ期限を伸ばしてしまう
  • このゲームがどんなライブラリ使っているか知りたい人
  • 奇特な人

2️⃣ なぜ1週間なの?

個人開発の一番の敵っていかにモチベーションを保つかだと思いませんか?

私の場合1ヶ月もたてば他に興味あることが出てきてしまうのでモチベーションが移ってしまいます。
でも、1週間なら全力で頑張れるちょうどよい長さかな~と考えました。

(それと、1週間ならクオリティ低くても言い訳になるかな と)

3⃣ 1週間という短い期間で終わらせるために意識したこと

(1) 1週間のカウントダウンタイマーを設置する

いつもズルズル伸ばしてしまう癖があるので Twitter で公開日を宣言しました。
そして公開日までのカウントダウンタイマーを常に目に入る位置に設置しておきました。

このカウントダウンタイマー、すっごく効果があったように思います。度々目に入るので程よい緊張感と無機質な圧力をくれます。
さらにシレっと周囲の人たちに公開日を宣言できるのでかなりお勧めです。

⚠ 補足 ⚠
さも予定を守ったように言ってますが結局半日ほど遅く公開してしまいました
ほ ん と す み ま せ ん で し た ?

(2) 適当な企画書を作って全体を把握しならが作業する

企画書などが無い状態でコーディングすると目に付いた場所から手を付けてしまいがちで視野が局所的になりやすく作業の優先度決めが難しくなります。

簡単なものでも全体像を把握できるものがあると、次にどの部分を実装するか考えるときに『最低でもここを実装しないとだから優先度高めで』みたいなスケジュール管理が容易になります。

今回は以下の様な簡易仕様書をあらかじめ書いておりました。

これのおかげで実装段階での仕様変更が減り、一直線にゴールに向かって実装できるので結構大切なものだと思います。

(3) 新しいことは1個までに制限して知っているものを使う

初めて使う道具が多いと勉強することが増えたり些細なことにはまったりして時間を消費しがちなので、使い慣れた開発環境使った事のあるライブラリを中心に選定しました。

使い慣れたもの

  • VS-Code ... 開発環境
  • TypeScript ... 開発言語
  • UIKit ... UI コンポーネント

以前から実験レベルで遊んでいたもの

  • Three.js ... 3D 描画エンジン
  • Cannon.js ... 3D 物理エンジン

初めて使うもの

  • Audio API ... BGM や効果音の再生

今回は遊べる Web ゲームを短期間で作ることが目的だったので冒険をしない制約を設けましたが、完成度が低くても未知なる技術を沢山学びたいならどんどん新しい技術を使ってみたらよいと思います。

目的次第でやり方を臨機応変にすることが大事なのかなと?

(4) こだわりたい部分を絞り他を捨てる覚悟を持つ

こだわりが強いと味のあるイイものができるけど、その代わり完成が遅くなる傾向にあると思います。

今回は『ローポリキャラ達が物理演算で予想外の挙動をする』部分だけこだわりましたが、操作 UI の見た目などはブラウザ標準のプログレスバーを使っていたりと大部分はかなり適当です。

個人的にはゲームの中にブラウザの DOM を混ぜることに違和感とアレルギーを感じるのですが『こだわる部分を絞って他は適当』と大胆に割り切っちゃいました。
image.png

他にも、ペンギン/サメ/コイン/木 の当たり判定はすべて立方体(正六面体)で手抜きをしましたが、これは予想に反して『サメさんやコインが地面に刺さってる』ように見えたり『木々があらぶってる』感じになったりヘンテコな世界観の演出に一役買ったように思います。
2020-06-18_00h32_18.png

4⃣ おまけ: 実装 Tips

唐突なおまけの実装 Tips です。

(1) Cannon.js / サメさんの Z 位置(奥行)を固定

Cannon.js は 3 次元物理演算なので XYZ 軸分の動きがあるのですが、サメさん(敵)に関しては奥行方向に動かれると攻撃を当てられなくなるので Z 軸を固定しています。

// CannonJs: サメさんの物理演算用の剛体を生成
const sharkBody = new CANNON.Body({ <省略> });

// ThreeJs: アニメーションループを開始
renderer.setAnimationLoop(() => {
    // CannonJs: 1フレーム分の物理演算を実行
    world.step(<省略>);

    // ★★ サメさんのZ位置を固定する ★★
    sharkBody.position.set(
        sharkBody.position.x,
        sharkBody.position.y,
        0); // Z軸を0に変更
});

(2) Cannon.js / 摩擦設定・反発設定

初期値だとほとんど滑ることは無く、滑らせた方が面白そうだったので摩擦と反発の設定をいじっています。

モデルごとに摩擦係数や反発係数を設定できれば直感的だったのですが、Cannon.js では『モデル1とモデル2に対しての摩擦・反発を設定』といった具合に設定が必要でした。

// 
// 物理演算ワールドを初期化
// 

const world = new CANNON.World();
world.gravity.set(0, -9.82, 0); // m/s²

const floorBodyMaterial = new CANNON.Material(`FloorModel`);
const penguinBodyMaterial = new CANNON.Material(`PenguinModel`);
const sharkBodyMaterial = new CANNON.Material(`SharkModel`);

// 摩擦反発設定: 床とペンギン
world.addContactMaterial(new CANNON.ContactMaterial(
    floorBodyMaterial,
    penguinBodyMaterial,
    {
        friction: 0.01, // 摩擦設定 (ペンギンが床を滑るように)
        restitution: 0.8, // 反発設定
    }
));

// 摩擦反発設定: 床とサメ
world.addContactMaterial(new CANNON.ContactMaterial(
    floorBodyMaterial,
    sharkBodyMaterial,
    {
        friction: 0.05, // 摩擦設定
        restitution: 0.3, // 反発設定
        contactEquationStiffness: 1e8,
        contactEquationRelaxation: 3,
    }
));

// 摩擦反発設定: ペンギンとサメ
world.addContactMaterial(new CANNON.ContactMaterial(
    penguinBodyMaterial,
    sharkBodyMaterial,
    {
        friction: 0.01, // 摩擦設定
        restitution: 2.0, // 反発設定 (攻撃を受けたサメが吹っ飛びやすいように)
    }
));

(3) Three.js / 同じモデルは使いまわして効率化

今回ゲーム内では同じモデルが大量に出現します。その際に毎回モデルをロードしていると実行効率が悪いので、モデル管理クラスが必要になります。

こういった目的の管理クラス系にはシングルトン実装が最適かと思いますが、TypeScript では module を使うと簡単にシングルトン実装が可能です。

/** モデル名の型定義 */
export type ModelName = `PENGUIN` | `SHARK` | `TREE` | `COIN`;

/** 
 * ゲーム内のモデルを管理するモジュール
 *  シングルトン実装
 *  モデルをあらかじめロードして、ロード済みのモデルを使いまわすことで効率化
 */
export module ModelManager {
    /** ロード済みのモデルを保持 */
    const modelMap = new Map<ModelName, THREE.Object3D>();

    /** 各種モデルをロードする */
    export async function load() {
        await loadModel(`PENGUIN`, `models/PenguinJumping.glb`);
        await loadModel(`SHARK`, `models/Shark.glb`);
        await loadModel(`TREE`, `models/Tree.glb`);
        await loadModel(`COIN`, `models/Coin.glb`);
    }

    /** モデルを複製して取得する */
    export function getModel(modelName: ModelName) {
        return modelMap.get(modelName)!.clone();
    }

    /** ロード済みモデルを開放する */
    export function dispose() {
        // ThreeJs: ロードしたモデルをすべて解放
        modelMap.forEach((obj3D, key) => {
            GameUtils.disposeObject3D(obj3D);
        });
    }

    async function loadModel(modelName: ModelName, path: string) {
        // ThreeJs: モデル読み込み
        const obj3D = await GameUtils.loadGltfModel(path);

        // リストに追加
        modelMap.set(modelName, obj3D);
    }
}

使い方

// あらかじめすべてのモデルをロードする
await ModelManager.load();

// ペンギンモデルを取得する (内部的にはロード済みのモデルを複製しているので効率的)
const penguin1 = ModelManager.getModel(`PENGUIN`);
const penguin2 = ModelManager.getModel(`PENGUIN`);
const penguin3 = ModelManager.getModel(`PENGUIN`);

最後に

ゲーム開発中に Twitter でいいねやコメントなどでリアクションをくれた方、開発中のゲームを試してヒントをくれた友人方、本当にありがとうございました。
大変励みになりモチベーションになりました、重ねてお礼申し上げます?

あと、ここまで読んでまだプレイしていない人!!
↓ やってからリアクションをクレクレ厨?

ペンギンがサメさんを倒すゲーム
敵さんを吹っ飛ばして 1,000 G 以上を目指してみてね?
https://games.westa.io/

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