20201206のUnityに関する記事は11件です。

僕の考えた最強のリアルタイム通信基盤(実践編)〜みんなでライブの場合〜

Happy Elements Advent Calendar 2020 25日目の記事です。

概要

24日目の記事でRedis Streamsを使ったリアルタイム通信基盤を使うに至った経緯を書きました。
今回はハンズオン形式で実際にRedis StreamsをPub/Subとして使う部分を書いていこうと思います。
この記事を元にRedis Streamsを使ったリアルタイム通信基盤を多くの企業で作ってもらえたらいいなと思っています。

環境

masOS BigSur 11.0.1
nodebrew 1.0.1
npm 6.14.9
node 14.0.0
redis-server 6.0.9

WebSocket使えるようにする

まずはnodeの初期化とwsのインストールを行います。

npm init --yes
npm install ws

WebSocket通信ができるようにコードを書きます。

server.js
const WebSocket = require('ws');

const wss = new WebSocket.Server({ port: 8080 });

wss.on('connection', function connection(ws) {
  ws.on('message', function incoming(message) {
    console.log('received: %s', message);
  });

  ws.send('something');
});

サーバーを起動させます。

node server.js

WebSocketがちゃんと動いているのを確認します。

wscat -c ws://localhost:8080

Redisに接続する

redisを起動します。

redis-server

ioredisをインストールします。

npm install ioredis

ioredisを使えるようにします。

server.js
const WebSocket = require('ws');
const Redis = require("ioredis");
const redis = new Redis();

const wss = new WebSocket.Server({ port: 8080 });

wss.on('connection', function connection(ws) {
  ws.on('message', function incoming(message) {
    console.log('received: %s', message);
  });

  ws.send('something');
});

Redis Streamsを使ってSubscribeする

ioredisのissueを参考にRedis Streamsを使ってSubscribeします。

server.js
const WebSocket = require('ws');
const Redis = require("ioredis");
const redis = new Redis();

const wss = new WebSocket.Server({ port: 8080 });

async function subscribeStream(stream, listener) {
  let lastID = '$'

  while (true) {
    // Implement your own `try/catch` logic,
    // (For example, logging the errors and continue to the next loop)
    const reply = await redis.xread('BLOCK', '5000', 'COUNT', 100, 'STREAMS', stream, lastID)
    if (!reply) {
      continue
    }
    const results = reply[0][1]
    const {length} = results
    if (!results.length) {
      continue
    }
    listener(results)
    lastID = results[length - 1][0]
  }
}

subscribeStream('mystream', console.log)

wss.on('connection', function connection(ws) {
  ws.on('message', function incoming(message) {
    console.log('received: %s', message);
  });

  ws.send('something');
});

サーバーを起動します。

node server.js

Redisに接続し、XADDすることで動作確認します。

redis-cli
127.0.0.1:6379> XADD mystream * aaa 1234

Redis Streamsを使ってPublishする

Publish用のRedisを作り、メッセージをPublishします。

server.js
const WebSocket = require('ws');
const Redis = require('ioredis');
const subscriber = new Redis();
const publisher = new Redis();

const wss = new WebSocket.Server({ port: 8080 });

async function subscribeStream(stream, listener) {
  let lastID = '$'

  while (true) {
    // Implement your own `try/catch` logic,
    // (For example, logging the errors and continue to the next loop)
    const reply = await subscriber.xread('BLOCK', '5000', 'COUNT', 100, 'STREAMS', stream, lastID);
    if (!reply) {
      continue;
    }
    const results = reply[0][1];
    const { length } = results;
    if (!results.length) {
      continue;
    }
    listener(results);
    lastID = results[length - 1][0];
  }
}

async function publishStream(stream, message) {
  await publisher.xadd(stream, '*', 'message', message);
}

subscribeStream('mystream', console.log)

wss.on('connection', function connection(ws) {
  ws.on('message', async function incoming(message) {
    await publishStream('mystream', message);
    console.log('publish: ' + message);
  });
});

Redis Streamsを介して受け取ったメッセージをサーバー内の全員に送信する

Subscribeしてメッセージを受け取った時に、サーバー内の全員に送信します。

server.js
const WebSocket = require('ws');
const Redis = require('ioredis');
const subscriber = new Redis();
const publisher = new Redis();

const wss = new WebSocket.Server({ port: 8080 });

async function subscribeStream(stream, listener) {
  let lastID = '$'

  while (true) {
    // Implement your own `try/catch` logic,
    // (For example, logging the errors and continue to the next loop)
    const reply = await subscriber.xread('BLOCK', '5000', 'COUNT', 100, 'STREAMS', stream, lastID);
    if (!reply) {
      continue;
    }
    const results = reply[0][1];
    const { length } = results;
    if (!results.length) {
      continue;
    }
    listener(results);
    lastID = results[length - 1][0];
  }
}

async function publishStream(stream, message) {
  await publisher.xadd(stream, '*', 'message', message);
}

subscribeStream('mystream', function broadcast(results) {
  results.forEach(result => {
    wss.clients.forEach(function each(client) {
      if (client.readyState === WebSocket.OPEN) {
        client.send(result[1][1]);
      }
    });
  });
});

wss.on('connection', function connection(ws) {
  ws.on('message', async function incoming(message) {
    await publishStream('mystream', message);
  });
});

wscatなどを用いてメッセージが複数コネクションでやりとりできることを確認します。

wscat -c ws://localhost:8080
Connected (press CTRL+C to quit)
> aaa
< aaa
< aaa
< bbb
> ccc
< ccc

Redis Streamsを使ったリアルタイム通信基盤の完成!

あとはルームごとにSubscriberを分けたり、Redisクラスタに接続できるようにしたり、ALBを用意してWebSocketサーバーの負荷分散をしたりすれば完成です!
この辺りはRedis Pub/Subでも同じなので挑戦してみてください。

まとめ

ハンズオン形式でRedis Streamsを使ったリアルタイム通信基盤の作り方を紹介しました。
スケールする圧倒的に簡単なリアルタイム通信基盤なので、手軽に使いたいニーズのある方にかなりオススメだと思っています。
(もっとこうした方がいいなどあれば教えていただけるととても喜びます)

この記事を見て、同じように最強のリアルタイム通信基盤を作ってもらえると嬉しいです。
最後までご覧いただきありがとうございました!

メンバー募集

Happy Elements株式会社 カカリアスタジオでは、
いっしょに【熱狂的に愛されるコンテンツ】をつくっていただけるメンバーを大募集中です!
もし弊社にご興味持っていただけましたら、是非一度
下記採用サイトをご覧ください。
Happy Elements株式会社 採用特設サイト

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

UnityでNavigation Drawerっぽいものを作りました

はじめに

こんにちは。Livesense Advent Calendar 7日目です。
普段はAndroidアプリの開発・運用を担当している私ですが、クロスプラットフォームにも最近興味が出てきました。
今回は少しだけ触ったことがあるUnityを駆使して、スマホアプリのNavigation Drawerっぽいものを作ってみたのでその紹介をさせてください!
本記事では初心者向けの記事となりますので、どうぞよろしくお願いいたします。

Navigation Drawer

一般的にアプリの左側から出てくるメニューのことです。
別カテゴリーの画面に切り替える時や、アプリの設定画面を探すときに開くことが多いのではないでしょうか。

画像はMaterial Designより引用

Unityで作るにあたって

UnityにはAndroidのようにNavigation Drawerを作るためのライブラリはありません(あったらすみません)
そのため自前で作るか、有志の開発したassetを購入する必要があります。
今回はもちろん自前で実装してみます。

実装

1. プロジェクト作成

今回は3D要素はないので、Mobile 2Dで作成します。

2. 画面準備

  1. 「GameObject > UI > Canvas」 でHierarchyにCanvasを追加
  2. Hierarchyの中にあるCanvasを右クリックし、「UI > Panel」を追加
  3. PanelのInspectorからColorをクリックし、透明度(A)を255にする。色は任意
  4. Panelを右クリックし、「UI > Text」「UI > Button」を追加
    Text設定値
    Width : 500
    Height : 200
    FontSize : 100
    Text : HOME
    Button設定値
    Anchor Presets : Top-Left
    PosX : 150
    PosY : -150
    Width : 300
    Height : 300
    FontSize : 100
    Text : ≡ (アイコンなのでテキストでなくてもOK)
  5. Panelをコピペして、「Panel(1)」を生成
  6. Panel(1) とTextとButtonをの設定値を一部変更
    Panel(1)設定値
    Color : Panel とは違う色にする
    Anchor Presets : Bottom-Left
    PosX : -980
    PosY : 0
    Width : 980
    Text設定値
    Text : SETTING
    Button設定値
    Text : ← (アイコンなのでテキストでなくてもOK)
  7. 2つのPanelの名前をそれぞれ以下の通りにする
    Panel : HomePanel
    Panel(1) : SettingPanel
  8. 2つのButtonの名前をそれぞれ以下の通りにする
    HomePanel配下のButton : NavigationButton
    SettingPanel配下のButton : BackButton

HierarchyとSceneが以下の通りになっていることを確認して、問題なければ次へ進みます。

Hierarchy Scene

3. 画面切り替え用のスクリプト

  1. ProjectタブのAssetsで右クリックし、「Create → C# Script」を選択
  2. もう一度Assetsで右クリックし、「Create → C# Script」を選択
  3. ファイル(クラス名)をそれぞれHomeDrawerNavigatorSettingDrawerNavigatorにリネーム
  4. 以下のソースコードをそれぞれのクラスにコピペ
HomeDrawerNavigator.cs
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.UI;

public class HomeDrawerNavigator : MonoBehaviour
{
    [SerializeField]
    GameObject settingPanel;

    public void OnClick()
    {
      // settingPanelのPositionを(0, 0)に移動
      if (settingPanel != null)
      settingPanel.transform.position = new Vector2(0, 0);
    }
}
SettingDrawerNavigator.cs
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.UI;

public class SettingDrawerNavigator : MonoBehaviour
{
    [SerializeField]
    GameObject settingPanel;

    public void OnClick()
    {
      // settingPanelのPositionを(-980, 0)に移動
      if (settingPanel != null)
      settingPanel.transform.position = new Vector2(-980, 0);
    }
}

4. スクリプト適用

  1. HomeDrawerNavigator.csNavigationButtonまでドラッグ&ドロップ
  2. NavigationButtonのInspectorにあるHome Drawer Navigator(Script)の中にある、Setting PanelにSettingPanelを適用
  3. Home Drawer Navigator(Script)の上にある、On Click()欄の右下にある「+」をクリック
  4. None (Object)の欄にはNavigationButtonを適用
  5. 「No Function > HomeDrawerNavigator > OnClick()を選択
  6. SettingDrawerNavigator.csBackButtonまでドラッグ&ドロップ
  7. BackButtonについても、上の2~5にそって同じように適用

5. 実行

Sceneの上にあるRun「▶︎」をクリックして動作確認してみましょう。
左上のButtonをクリックして以下のようになればOKです。

起動直後・BackButtonクリック後 NavigationButtonクリック後

おわりに

今回はNavigation Drawerっぽいものを紹介させていただきました。
Unityはゲーム開発の側面に目を向けがちですが、個人的にはクロスプラットフォームであることを生かしたスマホアプリ開発ができることが強みだと思っています。
Navigation Drawerの表示にスクリプトを用いましたが、ループ処理を使って徐々にPositionをずらすことでアニメーションのような動きもできます。
AndroidもiOSも両方作ってみたいけど...という方へぜひ参考になれば嬉しいです。

追記

それぞれのスクリプトを以下のように置き換えるとNavigation Drawerを開くアニメーションっぽいものができました。

HomeDrawerNavigator.cs
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.UI;

public class HomeDrawerNavigator : MonoBehaviour
{
    [SerializeField]
    GameObject settingPanel;

    private bool isClicked = false;

    void Update() {
      if (isClicked && settingPanel != null) {
        settingPanel.transform.position = Vector2.MoveTowards(settingPanel.transform.position, new Vector2(0, 0), 3000f * Time.deltaTime);
      }

      if (settingPanel.transform.position.x == 0) {
        isClicked = false;
      }
    }

    public void OnClick()
    {
      isClicked = true;
    }
}
SettingDrawerNavigator.cs
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.UI;

public class SettingDrawerNavigator : MonoBehaviour
{
    [SerializeField]
    GameObject settingPanel;

    private bool isClicked = false;

    void Update() {
      if (isClicked && settingPanel != null) {
        settingPanel.transform.position = Vector2.MoveTowards(settingPanel.transform.position, new Vector2(-980, 0), 3000f * Time.deltaTime);
      }

      if (settingPanel.transform.position.x == -980) {
        isClicked = false;
      }
    }

    public void OnClick()
    {
      isClicked = true;
    }
}

参考

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

バックグラウンドのUnityアプリで入力検知する方法2020 for Windows10

結論

  • キーボードのみでOK & 厳密な同時押しは必要ない → UnityRawInputが使いやすい
  • ゲームパッド対応や正確な同時押し判定が必要 → SharpDX.DirectInputを使う
    • ※SharpDXは既に開発終了となっていますが、Windows10で動作するのでこの記事で紹介しています

開発環境

  • Windows 10 Pro
  • Unity 2019.4.12f1 (2020.1.8f1でも動作確認済み)

事前設定

まずはUnityアプリがBackgroundで動くように設定します。
Project Settings > Player > Resolution and PresentationにあるRun In Backgroundにチェックを入れます。
(プロジェクト作成時にデフォルトでチェックされています)

image.png

UnityRawInput

使い方

以下のREADMEに記載されている通り、.unitypackageをダウンロードしてプロジェクトにimportして使います。
GitHub - Elringus/UnityRawInput: Windows Raw Input wrapper for Unity game engine

日本語の説明記事はこちらを参照。
【Unity】バックグラウンド動作時のキー入力取得 - Qiita

特に難しいことは無いので詳細な説明は割愛しますが、バックグラウンドで動かすためにはRawKeyInput.Startにtrueを渡す必要があります。

private void Start() {
  var workInBackground = true;
  RawKeyInput.Start(workInBackground);
}

注意事項.1 忘れずにRawKeyInput.Stop()を書く

上記URLにも記載してありますが、OnDisableなどのタイミングでRawKeyInput.Stop()を実行する必要があります。これを忘れると、2回目のアプリ実行時にUnityEditorが落ちるので注意してください。(私は1時間くらい気づきませんでした...)

  private void OnDisable()
  {
    RawKeyInput.Stop();
  }

注意事項.2 ゲームパッド入力は非対応

以下のIssueがある通り、ゲームパッド入力には対応していないようです。

Any leads on how to add Joystick Inputs? · Issue #7 · Elringus/UnityRawInput

注意事項.3 複数キーの同時押しがズレる

複数(大体3つ以上のキー)を同時に押した場合に、任意のキーの入力開始タイミングがズレます。
詳しくは後述の実装サンプルを参照してください。

SharpDX.DirectInput

ゲームパッド入力ではお馴染みのDirectInputを使う方式です。
Unity(C#)で使う場合はSharpDXというラッパーが用意されているのでこちらを使います。

Home | SharpDX

上記のページにある通り既に開発が終了してしまっていますが、良い意味で枯れた技術であり、Windows10で問題なく動作します。

使い方

以下にアクセスし、右側のリンクから.nupkg形式のファイルをダウンロードします。
この記事を書いた時点での最新バージョンは4.2(2018/08/24更新)です。

https://www.nuget.org/packages/SharpDX/
https://www.nuget.org/packages/SharpDX.DirectInput/

ダウンロードした.nupkgファイルをzipとして解凍し、lib\net45フォルダ内の.dllファイルをAssetsフォルダに入れます。

// 以下2つのDLLを使います
sharpdx.4.2.0.nupkg
  lib\net45\SharpDX.dll
sharpdx.directinput.4.2.0.nupkg
  lib\net45\SharpDX.DirectInput.dll

次に、UnityEditorのProject Settings > Player > Other SettingsにあるApi Compatibility Levelで.NET 4.xを選択します。
image.png

ここまででusing SharpDX.DirectInput;ができるようになるため、後はSharpDXの使い方に従って実装...なのですが、公式ドキュメントが少なかったので参考サイトを載せておきます。

キーボード入力の場合

SharpDX(DirectX) キーボード入力 - C# de げぇむプログラマー

任意のキーが入力されていることを検出するには以下のようにします。

Keyboard keyboard = new Keyboard(...); // Keyboardの初期化方法は上記URL参照
...
Key key = Key.A; // 検出したいキー
KeyboardState state = keyboard.GetCurrentState();
return state.IsPressed(inputKey);

ゲームパッド入力の場合

SharpDX(DirectX) ゲームパッド入力 - C# de げぇむプログラマー

任意のボタンが入力されていることを検出するには以下のようにします。

/** 実装イメージ **/
Joystick joystick = new Joystick(...); // Joystickの初期化方法は上記URL参照
...
int buttonNo = 0; // 検出したいボタン番号
JoystickState state = joystick.GetCurrentState();
return state.Buttons[buttonNo];

実装サンプル

UnityRawInputとSharpDXの両方を同時に動かすためのサンプルアプリを作ってみました。
キーボードでA,S,D,Fのそれぞれのキーの入力開始時刻を画面に表示します。

https://github.com/MasaoBlue/unity-windows-bg-input-sample

READMEにも記載している通り、UnityRawInputはウィンドウにフォーカスが当たっていない状態でしか反応しないため注意してください。(この制限はSharpDXと同時に使用した場合のみ発生します)

UnityRawInputを使った場合の同時押しのズレについて

UnityRawInputを使用した方式だと、1フレーム中に複数のキーを同時押しした場合に、任意のキーの入力開始のタイミングが微妙にズレて検出されてしまいます。

下記がその一例で、SharpDX側では全て同一フレーム内で入力を検出できていますが、UnityRawInputの場合はAとSの入力開始時間がズレているのが分かります。※あくまで入力開始判定がズレるだけで、複数キーが押しっぱなしになっていることは正常に検出できます

image.png

さいごに

私はbeatmania IIDXが好きで、ゲーム中にボタンを押した所を可視化するツールを作っています。

IIDX Input Capturer

このツールを構想した2017年当時はSharpDXを使う方法が分からず挫折しましたが、今年になって再度調べてみた所DLLをimportするだけで使えることが分かり、なんとか実装までたどり着くことができました。

Unityのバックグラウンド入力について検索してもUnityRawInputを使った方法しかヒットしなかったため、SharpDXを使う方法もあるということが誰かに伝われば幸いです。

それでは、来年も良いUnityライフを!

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

【Unity】キャラクターが移動した跡の表現

本記事は サムザップ #2 Advent Calendar 2020 の12/7の記事です。
昨日の記事は @arima_moto さんの「LaravelAdminのシャーディング対応」でした。

はじめに

サムザップでエンジニアマネージャーをしています、北島です。
仕事ではマネジメントを主にしているので、なにかを実装する時間は減ってしまっているのですが・・・
アドベントカレンダーのような機会があると、やる機会が出来て良いですね!

さて、私の本業はサーバーエンジニアなのですが、趣味でたまにUnityを触っています。
今回は、Unityで「キャラクターが移動した跡の表現」について書きたいと思います。
Unityに関しては初心者ですので、温かい目で読んでいただければと思います。

実装するもの

今回、実装するものは以下になります。
【Unity】キャラクターが移動した跡の表現.gif
※gifなので、少しカクカクしているかもしれませんがお許しを

環境

以下の環境での記事になります。
バージョンが違う場合は、多少異なる部分があると思われます。

バージョン: Unity2019.4.10f1

実装

前提として、キャラクターの表示や移動に関しては予めできているものとします。
そちらについては、私の過去の記事(UnityでマリオやHollow Knightのような気持ちいいジャンプを実装するには)を参考にして下さい。

キャラクターに ParticleSystem を Add Compornent して表現を作っていきます。
1.png
まず、基本的な設定です。
Start Sizeや Start Rotationなどを設定します。
Simulation SpaceをWorldにすることで、Local座標ではなくWorld座標でParticleを生成してくれるので便利です。
2.png
次に、Emissionです。
Rate over Distanceを設定することでキャラクターの移動に応じてパーティクルを出してくれるようになります。
3.png
次にShapeです。
ここでは、キャラクターの足元に生成されるように、Positionを設定しています。
4.png
次にSize over Lifetimeです。
山のように設定することで、膨らむように出ていき段々消えていくような表現になります。
5.png
最後にRendererです。
今回は、簡単に表現を試したかったので、Cubeを選んでいます。
作りこんだSpriteで足跡を表現できると、カッコイイかもしれませんね!

最後に

というわけで、今回はUnityでキャラクターが移動した跡の表現を実装しました。

本業はサーバーエンジニアなので、こういった目で分かるものができるクライアント側もたまには楽しいですね!
この記事が少しでもなにかの参考になると嬉しいです。

明日は、@shimizu_toshihiko さんの記事です、お楽しみに!

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

弱小開発者が7年間ソシャゲを開発して感じた7つの教訓

はじめに

Unity#3アドベントカレンダー・7日目の記事です。

どうもゴンです!
公認会計士から、脱サラして、セブ島で起業して
英語学習ゲーム「英語物語」を作りました。
Android IOS

だいたい月商300−500万、DAU2万弱のソシャゲで、数十人で開発・運営してます。

英語物語が2020年12月で7周年を迎えるので、
これからゲームやアプリ開発をしたいと思っているプログラマ向けに
7年間のソシャゲ開発を通して学んだ7つの教訓
をお伝えさせて頂きます。

教訓① 最初から独立してソシャゲを作るな。

本当に昔の自分に伝えたい言葉です。
この後の教訓も、これさえ守ればだいたい解決します。

独立して、かなり初期の段階からUnityで英語物語を作ったのですが、
まぁ、大変でした。なんとかなってるのは、支えてくれるユーザ様と家族と仲間のおかげです。

最初から独立してソシャゲを作るのは、
ポケモンで例えるなら、
最初のキャラをゲットして、すぐに四天王に挑戦するようなものです。

レベル上げも技の習得も仲間集めも戦い方すら知らない状態で、強敵に挑むのは辛いです。
ニコニコ動画もびっくりの難易度です。

ソシャゲビジネスにはたくさんの人・物・金ノウハウが必要です。
そして、競合他社がスーパー強いです。
さらに、一度開発したら終わりではなく、その後の運用保守が始まります。

適切な企業で修行しながら、
副業として手離れのいい開発を繰り返し、
ノウハウや人脈、資金を培うのが楽です。

特に独立してお金を稼がないといけなくなると、
自分の夢を実現するために必要な。
長期的に正しい意思決定をしにくくなるので、
注意してください。

教訓② 安易に人を雇うなかれ

あなたはプログラマとしては、優秀かもしれませんが、
マネージャー・人事としてはどうでしょう?

僕は会計士やプログラマとしてのノウハウ(それもほんの少しですが)
しかない段階で、人を雇って、たくさんの失敗をしました。
運によく今の素敵な仲間に恵まれていますが、
かなりお金と労力の無駄遣いをしてきました。

できれば、他の企業で人を雇い管理する経験とノウハウを学ばせてもらいましょう。
それができないなら、本や動画等をたくさん読んで、慎重に決断しましょう。

安いから、友達だからと未経験者を雇うのは、とても危険です。
多少高くても経験あるプロに頼む方が懸命でしょう。
できれば、一緒に働いた事のある人がおすすめです。

僕は独立してからとても安い給与でやってきましたが、
自分の給与が安いと、周りにも低給を期待してしまうので、
独立後も、まずは自分の給与を最大限引き上げた上で、
採用を開始するのが良いかもしれません。

教訓③ 新規事業の9割は失敗する事実を受け入れよう

基本的に、新しく始める事は失敗します。
にもかかわらず、始める前は絶対に成功すると信じて行動してしまいます。

英語物語が軌道に乗った後、フィリピンチームで作った世界向けのアプリで
凄まじい失敗をしてしまいました。
過去記事はこちら⇒海外でゲーム開発会社を作って、2000万円溶かして学んだ反省点

挑戦するなと言いたいのではありません。
最低限の挑戦をした後に、
その挑戦を継続すべきか否かを
きちんと分析・意思決定することが大切です。

挑戦が失敗だったという事実から目をそむけたいので、
ずるずる継続しがちですが、誰も幸せになりません。

ほとんどの新規事業が失敗する事実を受け入れ、
適切な時期に撤退の意思決定を行える準備を整えましょう。

教訓④ 技術投資を惜しむな。

7年間も、一つのゲームを開発してると、
凄まじい負の遺産に遭遇します。

一度、汚くなったコードは、加速度的に汚くなります。

将来、製品やサービスの開発をしたいと思っているなら、
長期的にプロダクトと付き合う事は避けられません。

たとえ、短期のプロトタイプ開発であっても、練習だと思って
最低限の品質を維持して開発した方が将来のためになります。

製品の開発だけでなく、
プログラマのベストプラクティスとして紹介されてるような
技術をしっかり学習し、実践していきましょう。

特にGit、設計関連、アジャイルとスクラム関連技術は、重要度が高いと感じています。

教訓⑤ システム以外も、しっかり設計・実装しよう。

長年プログラマをやってる皆さんなら、ソフトウェアにとって、
いかに綺麗なアーキテクチャとコードが大切かを身にしみているかと思います。

そしてそれと同じくらい、
組織や事業、業務プロセスの設計・実装を行う事が大切です。

適当に人雇って、適当に業務をお願いして、適当に完成させて公開する。のは、
何の設計もなしに、コード規約なしに、
一つのクラスで、一つのメソッドでコードを書いてるようなものかもしれません。

組織や事業、業務プロセスは、ソフトウェア以上に、仕様変更やリファクタが大変です。

英語物語の設計には多少気を使ってきましたが、
組織や事業、業務プロセスの設計をおろそかにしたために、
様々な問題が噴出し、結果として開発に使える時間が少なりました。

ソフト開発と同じように、組織や事業、業務プロセスも
きちんと学んで、きちんと設計し、きちんと運用を心がけましょう。

教訓⑥ 退屈に恋をしろ。

1割の確率でしか成功しない新規事業が成功したとします。
その後すぐに、関連性の薄い別の新規事業を始める人がいます。

かくいう僕も、そんな事ばかりでした。
英語物語がそこそこ成功した後に、
すぐ別のアプリや事業に手を出そうとしたのです。

失敗して、また英語物語に戻り、また飽きて、別の事を始める。
そんな事の繰り返しでした。

せっかく成功したのなら、追求しましょう。
少なくとも、徹底的に仕組み化して手離れを良くしてから別の事をはじめましょう。
中途半端な状態で、別の事を始めるのは大変です。

器用な人にはできるかもしれませんが、
もしあなたがそうでないのなら、
退屈に恋をして、しっかり集中するといいかもしれません。

教訓⑦ 完璧主義は捨てろ。信じる勇気。強みに集中。

特にソーシャルゲームだと膨大な要素の管理が必要になります。
大量の業務や色々なノウハウが必要となり、一人では無理ゲーです。

そんな中で大切だなと感じたのが、完璧主義を捨てる事です。
大切な2割(のさらに2割)部分だけ、しっかりこだわりを発揮して、
あとの部分は、自分が信じたメンバーを信じる事が大切です。

自分の想定と違うからといって、1から10まで指摘していては
時間が足りないし、メンバーのやる気はなくなります。

きちんと目的や背景を伝えたら、
その分野の事を一番知っているのは担当メンバーだと意識して、
まずはベストを尽くして作業してくれたことを最大限褒めて、感謝し、
本当に重要な部分でのみ、優しく指摘するのがおすすめです。

そして、絶対に自分がすべき領域に集中できる環境を作っていきましょう。

おまけ Unityで大規模開発する上で大切だと思うポイント

  • プラグインの管理は厳重に。
  • Unityのバージョンアップに関する明確な指針を持つ。
  • コードの管理や設計と同じく、SceneやPrefab、ProjectFolderの管理・設計を考えとく。
  • Monovihaviorに紐付いたView層から、なるべくロジックを引き剥がす。

あとがき 

最後までお読みくださり、ありがとうございます。
なんだかんだ、失敗ばかりの人生ですが、
素敵な仲間とユーザさんと、友達・家族に恵まれて
僕はとっても幸せです。

今後は英語物語の開発にコミットしつつ、
フィリピン人メンバーを中心にした開発業務プロセスの構築と、
日本メンバーを中心にした企画運営分析マーケプロセスの構築
に注力していく予定です。

個人的には、筋トレ・ストレッチ・瞑想・読書・習慣管理・日報に取り組みたい。

もしよければ、ツイッターをフォロー頂けると、
とっても嬉しいです。
ツイッターはこちら

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

OpenCVplusUnity 画像フリップ処理について

はじめに

この記事ではOpenCVplusUnity(無料)を使用しています。
Unityで画像を保存する際にフリップ処理したいなんて時があると思います。RendereTextreでこねくり回したり、ピクセル取り出して処理することもできるかと思いますが面倒です。大人しくそんな時はOpenCVを使いましょう。楽です。

今回はこの結果を目指します。Texture2DをX軸,Y軸,XY軸に対してフリップ処理します。
スクリーンショット 2020-12-06 13.20.05.png
といっても、することはPythonで使用する時と変わりません。
Texture2DをMatへ変換してFlip変換して再びTexture2Dへ返しているだけです。

実装方法

OpenCVManager.cs
using UnityEngine;
using OpenCvSharp;

public class OpenCVManager
{
   /// <summary>
    /// Texture2Dをフリップ回転処理
    /// </summary>
    public static Texture2D FlipToTex2D(Texture2D tex, int flipNum)
    {
        Mat origin = OpenCvSharp.Unity.TextureToMat(tex);
        if (flipNum == -1)
        {
            Cv2.Flip(origin, origin, FlipMode.XY);
        }
        else if (flipNum == 0)
        {
            Cv2.Flip(origin, origin, FlipMode.X);
        }
        else if (flipNum == 1)
        {
            Cv2.Flip(origin, origin, FlipMode.Y);
        }
        return OpenCvSharp.Unity.MatToTexture(origin);
    }
}

まとめ

余談ですが、iOSアーカイブの予定のある方は最初からOpenCVforUnityを購入をお勧めします。bitcodeエラーから抜けれず、アーカイブできなくて一度詰みました。全移行する苦行が待ってます。

もちろんOpenCVをネイティブプラグインで全て自分で繋込めれば問題ないですが労力が見合ってません。1万円程度ですのでケチらずお金で解決できるなら解決しましょう。

もし、OpenCVplusUnityでアーカイブできた人は教えてください。
外部ライブラリを使用した際は、アーカイブまで確認しろと教えられた良い教訓でした。

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

Google PlayのUSランキングの上位層のアプリを見ながらゲームを考えてみる #1

はじめに

就活用にUnityでなにかハイパーカジュアルゲームを作ろうと思ったのですが、せいぜい十個くらいしかやったことがないのでそこまでこのジャンルについて知りません。
なのでこの記事ではGooglePlayのアメリカの無料ゲームのランキングを見ながらアプリの企画について考えていこうと思います。
餅は餅屋といいますし、上位に入ってるアプリの良い所を参考にすれば共通点やDL数をあげるコツが分かるのでは?という発想です。
なんでアメリカなの?かというとそっちのほうがユーザーが多く、ユーザー数をより多く欲しい場合は対応せざるを得ないと思うからです。

見ていくアプリ

URL:https://play.google.com/store/apps/collection/cluster?clp=0g4cChoKFHRvcHNlbGxpbmdfZnJlZV9HQU1FEAcYAw%3D%3D:S:ANO1ljJ_Y5U&gsr=Ch_SDhwKGgoUdG9wc2VsbGluZ19mcmVlX0dBTUUQBxgD:S:ANO1ljL4b8c&hl=us&gl=us
image.png
image.png

Among Us

いわゆる宇宙人狼ですね。
人狼をグラフィカルにしたようなゲームです。
海外はいまいちわかりませんが、日本ではこのゲームは実況者とかVtuberがやってたから流行ったイメージがありますね。
同じようなゲームでいうと、ProjectWinterを2DにしてSFチックにした感じでしょうか。

https://www.famitsu.com/news/202011/09208954.html

こちらの記事によると海外でもTwitchの大手ストリーマーが取り上げて、それで流行ったようです。
ルールが単純かつ有名で、実況映えしていて、見ているだけでも楽しいし、実際にプレイしてもすぐに楽しめます。

パブリックで見知らぬ人とやるときは大体が海外の方とマッチングしますし、そもそも日本語は入力できないので
英語だけで話して人狼を特定していく感じになります。
人狼側は一人killしてから次のkillまでに長めのクールタイムがあったりで、レベルデザインがすごいなと思います。

昔からの遊びを別の形にしてゲームにするというのは結構やってる所が多い気がしますが、いい成功例だと思います。
世界共通とはいわずとも、やっているところが多い遊びを調べて何かのジャンルと掛け合わせるというのはよさそうですね。

あそびと言って思い出しましたが、世界のアソビ大全とかいうゲームがありましたね。
テーブルゲームが基本ですが、あれも同じような発想なのかな。

Project Makeover

パズル&着せ替えゲームという感じでしょうか。
レビューを見ていると難易度高め&運要素高め&要求ゲーム内通貨が多い&回復手段が少ない、みたいなマゾゲーな印象を感じます。
パズルゲームは基本やらないので参考にしづらいですが、着せ替え要素が一つのゲームの面白さの形としてあるゲームは多いですよね。

Roof Rails

VOODOOさんのハイパーカジュアルゲームですね。
キャラが持っている棒を大きくして、その棒でアイテムを拾ったりアクションしたりするゲームです。
棒が擦り切れたら失敗するようです。
ハイパーカジュアルゲームは結構持っているものを大きくしてなにかするゲームがある気がしますね。

Sushi Roll 3D - Cooking ASMR Game

寿司を作って提供するゲームです。
どこかで見たようなデザインの客が要望してくるメニューを色々なアクションで作っていきます。

オブジェクトハント

隠れん坊オンラインですね。これ。

ショートカットラン

VOODOOさんの作品ですね。
最近の広告だとこれが一番見る気がします。
道でブロックを集めて、そのブロックの分だけ海をショートカットできる。
アイコン、名前、ストアの画像全てで分かり易いですね。
プレイしていなくてもゲームの内容が伝わってきたので、分かりやすさが如何に大事なことなのかと思わされますね。
ぱっと見で内容を伝えるっていうのは凄いことだよなあ、とつくづく思います。

Imposter Solo Kill

Among Usに寄せた暗殺ゲーですね。

Chat Master!

チャットアプリのようですがいまいち500万ダウンロードされている理由が分かりません。
Google Playは謎が多いですね...

BMX Space

BMXができるゲームですね。オンラインプレイにも対応しています。
同じデベロッパーの作品を見るとこれとは別に自由にBMXが楽しめるゲームがあったので、
そちらからの流入が多そう。
一つのジャンルを専門的につくっていくスタイルも一部のユーザーには刺さりそうです。

まとめ

個人的にはスマホゲーは横持ちのほうが好きなものが多いのですが、ランキングで見ると無料ゲームで横持ち要求しそうなのはほとんどないですね。レースゲームであっても縦持ちで作られているものがあります。こういうジャンルで横持ちは人権無さそう。
あと見るからにパクリって分かるレベルでも消されないのも発見ですね。する気もないですが。

自分はレースゲームが好きなので、こういうハイパーカジュアルを作るとしたら車要素やレース要素を含めたいですね。

今回はこの辺で一回終わりにします。
つづきはまた別で投稿することにします。

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

NoCode × Unity ~コードを1行も書かずにRunゲームを作る~

こんにちは!元 Life is Tech! メンターのジーニーです!
Life is Tech! では Unity、Maya、AfterEffects などを教えていました。

注意!

本記事は NoCode でどの程度のゲームを作れるかを実験した ネタ記事 です。
Unity でゲームを作る場合、通常少なからずコードを書いたり読んだりする必要があります。

もしどうしてもコードを書きたくない!という場合は Bolt などのビジュアルスクリプティングツールを使いましょう。
(いつの間にか Bolt 無料化してたんですね、知らなかった、、、)
https://assetstore.unity.com/packages/tools/visual-scripting/bolt-163802

はじめに

プログラミング未経験者が Unity なら3Dゲームを簡単に作れる!と思って手をつけたものの、案外初歩の段階でコードが出てきて、結局それなりにコード書かないといけないじゃん!ってなって挫折…、結構あるあるだと思っています。
まして時代は NoCode。ならば意地でもコードを書かずにゲーム作ってやる!!

…ということで、今回の記事では一切コードを書かずにゲームを作ってみました。

ルール

ゲームを作る前にルールを決めます。今回は以下の3つの縛りで作りました。

  • コードは一切書かない
  • 外部 Asset は一切使わない
  • 使用可能パッケージは Unity UI のみ

ホントのこと言うと3つ目ちょっとセコいのですが、これがないと流石に厳しいし、最初から入っているやつなのでお許しを、、、

参考情報として、諸々のバージョンは以下の通りです。

  • Unity: 2019.4.0f1
  • Unity UI: Version 1.0.0

できたもの

今回は Run ゲームを作りました。落下せずにゴールに向かうシンプルなものです。
操作は左右移動とジャンプのみ。

できたものを貼っておきます。
https://unityroom.com/games/nocode_rungame
1fc1663aa1164fe17cc5ed40d8853a67.gif
クオリティはお察しですが、一応遊べるものにはなっています。

使ったアセットはたったこれだけ。C# スクリプトは勿論ゼロ。
image.png
WebGLの書き出しファイルの合計サイズはたった 3.66MB1
ビルドもびっくりするほど高速でした。

実装方針

今回は下記の実装方針を取りました。

  • ゲーム中の「動き」はすべて Animation で表現する
  • ゲームパッドは Unity UI の機能で作る

ゲーム中のアニメーションは

  • 左右の移動、ジャンプなどプレイヤーの操作に関わる部分はプレイヤー自身を動かす
  • 前進やステージギミックなどプレイヤーの操作に関わらず動く部分はステージを動かす

としました。このほうがアニメーションの動作チェックなどやりやすいかなと、なんとなく思ったからです。
(このようにしたことの弊害が後々出てくるのですが…)

プレイヤーを作る

プレイヤーオブジェクトの設定は下の通りです。
image.png
ポイントは、

  • Animator の Apply Root Motion にチェックを入れる
  • Rigidbody の Drag、Angular Drag は 0 にする
  • Rigidbody の Use Gravity にチェックを入れる
  • Rigidbody の Constraints の Freeze Rotation はすべてチェックを入れる

くらいでしょうか。
Apply Root Motion にチェックを入れると、現在いる位置から相対的にアニメーションするようになります。

プレイヤーのアニメーション遷移は下の通りです。
image.png
名前の通りですが、Wait が入力待ち(= 何もアニメーションしない)、Right が右移動、Left が左移動、Jump がジャンプです。
図の左を見ると分かるように Right、Left、Jump の3つのトリガーが定義されており、この3つのトリガーを使って状態を遷移させます。

ゲームパッドを作る

ゲームパッドの UI はプレイヤーの子オブジェクトとして配置します。
Canvas の Render Mode を World Space にしておき、Event Camera は忘れずにメインカメラを指定します。
image.png
例えば右ボタンは以下のようになっています。
各ボタンの役割をする Image には Event Trigger コンポーネントをアタッチし、クリック時 (Pointer Click) のイベントとして、Player オブジェクトの Animator.SetTrigger を指定します。
引数として Right、Left、Jump と入れてあげれば、ボタンをクリックした際に先程定義した3つのトリガーを操作できます。
image.png
なお、Event Trigger を使う場合は、必ず Image の Raycast Target にチェックを入れましょう。これが入っていないとポインタをボタンの上に持っていっても反応してくれません。
逆に、テキストなど当たり判定が必要のない UI はRaycast Target のチェックを外しておくのが無難です。

ステージを作る

ステージは空オブジェクトを作り、その子オブジェクトとして複数の Plane を置いています。
ゴールに置いたゲートに文字を入れたかったので Canvas も入れています。
image.png
空オブジェクトにアニメーションを付けると子の Plane もその動きに追従します。
ステージはゲームオーバー時などに位置をリセットする必要がある(= 相対的な移動をするべきでない)ので、Apply Root Motion のチェックは外しておきます。
image.png
ステージ移動のアニメーションは絶対座標で指定し、等速で手前方向に動くようにします。
DopeSheet で編集すると速度変化が緩やかなカーブを描いていることがあるので、Curves を編集し下図のように直線になるようにします。これでステージが等速で移動するようになります。
image.png

その他困ったこと

上記の内容で大体のことは説明できたのですが、実装中に困ったことが2点ありました。

プレイヤーどこに落ちるか分からん問題

プレイヤーはミスした際に落下します。
普段なら、ある高さより下に来たらスタート位置に戻したり、ゲームオーバー画面に遷移させたりするわけですが、スクリプトが書けないのでそんなことできません。なので、落下した先にゲームオーバー画面 (らしきもの) を用意してあげる必要があります。

ただ、最初に決めた仕様でプレイヤーは左右に好き勝手移動します。
どこに落ちるかをあらかじめ知ることはできないのです。
image.png
最初、上のように愚直にゲームオーバー画面と床を置いたものの、これだと落下位置によっては...
image.png
こんな感じになってしまいます。なんだよmeOverって...。

プレイヤーを動かすのをあきらめ、左右移動も含めアニメーションは全部ステージに押し付けるべきか…。
ぶっちゃけそれでも良かったんですが、最初に決めた仕様を捻じ曲げたくなかったので、今回は以下のように解決しました。
image.png
とりあえず沢山書いとけばどこに落ちても文字が読める!お菓子の個包装かよ。
image.png
ちなみに全体はこんな感じになっているのですが、Rect Mask 2D コンポーネントをアタッチした Image の子に GameOver の文字を敷き詰めた Text を置くと作れます。

プレイヤーを初期位置に戻すの大変問題

プレイヤーにアニメーションさせるのをいい加減あきらめろという話ですが、ゲームオーバー画面からプレイヤーを初期位置に戻す方法も悩みました。
プレイヤーは Apply Root Motion にチェックを入れてる影響で、特定座標にワープ!みたいなことがやりづらく、、、。
そこで、この問題は物理的解決策をとることにしました。
bba3bb71c06e07741f784383c9af134f.gif
絵的に多少面白くなった気がするので、これで良しとしましょう。
この動き、どこかで見たことあると思ったら、ボーリングのピン立て直すやつや。。。

まとめ

ネタ記事とは言ったものの、コード無しでも意外とゲームらしきものは作れることが分かりました。
今回の応用で少なくともノベルゲームやシューティングゲームくらいなら作れそうな気はしています。


  1. それでも容量大きくない?という声が聞こえてきそうですが、どれだけ削ってもある程度エンジンコードが残るので、ほぼほぼ限界値です、、、 

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

HoloLens2 × Cognitive Services(Speech SDKでテキストから音声合成)

はじめに

HoloLensアドベントカレンダー2020の6日目の記事です。
前回の記事の続きで、画像分析APIで画像説明文を生成し、それを読み上げてみましょう。

開発環境

  • Azure
  • Speech SDK 1.14.0
  • Unity 2019.4.1f1
  • MRTK 2.5.1
  • Windows 10 PC

導入

1.前回の記事まで終わらせてください。

2.Speech SDKを設定する(Unity)からMicrosoft.CognitiveServices.Speech.1.14.0.unitypackageをダウンロードし、インポートします。

image.png

3.Azureポータルから音声を作成し、場所とキーをメモっておいてください。
image.png

image.png

4.スピーカー出力に合成するを参考にTapToCaptureAnalyzeAPI.csを編集します。画像分析APIで得られた画像説明文をSpeech SDKのsynthesizer.SpeakTextAsyncに投げます。

TapToCaptureAnalyzeAPI.cs
using System.Collections;
using System.Collections.Generic;
using System.Linq;
using System;
using UnityEngine;
using Microsoft.MixedReality.Toolkit.Utilities;
using System.Threading.Tasks;
using OpenCVForUnity.CoreModule;
using OpenCVForUnity.UnityUtils;
using OpenCVForUnity.ImgprocModule;

// SpeechSDK 追加分ここから
using System.IO;
using System.Text;
using Microsoft.CognitiveServices.Speech;
using Microsoft.CognitiveServices.Speech.Audio;
// SpeechSDK 追加分ここまで

public class TapToCaptureAnalyzeAPI : MonoBehaviour
{

    // SpeechSDK 追加分ここから
    public AudioSource audioSource;
    async Task SynthesizeAudioAsync(string text) 
    {
        var config = SpeechConfig.FromSubscription("YourSubscriptionKey", "YourServiceRegion");
        var synthesizer = new SpeechSynthesizer(config, null); // nullを省略するとPCのスピーカーから出力されるが、HoloLensでは出力されない。

        // https://github.com/Azure-Samples/cognitive-services-speech-sdk/blob/master/quickstart/csharp/unity/text-to-speech/Assets/Scripts/HelloWorld.cs
        // Starts speech synthesis, and returns after a single utterance is synthesized.
        using (var result = synthesizer.SpeakTextAsync(text).Result)
        {
            // Checks result.
            if (result.Reason == ResultReason.SynthesizingAudioCompleted)
            {
                // Native playback is not supported on Unity yet (currently only supported on Windows/Linux Desktop).
                // Use the Unity API to play audio here as a short term solution.
                // Native playback support will be added in the future release.
                var sampleCount = result.AudioData.Length / 2;
                var audioData = new float[sampleCount];
                for (var i = 0; i < sampleCount; ++i)
                {
                    audioData[i] = (short)(result.AudioData[i * 2 + 1] << 8 | result.AudioData[i * 2]) / 32768.0F;
                }

                // The output audio format is 16K 16bit mono
                var audioClip = AudioClip.Create("SynthesizedAudio", sampleCount, 1, 16000, false);
                audioClip.SetData(audioData, 0);
                audioSource.clip = audioClip;
                audioSource.Play();

                // newMessage = "Speech synthesis succeeded!";
            }
            else if (result.Reason == ResultReason.Canceled)
            {
                var cancellation = SpeechSynthesisCancellationDetails.FromResult(result);
                // newMessage = $"CANCELED:\nReason=[{cancellation.Reason}]\nErrorDetails=[{cancellation.ErrorDetails}]\nDid you update the subscription info?";
            }
        }
    }
    // SpeechSDK 追加分ここまで

    public GameObject quad;

    [System.Serializable]
    public class Analyze
    {
        public Categories[] categories;
        public Color color;
        public Description description;
        public string requestId;
        public Metadata metadata;
    }

    [System.Serializable]
    public class Categories
    {
        public string name;
        public float score;
    }

    [System.Serializable]
    public class Color
    {
        public string dominantColorForeground;
        public string dominantColorBackground;
        public string[] dominantColors;
        public string accentColor;
        public bool isBwImg;
        public bool isBWImg;
    }

    [System.Serializable]
    public class Description
    {
        public string[] tags;
        public Captions[] captions;
    }

    [System.Serializable]
    public class Captions
    {
        public string text;
        public float confidence;
    }

    [System.Serializable]
    public class Metadata
    {
        public int height;
        public int width;
        public string format;
    }

    UnityEngine.Windows.WebCam.PhotoCapture photoCaptureObject = null;
    Texture2D targetTexture = null;

    private string endpoint = "https://<Insert Your Endpoint>/vision/v3.1/analyze";
    private string subscription_key = "<Insert Your API Key>";

    public void AirTap()
    {
        Resolution cameraResolution = UnityEngine.Windows.WebCam.PhotoCapture.SupportedResolutions.OrderByDescending((res) => res.width * res.height).First();
        targetTexture = new Texture2D(cameraResolution.width, cameraResolution.height);

        // PhotoCapture オブジェクトを作成します
        UnityEngine.Windows.WebCam.PhotoCapture.CreateAsync(false, delegate (UnityEngine.Windows.WebCam.PhotoCapture captureObject) {
            photoCaptureObject = captureObject;
            UnityEngine.Windows.WebCam.CameraParameters cameraParameters = new UnityEngine.Windows.WebCam.CameraParameters();
            cameraParameters.hologramOpacity = 0.0f;
            cameraParameters.cameraResolutionWidth = cameraResolution.width;
            cameraParameters.cameraResolutionHeight = cameraResolution.height;
            cameraParameters.pixelFormat = UnityEngine.Windows.WebCam.CapturePixelFormat.BGRA32;

            // カメラをアクティベートします
            photoCaptureObject.StartPhotoModeAsync(cameraParameters, delegate (UnityEngine.Windows.WebCam.PhotoCapture.PhotoCaptureResult result) {
                // 写真を撮ります
                photoCaptureObject.TakePhotoAsync(OnCapturedPhotoToMemoryAsync);
            });
        });
    }

    async void OnCapturedPhotoToMemoryAsync(UnityEngine.Windows.WebCam.PhotoCapture.PhotoCaptureResult result, UnityEngine.Windows.WebCam.PhotoCaptureFrame photoCaptureFrame)
    {
        // ターゲットテクスチャに RAW 画像データをコピーします
        photoCaptureFrame.UploadImageDataToTexture(targetTexture);
        byte[] bodyData = targetTexture.EncodeToJPG();

        Response response = new Response();

        try
        {
            string query = endpoint + "?visualFeatures=Categories,Description,Color";
            var headers = new Dictionary<string, string>();
            headers.Add("Ocp-Apim-Subscription-Key", subscription_key);
            response = await Rest.PostAsync(query, bodyData, headers, -1, true);
        }
        catch (Exception e)
        {
            photoCaptureObject.StopPhotoModeAsync(OnStoppedPhotoMode);
            return;
        }

        if (!response.Successful)
        {
            photoCaptureObject.StopPhotoModeAsync(OnStoppedPhotoMode);
            return;
        }

        Debug.Log(response.ResponseCode);
        Debug.Log(response.ResponseBody);
        Analyze analyze = JsonUtility.FromJson<Analyze>(response.ResponseBody);
        Debug.Log(analyze.description.captions[0].text);
        await SynthesizeAudioAsync(analyze.description.captions[0].text);

        // OpenCVを用いて結果をて画像に書き込み
        Mat imgMat = new Mat(targetTexture.height, targetTexture.width, CvType.CV_8UC4);
        Utils.texture2DToMat(targetTexture, imgMat);
        Debug.Log("imgMat.ToString() " + imgMat.ToString());
        Imgproc.putText(imgMat, analyze.description.captions[0].text, new Point(10, 100), Imgproc.FONT_HERSHEY_SIMPLEX, 4.0, new Scalar(255, 255, 0, 255), 4, Imgproc.LINE_AA, false);
        Texture2D texture = new Texture2D(imgMat.cols(), imgMat.rows(), TextureFormat.RGBA32, false);
        Utils.matToTexture2D(imgMat, texture);
        Renderer quadRenderer = quad.GetComponent<Renderer>() as Renderer;
        quadRenderer.material.SetTexture("_MainTex", texture);

        // カメラを非アクティブにします
        photoCaptureObject.StopPhotoModeAsync(OnStoppedPhotoMode);
    }

    void OnStoppedPhotoMode(UnityEngine.Windows.WebCam.PhotoCapture.PhotoCaptureResult result)
    {
        // photo capture のリソースをシャットダウンします
        photoCaptureObject.Dispose();
        photoCaptureObject = null;
    }
}

5.SpeechConfig.FromSubscriptionにキーと場所をコピペしてください。

6.最初は、synthesizer.SpeakTextAsyncでAudioConfigパラメーターを省略するだけでスピーカーから出力されるので行けると思ったのですが、HoloLensでは出力されませんでした。

static async Task SynthesizeAudioAsync() 
{
    var config = SpeechConfig.FromSubscription("YourSubscriptionKey", "YourServiceRegion");
    using var synthesizer = new SpeechSynthesizer(config);
    await synthesizer.SpeakTextAsync("Synthesizing directly to speaker output.");
}

7.そこで、InternetClientServer と PrivateNetworkClientServer の機能を有効にしたり、MixedRealityToolkitの音声コマンドの設定をしてみたり、

音声認識と文字起こしには Speech SDK を使用するため、Speech SDK の機能の妨げにならないように、MRTK の音声コマンドを構成する必要があります。 これを実現するには、音声コマンドの開始動作を Auto Start から Manual Start に変更することができます。
Hierarchy ウィンドウで MixedRealityToolkit オブジェクトを選択した状態で、Inspector ウィンドウで Input タブを選択し、DefaultHoloLens2InputSystemProfile と DefaultMixedRealitySpeechCommandsProfile を複製し、音声コマンドの Start BehaviorManual Start に変更します。

image.png image.png image.png

ARM64でビルドしたりしたのですが、関係ありませんでした。

8.調べた結果【Unity】Microsoft Azure を用いてキャラクターを流暢に話させる
cognitive-services-speech-sdkのHelloWorld.csを参考に、音声合成結果をAudioSourceのclipに割り当てる必要がありました。

9.あとはTapToCaptureAnalyzeにAudioSourceをAdd Componentし、TapToCaptureAnalyzeAPIのAudioSourceにTapToCaptureAnalyzeをD&Dしてアタッチします。

image.png

実行

実行動画を見てください。エアタップすると画像キャプチャし、画像説明文を生成、読み上げてくれます。

"a hand holding a fanned out money" 札束を持つ手

スタートアップ夢の扉のヌンギルが完成しました!
明日は、弟子(@Horomoto-Asahi)による「MRTK関連で何かを書く」です。

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

HoloLens2 × Azure Cognitive Services(Speech SDKでテキストから音声合成)

はじめに

HoloLensアドベントカレンダー2020の6日目の記事です。
前回の記事の続きで、画像分析APIで画像説明文を生成し、それを読み上げてみましょう。

開発環境

  • Azure
  • Speech SDK 1.14.0
  • Unity 2019.4.1f1
  • MRTK 2.5.1
  • Windows 10 PC

導入

1.前回の記事まで終わらせてください。

2.Speech SDKを設定する(Unity)からMicrosoft.CognitiveServices.Speech.1.14.0.unitypackageをダウンロードし、インポートします。

image.png

3.Azureポータルから音声を作成し、場所とキーをメモっておいてください。
image.png

image.png

4.スピーカー出力に合成するを参考にTapToCaptureAnalyzeAPI.csを編集します。画像分析APIで得られた画像説明文をSpeech SDKのsynthesizer.SpeakTextAsyncに投げます。

TapToCaptureAnalyzeAPI.cs
using System.Collections;
using System.Collections.Generic;
using System.Linq;
using System;
using UnityEngine;
using Microsoft.MixedReality.Toolkit.Utilities;
using System.Threading.Tasks;
using OpenCVForUnity.CoreModule;
using OpenCVForUnity.UnityUtils;
using OpenCVForUnity.ImgprocModule;

// SpeechSDK 追加分ここから
using System.IO;
using System.Text;
using Microsoft.CognitiveServices.Speech;
using Microsoft.CognitiveServices.Speech.Audio;
// SpeechSDK 追加分ここまで

public class TapToCaptureAnalyzeAPI : MonoBehaviour
{

    // SpeechSDK 追加分ここから
    public AudioSource audioSource;
    async Task SynthesizeAudioAsync(string text) 
    {
        var config = SpeechConfig.FromSubscription("YourSubscriptionKey", "YourServiceRegion");
        var synthesizer = new SpeechSynthesizer(config, null); // nullを省略するとPCのスピーカーから出力されるが、HoloLensでは出力されない。

        // https://github.com/Azure-Samples/cognitive-services-speech-sdk/blob/master/quickstart/csharp/unity/text-to-speech/Assets/Scripts/HelloWorld.cs
        // Starts speech synthesis, and returns after a single utterance is synthesized.
        using (var result = synthesizer.SpeakTextAsync(text).Result)
        {
            // Checks result.
            if (result.Reason == ResultReason.SynthesizingAudioCompleted)
            {
                // Native playback is not supported on Unity yet (currently only supported on Windows/Linux Desktop).
                // Use the Unity API to play audio here as a short term solution.
                // Native playback support will be added in the future release.
                var sampleCount = result.AudioData.Length / 2;
                var audioData = new float[sampleCount];
                for (var i = 0; i < sampleCount; ++i)
                {
                    audioData[i] = (short)(result.AudioData[i * 2 + 1] << 8 | result.AudioData[i * 2]) / 32768.0F;
                }

                // The output audio format is 16K 16bit mono
                var audioClip = AudioClip.Create("SynthesizedAudio", sampleCount, 1, 16000, false);
                audioClip.SetData(audioData, 0);
                audioSource.clip = audioClip;
                audioSource.Play();

                // newMessage = "Speech synthesis succeeded!";
            }
            else if (result.Reason == ResultReason.Canceled)
            {
                var cancellation = SpeechSynthesisCancellationDetails.FromResult(result);
                // newMessage = $"CANCELED:\nReason=[{cancellation.Reason}]\nErrorDetails=[{cancellation.ErrorDetails}]\nDid you update the subscription info?";
            }
        }
    }
    // SpeechSDK 追加分ここまで

    public GameObject quad;

    [System.Serializable]
    public class Analyze
    {
        public Categories[] categories;
        public Color color;
        public Description description;
        public string requestId;
        public Metadata metadata;
    }

    [System.Serializable]
    public class Categories
    {
        public string name;
        public float score;
    }

    [System.Serializable]
    public class Color
    {
        public string dominantColorForeground;
        public string dominantColorBackground;
        public string[] dominantColors;
        public string accentColor;
        public bool isBwImg;
        public bool isBWImg;
    }

    [System.Serializable]
    public class Description
    {
        public string[] tags;
        public Captions[] captions;
    }

    [System.Serializable]
    public class Captions
    {
        public string text;
        public float confidence;
    }

    [System.Serializable]
    public class Metadata
    {
        public int height;
        public int width;
        public string format;
    }

    UnityEngine.Windows.WebCam.PhotoCapture photoCaptureObject = null;
    Texture2D targetTexture = null;

    private string endpoint = "https://<Insert Your Endpoint>/vision/v3.1/analyze";
    private string subscription_key = "<Insert Your API Key>";
    private bool waitingForCapture;

    void Start(){
        waitingForCapture = false;
    }

    public void AirTap()
    {
        if (waitingForCapture) return;
        waitingForCapture = true;

        Resolution cameraResolution = UnityEngine.Windows.WebCam.PhotoCapture.SupportedResolutions.OrderByDescending((res) => res.width * res.height).First();
        targetTexture = new Texture2D(cameraResolution.width, cameraResolution.height);

        // PhotoCapture オブジェクトを作成します
        UnityEngine.Windows.WebCam.PhotoCapture.CreateAsync(false, delegate (UnityEngine.Windows.WebCam.PhotoCapture captureObject) {
            photoCaptureObject = captureObject;
            UnityEngine.Windows.WebCam.CameraParameters cameraParameters = new UnityEngine.Windows.WebCam.CameraParameters();
            cameraParameters.hologramOpacity = 0.0f;
            cameraParameters.cameraResolutionWidth = cameraResolution.width;
            cameraParameters.cameraResolutionHeight = cameraResolution.height;
            cameraParameters.pixelFormat = UnityEngine.Windows.WebCam.CapturePixelFormat.BGRA32;

            // カメラをアクティベートします
            photoCaptureObject.StartPhotoModeAsync(cameraParameters, delegate (UnityEngine.Windows.WebCam.PhotoCapture.PhotoCaptureResult result) {
                // 写真を撮ります
                photoCaptureObject.TakePhotoAsync(OnCapturedPhotoToMemoryAsync);
            });
        });
    }

    async void OnCapturedPhotoToMemoryAsync(UnityEngine.Windows.WebCam.PhotoCapture.PhotoCaptureResult result, UnityEngine.Windows.WebCam.PhotoCaptureFrame photoCaptureFrame)
    {
        // ターゲットテクスチャに RAW 画像データをコピーします
        photoCaptureFrame.UploadImageDataToTexture(targetTexture);
        byte[] bodyData = targetTexture.EncodeToJPG();

        Response response = new Response();

        try
        {
            string query = endpoint + "?visualFeatures=Categories,Description,Color";
            var headers = new Dictionary<string, string>();
            headers.Add("Ocp-Apim-Subscription-Key", subscription_key);
            response = await Rest.PostAsync(query, bodyData, headers, -1, true);
        }
        catch (Exception e)
        {
            photoCaptureObject.StopPhotoModeAsync(OnStoppedPhotoMode);
            return;
        }

        if (!response.Successful)
        {
            photoCaptureObject.StopPhotoModeAsync(OnStoppedPhotoMode);
            return;
        }

        Debug.Log(response.ResponseCode);
        Debug.Log(response.ResponseBody);
        Analyze analyze = JsonUtility.FromJson<Analyze>(response.ResponseBody);
        Debug.Log(analyze.description.captions[0].text);

        // SpeechSDK 追加分ここから
        // 生成された画像説明文をSynthesizeAudioAsyncに投げる
        await SynthesizeAudioAsync(analyze.description.captions[0].text);
        // SpeechSDK 追加分ここまで

        // OpenCVを用いて結果をて画像に書き込み
        Mat imgMat = new Mat(targetTexture.height, targetTexture.width, CvType.CV_8UC4);
        Utils.texture2DToMat(targetTexture, imgMat);
        Debug.Log("imgMat.ToString() " + imgMat.ToString());
        Imgproc.putText(imgMat, analyze.description.captions[0].text, new Point(10, 100), Imgproc.FONT_HERSHEY_SIMPLEX, 4.0, new Scalar(255, 255, 0, 255), 4, Imgproc.LINE_AA, false);
        Texture2D texture = new Texture2D(imgMat.cols(), imgMat.rows(), TextureFormat.RGBA32, false);
        Utils.matToTexture2D(imgMat, texture);
        Renderer quadRenderer = quad.GetComponent<Renderer>() as Renderer;
        quadRenderer.material.SetTexture("_MainTex", texture);

        // カメラを非アクティブにします
        photoCaptureObject.StopPhotoModeAsync(OnStoppedPhotoMode);
    }

    void OnStoppedPhotoMode(UnityEngine.Windows.WebCam.PhotoCapture.PhotoCaptureResult result)
    {
        // photo capture のリソースをシャットダウンします
        photoCaptureObject.Dispose();
        photoCaptureObject = null;
        waitingForCapture = false;
    }
}

5.SpeechConfig.FromSubscriptionにキーと場所をコピペしてください。

6.最初は、synthesizer.SpeakTextAsyncでAudioConfigパラメーターを省略するだけでスピーカーから出力されるので行けると思ったのですが、HoloLensでは出力されませんでした。

static async Task SynthesizeAudioAsync() 
{
    var config = SpeechConfig.FromSubscription("YourSubscriptionKey", "YourServiceRegion");
    using var synthesizer = new SpeechSynthesizer(config);
    await synthesizer.SpeakTextAsync("Synthesizing directly to speaker output.");
}

7.そこで、InternetClientServer と PrivateNetworkClientServer の機能を有効にしたり、MixedRealityToolkitの音声コマンドの設定をしてみたり、

音声認識と文字起こしには Speech SDK を使用するため、Speech SDK の機能の妨げにならないように、MRTK の音声コマンドを構成する必要があります。 これを実現するには、音声コマンドの開始動作を Auto Start から Manual Start に変更することができます。
Hierarchy ウィンドウで MixedRealityToolkit オブジェクトを選択した状態で、Inspector ウィンドウで Input タブを選択し、DefaultHoloLens2InputSystemProfile と DefaultMixedRealitySpeechCommandsProfile を複製し、音声コマンドの Start BehaviorManual Start に変更します。

image.png image.png image.png

ARM64でビルドしたりしたのですが、関係ありませんでした。

8.調べた結果【Unity】Microsoft Azure を用いてキャラクターを流暢に話させる
cognitive-services-speech-sdkのHelloWorld.csを参考に、音声合成結果をAudioSourceのclipに割り当てる必要がありました。

9.あとはTapToCaptureAnalyzeにAudioSourceをAdd Componentし、TapToCaptureAnalyzeAPIのAudioSourceにTapToCaptureAnalyzeをD&Dしてアタッチします。

image.png

実行

実行動画を見てください。エアタップすると画像キャプチャし、画像説明文を生成、読み上げてくれます。

"a hand holding a fanned out money" 札束を持つ手

スタートアップ夢の扉のヌンギルが完成しました!
明日は、弟子(@Horomoto-Asahi)による「MRTK関連で何かを書く」です。

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

CIでC#(Unity)のコードフォーマット・静的コード解析・スクリプトビルドを行う方法

本稿はKLab Engineer Advent Calendar 2020の6日目の記事です。

はじめに

PullRequest時のCI環境上でC#(Unity)のコードフォーマット・静的コード解析・スクリプトビルドを行う仕組みを作ったので、内容を紹介したいと思います。

使用したツール

ツール名 説明
CleanupCode Command-Line Tool Rider(ReShaper)内蔵のコードフォーマッターをコマンドラインで実行できるようにしたツールで、Riderがなくても単独で実行できるようになっているスタンドアロンツールです
InspectCode Command-Line Tool Rider(ReShaper)内蔵の静的コード解析をコマンドラインで実行できるようにしたツールで、Riderがなくても単独で実行できるようになっているスタンドアロンツールです

上記のツールはRider(ReShaper)内蔵のコードフォーマッターと静的コード解析をコマンドラインで実行できるようにしたライセンスフリーのツールです。
このツールを使うだけであればRider(ReShaper)のライセンスは不要なので、
Rider以外のIDEを使用している開発者はコマンドラインツールだけローカル環境にセットアップして使用する事もできます。
Window,Mac,Linuxに対応しているのとコマンドラインツールなので各種IDEとの連携もやりやすいんじゃないでしょうか。

このコマンドラインツールはRider(ReShaper)のバージョンごとに用意されているので、IDEとコマンドラインツールのバージョンは開発者全員で統一するようにして下さい。

Unityの.slnファイル・.csprojファイルを作成する

コマンドラインツールの実行するには.slnファイルと.csprojファイルが必要です。
CI環境ではgithubからソースコードをcloneして利用する事が多いと思いますが、私のプロジェクトではgithubには.slnと.csprojをコミットしていないためCI環境で作成する必要がありました。

Unityの.slnファイルと.csprojファイルはUnityEditorによって自動生成されますが、作成するためのAPI等は提供されていません。
色々調べたところ、UnityEditorのメニューのAssets → Open C# Projectを実行すると.slnファイルと.csprojファイルが作成される事がわかりましたが、同時にUnityEditorに関連付けられたIDEも起動してしまうためCI環境で実行する事を考えるとIDEの起動は避けたい所です。

そこで、Open C# Projectの内部実装を確認するために、UnityEditorのレポジトリを確認してみたところ、以下のような実装になっていました。
※Unity2018.4のコードです

internal class SyncVS : AssetPostprocessor
{
    // -------省略--------
    [MenuItem("Assets/Open C# Project")]
    static void SyncAndOpenSolution()
    {
        SyncSolution();
        OpenProjectFileUnlessInBatchMode();
    }
    // -------省略--------

実装を見た感じSyncVS.SyncSolution();を実行すれば.slnファイルの作成だけを行ってくれそうですが、SyncVSクラスはinternal classなので別アセンブリからは実行できません。
そこで以下のようにリフレクションを使って呼び出すようにしました。

var syncVs = Type.GetType("UnityEditor.SyncVS,UnityEditor");  
var syncSolution = syncVs.GetMethod("SyncSolution", BindingFlags.Public | BindingFlags.Static);  
syncSolution.Invoke(null, null);

上記の処理をUnityのbatchmodeを使ってコマンドラインから実行する事で、CI環境上で.slnファイル・.csprojファイルを作成する事ができるようになりました。

[Tips]
このようなリフレクションを使ったhackを覚えておくと、たまに役に立つ事があるので覚えておくといいと思います。
注意点としては今回のような非公開の関数を呼び出す場合は、Unityのバージョンが変わった際に実装内容が変わって使えなくなる可能性がある事も頭に入れておいて下さい。

コードフォーマット

コードフォーマットの定義ファイルを作成する

Rider(or ReShaper)でコードフォーマットルールが定義された.sln.DotSettingsファイルを作成します。
作成方法は、Riderの設定画面でコードフォーマット設定を行い、Saveボタンの右の▼ボタンから「Solution "xxxxx" team-shared」ボタンを押す事で、プロジェクトルートに.sln.DotSettingsファイルが作成されます。
Solution
参考:.sln.DotSettingsファイルの作り方

コマンドラインでコードフォーマッターを実行する

コマンドラインツールに含まれるcleanupcode.shスクリプトを使用します。
パラメータには.slnファイルと、
--profileパラメータには.sln.DotSettinsファイルの拡張子(.sln.DotSettins)を除いたファイル名を指定して下さい。
外部ライブラリ等をコードフォーマット対象から外したい場合は--excludeパラメーターで除外する事もできます。

./cleanupcode.sh \
--toolset-path=/Library/Frameworks/Mono.framework/Versions/Current/lib/mono/msbuild/15.0/bin/MSBuild.dll \
--exclude=Assets/LineSDK/**/* \
--profile=Sample \  # .sln.DotSettinsファイルの拡張子を除いたファイル名
Sample.sln

※Windows環境の場合はcleanupcode.exeを使用してください

静的コード解析

コマンドラインで静的コード解析ツールを実行する

コマンドラインツールに含まれるinspectcode.shスクリプトを使用します。
パラメータには.slnファイルと.sln.DotSettingsファイル(拡張子を含める)と解析結果を出力するファイル名を指定して実行して下さい。
外部ライブラリ等を対象から外したい場合は--excludeパラメーターで除外する事もできます。

./inspectcode.sh \
--toolset-path=/Library/Frameworks/Mono.framework/Versions/Current/lib/mono/msbuild/15.0/bin/MSBuild.dll \
--exclude=Assets/LineSDK/**/* \
--profile=Sample.sln.DotSettings \
Sample.sln \
-o=report.xml # 解析結果の内容を出力するファイル

※Windows環境の場合はinspectcode.exeを使用してください

解析結果ページの生成

解析結果はxmlで出力されるためHTMLに整形してCI環境上から確認しやすくしました。
以下のコードではxsltprocコマンドを使って、xmlにxslを適用する事で整形したhtmlを出力しています。

# report.xmlにreport.xslを適用してreport.htmlを出力
xsltproc --output report.html report.xsl report.xml

report.xsl

<?xml version="1.0" encoding="UTF-8"?>
<xsl:stylesheet version="1.0"
xmlns:xsl="http://www.w3.org/1999/XSL/Transform">
  <xsl:key name="IssueSearch" match="IssueType" use="@Id"/>
  <xsl:template match="Report">
    <html>
      <head>
        <title>Inspect Code Report</title>
        <style type="text/css">
        table {
            font-size: small;
        }
        table, tr, th, td {
            border-collapse: collapse;
            border: 1px solid #000000;
        }
        th {
            background-color: #3333ff;
            color: #ffffff;
        }
        </style>
      </head>
      <body>
        <h1>Inspect Code Report</h1>
        <xsl:apply-templates select="Issues"/>
      </body>
    </html>
  </xsl:template>
  <xsl:template match="Project">
    <h2><xsl:value-of select="@Name"/></h2>
    <table>
      <tr>
        <th>TypeId</th>
        <th>Severity</th>
        <th>File</th>
        <th>Offset</th>
        <th>Line</th>
        <th>Message</th>
        <th>TypeDescription</th>
      </tr>
      <xsl:apply-templates/>
    </table>
  </xsl:template>
  <xsl:template match="Issue">
    <xsl:variable name="typeId" select="@TypeId" />
    <tr>
      <td><xsl:value-of select="@TypeId"/></td>
      <td><xsl:for-each select="key('IssueSearch', $typeId)" ><xsl:value-of select="@Severity"/></xsl:for-each></td>
      <td><xsl:value-of select="@File"/></td>
      <td><xsl:value-of select="@Offset"/></td>
      <td><xsl:value-of select="@Line"/></td>
      <td><xsl:value-of select="@Message"/></td>
      <td><xsl:for-each select="key('IssueSearch', $typeId)" ><xsl:value-of select="@Description"/></xsl:for-each></td>
    </tr>
  </xsl:template>
</xsl:stylesheet>

スクリプトビルド

UnityEditorを使ったスクリプトビルドは遅いので、xbuildコマンドを使ってUnityEditorを使わずにスクリプトビルドを行うようにしました。
UnityEditorを使ったビルドとは完全に同じ挙動にはなりませんが、目的がスクリプトのチェックだけであれば問題になる事は殆どないでしょう。

/Applications/Unity/Unity.app/Contents/MonoBleedingEdge/bin/xbuild Sample.sln

おわりに

同じような事ができるツールはいくつかあると思うので、皆様の環境にあった方法を選択してもらえばいいかと思います。
今回紹介した内容が、その時の一つの選択肢になれば幸いです。

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