CocosSharp migration from 1.6 to 1.7

  • 8
    Like
  • 0
    Comment

2016/12/21 追記 イベントが動かなくなる件が解決しました!!


初のAdvent Calendar!
なんとか間に合ったー

事の始まり

私が現在参加しているベンチャー企業では、Xamarinを使ったAndroid/iPhone向けのマルチプラットフォームアプリを作成しています。
その中のコンテンツの一つとしてCocosSharpを使ったゲームがあり、Androidではほぼ完成していました。
ところでiOS向けにビルドしたところ、ゲーム終了時にXamarinForms側に戻ってこれないという事象が発生しました。
 ※CCApplication.ExitGameすると画面が真っ暗になって一切の操作を受け付けない状態に
そこでTwitterで助けを求めたところCocosSharp第一人者の@hiro128_777さんからアドバイスを頂き、CocosSharpの1.6系から1.7系へと移行する果てしない旅が始まったのです。

その時の魂の叫びと天の助け。
https://twitter.com/ShikaTech/status/807209104917680128

いきなりのゴメンナサイ

12/16時点でプロダクトコードのマイグレーションは未完了であり、毎日ズブズブと沼にハマっていく有様です。マジ溺れ死ぬ。
本記事ではサンプルコードをネタに1.6から1.7への移行を促しつつ、私が直面した問題を赤裸々に公開したいと思います。

1.6 to 1.7

大前提として、PCL/Android/iOSの組み合わせでプロジェクトを作成し、CocosSharp.PCL 1.6がインストールされているものとします。
また、1.6およびXamarinFormsでの基本的な開発経験があるものとします。
CocosSharpの公式サンプルはこちら

前準備

Nugetより、CocosSharp.Formsをインストールしてください。
その過程でCocosSharp(無印)がインストールされます。
さらにその過程で、CocosSharp.PCLがパッケージから消えてなくなるはずです。
 ※ただしNugetパッケージマネージャで見ると居る…とりあえず動けばいいので見て見ぬふり。。
また、1.7のGetting Startページも一通り目を通しておくと理解が深まります。
Using CocosSharp in Xamarin.Forms

image

起動方法を変更

1.6系ではCCApplication,CCApplicationDelegateあたりを使っていたと思いますが、これらはなくなりました。
 ※IGameは起動終了用のインタフェースと読み替えてください

旧コード

iOS側開始終了コード
    public class Game : IGame
    {
        public string Device { get; set; } = "iOS";

        private CCApplication GameApplication { get; set; }
        public void Start(object sender)
        {
            GameApplication = new CCApplication();
            GameApplication.ApplicationDelegate = new GameDelegate();
            GameApplication.StartGame();
        }
        public void End(object sender)
        {
            if (GameApplication != null)
            {
                GameApplication.ExitGame();
            }
        }
    }
Android側開始終了コード
    public class GameService : IGame
    {
        public string Device { get; set; } = "Droid";

        public void End(object sender)
        {
            var intent = new Intent(Forms.Context, typeof(MainActivity));
            intent.AddFlags(ActivityFlags.ClearTop | ActivityFlags.PreviousIsTop);
            Forms.Context.StartActivity(intent);
        }

        public void Start(object sender)
        {
            var intent = new Intent(Forms.Context, typeof(GameActivity));
            intent.AddFlags(
                ActivityFlags.NewTask               // Activity以外からActivityを呼び出すのに必須
                | ActivityFlags.NoHistory           // スタックに登録しない
                | ActivityFlags.ExcludeFromRecents  // 最近使ったアプリの一覧に表示しない
            );
            Forms.Context.StartActivity(intent);
        }
    }
Android側GameActivityクラス
    public class GameActivity : AndroidGameActivity
    {
        private CCApplication GmaeApplication { get; set; }

        protected override void OnCreate(Bundle bundle)
        {
            base.OnCreate(bundle);

            GmaeApplication = new CCApplication();
            GmaeApplication.ApplicationDelegate = new GameDelegate();
            SetContentView(GmaeApplication.AndroidContentView);
            GmaeApplication.StartGame();
        }
    }
GameDelegate.cs
    public class GameDelegate : CCApplicationDelegate
    {
        public static CCWindow SharedWindow { get; set; }

        public override void ApplicationDidFinishLaunching(CCApplication application, CCWindow mainWindow)
        {
            application.ContentRootDirectory = "Content";
            application.ContentSearchPaths.Add("images");

            //ゲームの起点画面に遷移
            var scene = Layers.GameStartLayer.GetScene(mainWindow);
            mainWindow.RunWithScene(scene);
        }
    }

新コード

Xamarin.Formsの1ページとして作ったXamlページにCocosSharpViewを配置するイメージ

BattlePage.xaml.cs
    public partial class BattlePage : ContentPage
    {
        public BattlePage()
        {
            InitializeComponent();

            var gameView = new CocosSharpView()
            {
                HorizontalOptions = LayoutOptions.FillAndExpand,
                VerticalOptions = LayoutOptions.FillAndExpand,
                ViewCreated = (object sender, EventArgs e) =>
                {
                    var gv = sender as CCGameView;
                    //Helpersはアプリ内でGlobalに使いまわす自作ヘルパ
                    gv.DesignResolution = new CCSizeI(Helpers.Settings.AppWidth, Helpers.Settings.AppHeight);
                    gv.ResolutionPolicy = CCViewResolutionPolicy.ShowAll;       
                    gv.RunWithScene(GameStartLayer.GetScene(gv));
                }
            };

            this.Content = gameView;
        }

        protected override bool OnBackButtonPressed()
        {
            //戻るボタン無効化するならfalse
            return base.OnBackButtonPressed();
        }
    }

CCApplicationは死んだ!CCGameViewだ!!

起動方法の変更にある通り、CCApplicationは使わなくなりました。
これは何も考えずに消してOKなので、ガツッとSearch & Destroyしちゃいましょう!

また、各Layerで引数に取るCCWindowがCCGameViewに変更となりますので、こっちもガツッと置換してください。
 GetScene(CCWindow mainWindowGetScene(CCGameView mainWindow
無意識的に使っているであろうプロパティも置換対象です
 .GetScene(Window.GetScene(GameView

困るぜ!

ここまでで、ひとまずビルドは通るようになったかと思います!
…が、そうは問屋が卸しません。。

サイズがおかしくなったぜ!

1.6のCocosSharpでは特に何も考えなくても、AddedToSceneが終わったタイミングのVisibleBoundsWorldspace.Sizeで表示領域のサイズが取れていました。
例えば開発機であるXperiaZUltraであれば 1080x1824 としていい感じに使えます。
これが1.7だとデフォルトで 640x480 になってしまうようです。
仕方ないので以下のコードのようにアプリ起動時に必ず通るページでサイズを取るように変えたところ、540x887 が取れました。
Xamarin上のサイズとCocosSharp上のサイズでは何か異なってしまうようです。
見た感じ、ちょうど半分になっていたのでその辺りにカラクリが…?

StartUpPage.xaml.cs
    public partial class StartUpPage : ContentPage
    {
        public StartUpPage()
        {
            InitializeComponent();
        }

        protected override void OnSizeAllocated(double width, double height)
        {
            base.OnSizeAllocated(width, height);
            Helpers.Settings.AppWidth = (int)width;
            Helpers.Settings.AppHeight = (int)height;
        }
    }

非同期で動かなくなったぜ!

1.6実装時には、あちこちでAsync処理を書いていました。
例えば、各Layerの親となる基底クラスを作り、Baseでヘッダー・フッターのコンテンツ表示処理。
各Layer独自の表示コンテンツはabstractメソッドをAsync呼び出しする形で実装していました。(下記参照)
それらは、動かなくなりました。動かなくなりました!!
 ※根本的に問題のあるコードだったのかもしれませんが、1.7に移行する前は動いていたモノです。

↓こんな感じ。どっかでデッドロックしてたんだろうなぁ…

BaseLayer.cs
    public abstract class BaseLayer : CCLayerColor
    {
        protected override async void AddedToScene()
        {
            base.AddedToScene();

            await Task.WhenAll(InitCommonContentsAsync(), InitLayersContentsAsync());
        }

        private Task InitCommonContentsAsync()
        {
            var task1 = LoadSpriteAsync();

            var task2 = CreateHeaderAsync().ContinueWith(async t => DisplayHeader = await t);
            var task3 = CreateFooterAsync().ContinueWith(async t => DisplayFooter = await t);

            var task4 = new Task(() =>
            {
                var height = DisplayHeight;
                height -= DisplayHeader;
                height -= DisplayFooter;
                CenteringPosition = new CCPoint(DisplayCenter.X, DisplayFooter + height / 2);
            });

            return Task.WhenAll(task1, task2, task3).ContinueWith(t => task4);
        }

        protected abstract Task InitLayersContentsAsync();
    }

仕方ないので一旦Async処理を諦め、全て同期コードに書き換えたことで動くようにはなりました。
また、AzureMobileServiceを使っている部分でAsync呼び出ししかサポートしていない場所は、ラッパーを噛ませて同期処理にしています。
同期版のメソッドを実装する

上記以外にも、CCSpriteを作る過程でCCTexture2Dのインスタンスを作る部分があったんですが、非同期コードだとそこで止まっちゃうこともありました。
CCTexture2Dのコンストラクタで何やってるかまでは追ってないんですが、闇が深いようです…

なんだかStaticなCCSpriteが壊れるぜ!

元々、ゲーム内で使う汎用的なCCSpriteインスタンスは読み込みごとにStaticなメンバに保持し、使い回しをしていました。

CommonImages.cs
    public class CommonImages
    {
        private static Dictionary<string, CCSprite> _CommonImagesCache;
        public Dictionary<string, CCSprite> CommonImagesCache => _CommonImagesCache ?? (_CommonImagesCache = new Dictionary<string, CCSprite>());

        public CCSprite Get(Enum key)
        {
            if (CommonImagesCache == null) throw new InvalidOperationException();
            if (!CommonImagesCache.ContainsKey(key.ToKey()))
                //GameImageLoader.GetSpriteはAssemblyからpng読み出してCCSpriteインスタンスを作り返すメソッド
                CommonImagesCache.Add(key.ToKey(), GameImageLoader.GetSprite(key.ToName()));

            return CommonImagesCache[key.ToKey()];
        }
    }

これでゲーム内での使い回しはいい感じに動くんですが、Androidの戻るボタンやゲーム終了処理などをしてから再度ゲーム画面を開くと、壊れます。
 ※Staticで使いまわすのがベストプラクティスなのか、バッドノウハウなのかはこの際ご勘弁を。。
壊れ方がまた面白いんですが、CCSpriteの配置エリアに全然関係ないCCLabelに設定したTextが白抜きで間延びして入ったりします。
 ※プロダクトコードのスクショを見せられないのが実に惜しい可笑しさです
 
で回避作としてゲーム終了時にStaticメンバへNull代入することで二度目の起動時に正しい動作となりました。いいのか?

IRelease
    public void Release()
    {
        _CommonImagesCache = null;
    }

イベントが効かなくなったぜ!

これまた不思議な動きですが、基底クラスに仕込んだTouchイベントが突然動かなくなります。困ります。

BaseLayer.cs
    public abstract class BaseLayer : CCLayerColor
    {
        protected BaseLayer()
        {
            Color = new CCColor3B(236, 236, 236);
            Opacity = 255;

            var touchListener = new CCEventListenerTouchAllAtOnce { OnTouchesEnded = Touch };
            AddEventListener(touchListener, this);
        }

        protected abstract void Touch(List<CCTouch> touches, CCEvent ccevent);
    }

これ↑で各Layerでのタップ処理を実装していたんですが、数画面遷移すると突然Touchが反応しなくなります。
もちろん上記コードのAddEventListenerが通っていることは確認済み。
現時点で絶賛ハマり中。解決しねぇぇぇぇぇぇ

--- 2016/12/21 追記 ここから ---
どうやら元々の処理にバグが有り、マイグレーションを行ったことで顕著化したようです。
ReplaceSceneメソッドをScheduleメソッドに仕込み、タイムオーバーで自動的に画面遷移する機構を作っていました。
Touchイベントを拾うメソッドが画面遷移の刹那のタイミングで動いたときに、本来入るべきフローではないためNullReが発生し、それが原因で以降のTouchイベントが拾われなくなってしまったようです。
 ※正誤問題系の画面で、誤タップは残時間減という仕様のため、早く画面遷移させるために連打していたことで発現
NullReExcepとかUnHandledExcepとか出してくれれば分かりやすいんですが、見た目上は何も出ないんですよね…。
関連するかどうかわからなかったんですが、デバッグログを見ると以下のエラーが出ており、ネットの海によるとUIスレッドと非同期処理と画面描写と~という情報もありました。
 ※画面遷移が行われている途中でTouch処理がされて、その中でエラーが起きたから画面描写関連のエラーが発生した…?
  にしては画面遷移はちゃんと行われていたんだけど…

出力-デバッグ
12-21 18:52:09.413 W/Adreno-EGL(19173): <qeglDrvAPI_eglMakeCurrent:3037>: EGL_BAD_ACCESS
12-21 18:52:09.413 E/libEGL  (19173): eglMakeCurrent:792 error 3002 (EGL_BAD_ACCESS)

いずれにせよ、一旦イベント動かない問題は解決したので先に進む~んだ!
--- 2016/12/21 追記 ここまで ---

最後に

これから始める人は、問答無用で1.7にしましょう。
1.6時代のCocosSharp公式サンプルは、アプリの起動=ゲームの起動ゲームの終了=アプリの終了という前提のようです。
私のようにXamarinFormsと組み合わせてコンテンツの一つとしてゲームを入れ込もうと思うと…(iOS向けには)できません。。
Androidのみであればゲームの終了でActivity殺してIntent発行すればそれっぽく動くので、できないことはないでしょう。
 ※が、それってXamarin使った意味あんのって話。

それでは年末まで引き続き楽しいプログラミングライフを!
クリスマスとか関係ないしね