Galaxy Noteでのみ、しかもタッチペンを使ったときのみ描けなくなるというバグ

私のリリースしているお絵かきアプリのユーザーさんから、タッチペンを使うときだけ絵が描けないという報告を受けました。調べていくと、奇妙な現象でして、

  • 指では描ける
  • タッチペンを使った場合でもボタン等は反応する
  • 自分の手元にあるiPadや他のタブレットでは問題ない

調べてみると、以下のような投稿をForumで見かけました。

https://forum.unity.com/threads/android-pen-not-working.766508/?fbclid=IwAR2on-iHh1uE4cLJLd9Q1QKJfkXfDjCCDRP1arC3r__geryCtpbCVhw9UiE

どうもGalaxy Note特有の現象なようです。

Unity側で対処すべき問題なのかGalaxy Note側の問題なのかは分からないのですが、とにかく不具合なことは確かです。

issuetracker.unity3d.com


こちらを見ると1月中旬に修正が入ったそうですが、自分の使っているUnity2018.4.15fでビルドしたバージョンでは未だに発生するようでした。

仕方がないので先のフォーラムに書いてあったワークアラウンドを試してみたところ解決できました。

ワークアラウンドというのは見えないUIレイヤーを一つ挟んでそこでタッチとタッチポジションを検知するというもの。

ざっくりこういう感じのコンポーネントを作って、このisPointerDownやpointerPositionをInput.GetMouseButton( 0 )やInput.mousePositionの代わりに使うことで解決できました。

public class UILayer : IPointerDownHandler, IPointerUpHandler, IDragHandler
{
    public bool isPointerDown;
    public Vector2 pointerPosition;

    //------------------------------------------------------------------------------
    public void OnPointerDown(PointerEventData pointerEventData)
    {
        isPointerDown = true;
        pointerPosition = pointerEventData.position;
    }

    //------------------------------------------------------------------------------
    public void OnPointerUp(PointerEventData pointerEventData)
    {
        isPointerDown = false;
    }

    //------------------------------------------------------------------------------
    public void OnDrag(PointerEventData pointerEventData)
    {
        if( isPointerDown )
        {
            pointerPosition = pointerEventData.position;
        }
    }
}

GoogleSpreadSheetからデータを取ってきてScriptableObjectに値を突っ込む

基本的にはこちらを参考にしてます
7081.hatenablog.com

まずは上記のサイトを参考にしつつGoogle API Consoleでの設定を行います。

次に認証コードからアクセストークンとリフレッシュトークンを取得します。

WWWForm form = new WWWForm();
if (!form.headers.ContainsKey("Content-Type"))
{
    form.headers.Add("Content-Type", "application/x-www-form-urlencoded");
}
else
{
    form.headers["Content-Type"] = "application/x-www-form-urlencoded";
}
form.AddField("code", "OAuthの認証コード");
form.AddField("client_id", "OAuthのクライアントID");
form.AddField("client_secret", "クライアントシークレット");
form.AddField("redirect_uri", "http://localhost");
form.AddField("grant_type", "authorization_code");
form.AddField("access_type", "offline");
Dictionary<string, string> headers = form.headers;
byte[] rawData = form.data;
WWW www = new WWW("https://www.googleapis.com/oauth2/v4/token", rawData, headers);
while (!www.isDone) ;
Debug.Log("GetAccessJsonData, www.text : " + www.text);

アクセストークンさえ手に入れば、

//とってくるセルの範囲
string start_cell = "A1";
string end_cell = "Z100";//範囲は大きめにとっておいても大丈夫

string send_text = "https://sheets.googleapis.com/v4/spreadsheets/スプレッドシートのID/values/"
    + start_cell + ":" + end_cell + "?access_token=" + access_token;

WWW www = new WWW(send_text);
while (!www.isDone) ;

Debug.Log( "json data : " + www.text );

っという感じで、セルのデータをjsonデータとしてとってこれます。

ちなみにここで「スプレッドシートのID」とは、スプレッドシートのページのURLが

https://docs.google.com/spreadsheets/d/ほげほげ/edit#gid=0

だとしたら、この「ほげほげ」の部分です。

アクセストークンは寿命があるので、PlayerPrefsに覚えておいて、使えなくなったらまた発行してもらうっていうふうにしてみます。

//アクセストークンを取得
//覚えていたら、それを返す。
//それが無効になってたらリフレッシュして再取得したものを返す
public static string getAccessToken()
{
    const string KEY_ACCESS_TOKEN = "KEY_ACCESS_TOKEN";

    string access_token = PlayerPrefs.GetString( KEY_ACCESS_TOKEN, "" );
    //アクセストークンがまだ有効かどうか調べる
    string access_token_status = CheckAccessTokenStatus( (string)access_token );
    if( access_token_status == "Invalid Value" || access_token == "" )
    {
        //アクセストークンが無効だったので取り直す
        Debug.Log( "Refresh Access Token!!!" );
        access_token = RefreshAccessToken();
    }

    //アクセストークンを覚えておく
    Debug.Log( "access_token : " + access_token );
    PlayerPrefs.SetString( KEY_ACCESS_TOKEN, access_token );

    return access_token;
}

//Access Tokenが有効かどうかチェック
static string CheckAccessTokenStatus( string access_token )
{
    WWW www = new WWW("https://www.googleapis.com/oauth2/v3/tokeninfo?access_token=" + access_token );
    while (!www.isDone) ;

    JsonData jsonData = JsonMapper.ToObject(www.text);
    IDictionary idict = jsonData;
    if( idict.Contains("error_description") )
        return (string)jsonData["error_description"];
    else
        return "";
}

static string RefreshAccessToken()
{
    WWWForm form = new WWWForm();
    form.AddField("refresh_token", "リフレッシュトークン" );
    form.AddField("client_id", "クライアントID");
    form.AddField("client_secret", "クライアントシークレット");
    form.AddField("grant_type", "refresh_token");

    Dictionary<string, string> headers = form.headers;
    byte[] rawData = form.data;
    WWW www = new WWW("https://www.googleapis.com/oauth2/v4/token", rawData);

    while (!www.isDone) ;

    JsonData jsonData = JsonMapper.ToObject(www.text);
    return (string)jsonData["access_token"];
}

ちなみにJsonのパースにはLitJsonを使っています。
GitHub - Mervill/UnityLitJson: JSON library for Unity3D

今回はゲーム内テキストの日本文と英語文をMessageDatabaseというScriptableObjectに持たせることを想定し、そこにデータをぶち込もうと思います。

[System.Serializable]
public class TranslateText {
    public string key;
    public string jp;
    public string eng;
    public TranslateText( string key_, string jp_, string eng_ )
    {
        key = key_;
        jp  = jp_;
        eng = eng_;
    }
}

[CreateAssetMenu(menuName = "CreateMessageDatabase")]
public class MessageDatabase : ScriptableObject {
    public TranslateText[] translateTexts = new TranslateText[0];

#if UNITY_EDITOR
    //google spreadsheet から読み込んで登録
    public void RegisterData( string[][] data, int num_data )
    {
        translateTexts = new TranslateText[num_data];
        for( int i = 0; i < num_data; ++i )
        {
            string key = data[i][0];
            string jp  = data[i][1];
            string eng = data[i][2];
            translateTexts[i] = new TranslateText( key, jp, eng );
        }
    }
#endif
}


データをぶち込んでいるところも含めて、全体は以下のような感じ。

#if UNITY_EDITOR
using System;
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using UnityEditor;
using LitJson;

public class SpreadsheetDataLoader
{
    private const int MAX_COLUMN = 5;//多めに取る。足りなかったら増やす
    private const int MAX_ROW = 1000;//多めに取る。足りなかったら増やす。

    [System.Serializable]
    public class SpreadsheetData
    {
        public string range;
        public string majorDimension;
        public SpreadsheetData()
        {
            for (int i = 0; i < values.Length; ++i)
            {
                values[i] = new string[MAX_COLUMN];
            }
        }

        //多めにとっててもToObjectするときにshrinkされるっぽい
        public string[][] values = new string[MAX_ROW][];

        public int NumColumns { get { return values.Length; } }
    }

    [MenuItem("Assets/Load Spreadsheet")]
    static void LoadSpreadsheet()
    {
        string access_token = getAccessToken();

        //とってくるセルの範囲
        string start_cell = "A1";
        string end_cell = "Z100";//範囲は大きめにとっておいても大丈夫

        string send_text = "https://sheets.googleapis.com/v4/spreadsheets/スプレッドシートのID/values/"
            + start_cell + ":" + end_cell + "?access_token=" + access_token;

        WWW www = new WWW(send_text);
        while (!www.isDone) ;

        SpreadsheetData spreadsheet = null;
        spreadsheet = JsonMapper.ToObject<SpreadsheetData>(www.text);

        int num_columns = spreadsheet.NumColumns;
        //Scriptable Objectのインスタンスを作る
        MessageDatabase messages = AssetDatabase.LoadAssetAtPath<MessageDatabase>("Assets/MessageDatabase.asset");
        messages.RegisterData(spreadsheet.values, num_columns);//データを登録

        //セーブ
        UnityEditor.EditorUtility.SetDirty(messages);
        UnityEditor.AssetDatabase.SaveAssets();
    }

    //アクセストークンを取得
    //覚えていたら、それを返す。
    //それが無効になってたらリフレッシュして再取得したものを返す
    public static string getAccessToken()
    {
        const string KEY_ACCESS_TOKEN = "KEY_ACCESS_TOKEN";

        string access_token = PlayerPrefs.GetString( KEY_ACCESS_TOKEN, "" );
        //アクセストークンがまだ有効かどうか調べる
        string access_token_status = CheckAccessTokenStatus( (string)access_token );
        if( access_token_status == "Invalid Value" || access_token == "" )
        {
            //アクセストークンが無効だったので取り直す
            Debug.Log( "Refresh Access Token!!!" );
            access_token = RefreshAccessToken();
        }

        //アクセストークンを覚えておく
        Debug.Log( "access_token : " + access_token );
        PlayerPrefs.SetString( KEY_ACCESS_TOKEN, access_token );

        return access_token;
    }

    //Access Tokenが有効かどうかチェック
    static string CheckAccessTokenStatus( string access_token )
    {
        WWW www = new WWW("https://www.googleapis.com/oauth2/v3/tokeninfo?access_token=" + access_token );
        while (!www.isDone) ;

        JsonData jsonData = JsonMapper.ToObject(www.text);
        IDictionary idict = jsonData;
        if( idict.Contains("error_description") )
            return (string)jsonData["error_description"];
        else
            return "";
    }

    static string RefreshAccessToken()
    {
        WWWForm form = new WWWForm();
        form.AddField("refresh_token", "リフレッシュトークン" );
        form.AddField("client_id", "クライアントID");
        form.AddField("client_secret", "クライアントシークレット");
        form.AddField("grant_type", "refresh_token");

        Dictionary<string, string> headers = form.headers;
        byte[] rawData = form.data;
        WWW www = new WWW("https://www.googleapis.com/oauth2/v4/token", rawData);

        while (!www.isDone) ;

        JsonData jsonData = JsonMapper.ToObject(www.text);
        return (string)jsonData["access_token"];
    }

    //一番最初にRefleshTokenとかを調べるときに使う
    [MenuItem("Assets/GetRefleshToken")]
    static void GetRefleshToken()
    {
        WWWForm form = new WWWForm();
        if (!form.headers.ContainsKey("Content-Type"))
        {
            form.headers.Add("Content-Type", "application/x-www-form-urlencoded");
        }
        else
        {
            form.headers["Content-Type"] = "application/x-www-form-urlencoded";
        }
        form.AddField("code", "OAuthの認証コード");
        form.AddField("client_id", "OAuthのクライアントID");
        form.AddField("client_secret", "クライアントシークレット");
        form.AddField("redirect_uri", "http://localhost");
        form.AddField("grant_type", "authorization_code");
        form.AddField("access_type", "offline");
        Dictionary<string, string> headers = form.headers;
        byte[] rawData = form.data;
        WWW www = new WWW("https://www.googleapis.com/oauth2/v4/token", rawData, headers);
        while (!www.isDone) ;
        Debug.Log("GetAccessJsonData, www.text : " + www.text);
    }
}
#endif

これで、
f:id:wkpn:20191025224641p:plain
こーいうデータから
f:id:wkpn:20191025224707p:plain
こんな感じのScriptableObjectにできます。

自分のゲームでは、例えばゲーム内ショップのラインナップとか、スコア計算用の係数郡とかをGoogleSpreadSheetで管理して、それをScriptableObjectに流し込んで使っています。

Google Play Service系のプラグインを更新したら ClassNotFoundException: Didn't find class “android.support.v4.content.FileProvider”というエラーがでてハングした

PlayServiceResolverでプラグインを最新にしたら、アプリがクラッシュするようになっちゃいました。
どうもSocialConnector関係のところで止まっているようでした。

たぶん、android.support.v4.aarとかが無くなったからのようです。

最新では、androidx.core.content.FileProviderが代わりのものっぽいので、AndroidManifest.xml内の

android:name="android.support.v4.content.FileProvider"

ってところを

android:name="androidx.core.content.FileProvider"

こんな感じに修正して、

Asset/SocialConnector/SocialConnector.cs内の中の51行目

var fileProvider = new AndroidJavaClass("android.support.v4.content.FileProvider");

var fileProvider = new AndroidJavaClass("androidx.core.content.FileProvider");

と修正したら動くようになりました。

ビッグローブ光にて、何故かMacでだけIPv6接続できなかった。

うちのネットワーク環境、ビッグローブ光でiPv6対応したのに、なぜかIPv4でしかアクセスできない状態だった。
ちなみに、所有機Macbook Pro

この問題、ずーっと放置していたのだけど、今日Windows機を導入したので試してみたらIPv6でアクセスできた。
なんでMacIPv6接続できずWindowsでだけ接続できるんやー!っと思ったので、重い腰を上げてビッグローブに問い合わせてみた。

しかし、分からないのでAppleに聞いてくれと言われてしまう。オーマイガッ。

Appleに聞くのも腰が重いなぁと思って、プチプチっとネットワーク設定をいじっていたら、意外と簡単にIPv6アクセスできるようになった。

System Preference -> Network -> WiFi -> Advanced -> TCP/IP

Configure IPv6でLink-local onlyにしてたのをAutomaticallyにするだけだった。

ちなみにIPv6になって爆速になったかと言われるとそうでもなかった。

UnityのRandom.Range(min,max)はfloatとintで挙動が違う

Random.Range(min,max)は、引数がfloatの場合、maxはinclusiveになりますが、intの場合はmaxがexclusiveになります。

つまり、Random.Range(0.0f,10.0f)の場合、10.0fが返ってくるときがありますが、Random.Range(0,10)では10は返ってきません。最大でも9です。

なんでやねん!

知ったとき驚いたんですが、プログラミング界では普通なんですかね?

参考:
docs.unity3d.com

アプリ内課金実装周りで躓いたこと

アプリ内課金の実装にはUnityIAPを使用しています。
実装方法は、詳しい記事が色々とあるので、そちらをご参考下さい。
参考までに私が参考にした記事を1つ挙げておきます。

techtechnolog.com

ただ、こちらの参考サイトでStart()で呼んでいるものをAwakeにしたほうが良いと思います。
理由は下記に。

UnityIAP初期化されない時がある問題

UnityIAPが、なぜか初期化されないことがありました。かと言って初期化失敗のコールバックが呼ばれているわけでもない。

色々とググッていたら、Startに書いている初期化をAwakeにしてみろ的なコメントを見つけました。
試してみたところ、それでうまくいきました。

Android版で課金しようとすると「認証が必要です。Googleのアカウントにログインしてください」っと出る。

英語版だと「Authentication is required. You need to sign into your Google Account.」
これ、色々と発生する可能性がありまして、ググると、

Google Developer Consoleにアップした直後だと駄目だから、ちょっと待て

とか

新しく追加したアカウントの場合は、端末を初期化しないと駄目だ

とか、

書かれています。

しかし、自分は、そのどれにも当てはまらず。

Google Developer Consoleではchatで相談に乗ってくれるので、質問してみました。

そうすると直ぐに解決。
購入商品が「有効」になっていなかったから
でした。

Google Developer Consoleの、「ストアでの表示」->「アプリ内サービス」にあります。
f:id:wkpn:20170518143706p:plain
このスクショの右の方のやつですね。

こんなんに数時間ハマってしまった。

UnityでつくったアプリをXCodeのSimulatorで実行する方法

Unityでつくったアプリは、そういえばいっつも実機で確認しててSimulatorで実行したことがなかったです。

ふとシミュレーターで確認したくなったんですが、XCode上でシミュレーターを選ぶところもないし、どうやんねん!って思っていました。

調べてみると、

Player Settings -> Other Settings

にある「Target SDK」を"Device SDK"から"Simulator SDK"にすることでシミュレーターで実行できるようになることがわかりました。

f:id:wkpn:20180601170225p:plain

ビルドすると以下のようにXCode上にシミュレーションするデバイスのリストが表示されるようになります。

f:id:wkpn:20180601170311p:plain