次世代型ゲームエンジン「Xenko」を触ってみた

Xenko-Logo_R_On_White

次世代型ゲームエンジン「Xenko(ゼンコ)」とは、シリコンスタジオによって提供されているクロスプラットフォームゲームエンジンです。Xenkoを利用する開発チームに対して「すべてのゲーム開発者に、より早く・より自由にゲームを作れる環境を提供したい」という想いが軸にあるとのことです。公式ホームページを参照すると2017年7月31日までならばサブスクリプションプランの「Pro」が無料で利用できるので今のうちに試しておくのが良いでしょう。

 

 

1.Xenkoを手に入れる

公式サイトは日本語に対応していませんが、サブスクリプションプラン毎に利用できる機能が記述されている箇所に「Download」ボタンがあるので、お好みのプランを選択してダウンロードしましょう(Pro Plus、Customは問い合わせる必要があるようです)。

Xenkoセットアップ

 

 

2.ランチャーからサンプルプロジェクトを作成

Xenkoのインストールが終わるとランチャーが起動できるので、画面左上にある赤いスタートボタンを押して新規サンプルプロジェクトを作成します。

ランチャー画面

 

 

下図の画面のようにいくつかのサンプルプロジェクトが初めから用意されているため、いくつかのサンプルを見るだけでXenkoでのゲーム開発方法を学ぶことができます。今回は「JumpyJet」というサンプルプロジェクトを例に作り方を見ていきたいと思います。

JumpyJet新規プロジェクト作成

 

 

3.よく使うウィンドウについて

本章ではXenkoでのゲーム開発でよく使うウィンドウの簡単な紹介をします。

 

下図で示した場所に表示されているのは、現在のシーンに配置されているエンティティ一覧になります。アセットの種類によってはダブルクリックによってアセットの編集エディタを立ち上げることもできます。

現在のシーンに配置されているエンティティ一覧

 

 

下図で示した場所に表示されているのは、シーンに配置されているエンティティのプロパティになります。編集したいプロパティを持ったエンティティを選択した時に利用します。

エンティティのプロパティグリッド

 

 

下図のピンクの枠で示したソリューションエクスプローラー上の「Assets」を選択すると、青の枠で示したアセットビューにプロジェクト内で利用できるアセット一覧が表示されます。

Xenkoアセットの場所

 

 

下図のピンクの枠で示したソリューションエクスプローラー上の「JumpyJet.Game」を選択すると、青の枠で示したアセットビューにプロジェクト内で利用できるスクリプト一覧が表示されます。

Xenkoスクリプトの場所

 

 

4.ゲームの起動方法

ゲームを起動するためにはウィンドウ下図のピンクの枠で示した緑色の三角形のボタン(現在のプロジェクトのビルドとゲームの開始)を選択します。ショートカットキーも用意されており、「F5キー」が「 現在のプロジェクトのビルドとゲームの開始」、「F6キー」が「 現在のプロジェクトのビルド」となっています。

ゲームの開始ボタン

 

 

JumpyJetゲーム画面

 

 

5.利用アセットの解説

5.1 UI制御

5.1.1 プロパティを増やす方法

「JumpyJet」ではボタンの制御とスコアの制御にスクリプトを利用しています。シーン上に配置されている「UI」エンティティを選択するとプロパティグリッドに「UI Script」コンポーネントが付けられているのが確認できます。「UI Script」を開いてみると、「public」として「Font」と「UIImages」が宣言されているのでプロパティグリッドで変数の値が編集できるのだと理解できます(Unity脳)。例えば、「public SpriteSheet TestImages;」と記述して保存すると「TestImages」という値をプロパティグリッドより編集できます。ちなみにですが、「[DataMemberIgnore]」を利用するとpublicとして宣言してもプロパティに表示されないようにすることができるようです。

TestImagesを追加した時のプロパティ

 

 

5.1.2 スプライトシートを追加する方法

UIにはスプライトシートが利用されていますが、どのように「Assets」フォルダに追加するか説明します。今回は「JumpyJet」にサンプルが用意されているのでそちらを利用します。下図の「JumpyJet\JumpyJet\Resources\tex1.png」を「Assets」フォルダにドラッグアンドドロップして下さい。

プロジェクトに追加対象のスプライトシート

 

 

ウィンドウが表示されるので「2D sprites」を選択します。これだけでスプライトシートの追加は完了です。

プロジェクトに追加対象のスプライトをドラッグアンドドロップ

 

 

5.1.3 スプライトシートを利用する方法

もちろんですが、5.1.2章で追加したスプライトシートは設定しなければ期待通りに利用することができません。追加したスプライトシートをダブルクリックして開いてみます。下図の画面では追加したはずのテクスチャが表示されていません。

プロジェクトに追加スプライトシートを編集

 

 

下図のピンクの枠で示した箇所に「Sprites」という項目がありますが、ここで1つ1つスプライトを選択して設定する必要があります。やり方としては、すでに追加されている「tex1」を「button」にリネームし、「button」に関連付けたいスプライトを選択します。「+」ボタンを押すことでスプライトを増やせます。

スプライトシートにスプライトbuttonを追加する

 

 

下図はキャラクターを選択している例ですが、スプライトを選択する時は「魔法の杖」を使います。

魔法の杖を選択する

 

 

5.1.4 スライス設定

5.1.3章までの操作により、スクリプトでスプライトシートを利用するための設定が完了しました。さらにスプライトシート上でスライス設定もできるのでボタンスプライトのプロパティを編集してみます。

スライスを設定することでどんなメリットがあるのか下図に例を示します。ボタンに注目するとオリジナルの汚れが引き伸ばされているのと、引き伸ばされていないのが確認できます。このように引き伸ばされたくない部分の設定をするためにスライス設定が利用できます。

ボタンの伸縮させない部分の範囲指定を少しずらして設定した場合 ボタンの伸縮させない部分の範囲指定を期待通りの位置に設定した場合

 

 

スライスの設定をするためには、スプライトのBordersパラメータによって伸ばしたくない範囲を指定することになります。それぞれX(Left)、Y(Top)、Z(Right)、W(Bottom)となっているようです。つまり、Xの値を100に設定すると汚れの部分が伸ばしたくない範囲から外れることになります。

スプライトのスライス設定を編集

 

 

5.1.5 UI Script

「UI Script」の処理の流れとしては、ゲーム起動時に「メイン画面」、「ゲーム画面」、「ゲームオーバ画面」のUIを作成しておきます。画面上に配置されているボタンが押されることで、予め作成しておいた画面UIを切り替える仕組みになっています。「ゲーム画面」にはボタンがありませんが、プレイヤーがゲームオーバになるとゲームオーバイベントが発行されるため、そのイベントを受信したタイミングで「ゲームオーバ画面」に切り替わります。

 

①スプライトシートで設定したスプライトを読み込みます

        // ここでスプライトシートで設定したbuttonを取得しています
        buttonImage = SpriteFromSheet.Create(UIImages, "button");

 

②下記のコードではボタンの設定とボタン押下時の処理が記述されています。Buttonクラスを見ると「Content」、「Padding」、「MinimumWidth」といったパラメータは用意されていないように思えますが、Buttonクラスが継承している「ContentControl」、「Control」、「UIElement」にパラメータが用意されています。全てを把握することは難しいので、やりたい事が出てきた時に調べる程度で良いと思います。

ボタン押下時の処理に「GameGlobals」というクラスが登場していますが、これはスクリプトとして「JumpyJet.Game」に追加されており、内容としては各種イベントの定義やパラメータ定義に利用しているようです。

メイン画面のUIの作成が全て終わった後に、「ModalElement」の「Content」にパーツを代入しています。「ModalElement」によって自身より下の入力(おそらくレイヤー的に)が得られなくなるので、入力制御に利用できそうです。今回は「HorizontalAlignment.Stretch」と「VerticalAlignment.Stretch」を指定しているので、親要素全範囲が埋まることになります。

        private void CreateMainMenuUI()
        {
            var xenkoLogo = new ImageElement { Source = SpriteFromSheet.Create(UIImages, "xk_logo") };

            xenkoLogo.SetCanvasPinOrigin(new Vector3(0.5f, 0.5f, 1f));
            xenkoLogo.SetCanvasRelativeSize(new Vector3(0.75f, 0.5f, 1f));
            xenkoLogo.SetCanvasRelativePosition(new Vector3(0.5f, 0.3f, 1f));

            var startButton = new Button
            {
                Content = new TextBlock {Font = Font, Text = "Touch to Start", TextColor = Color.Black, 
                    HorizontalAlignment = HorizontalAlignment.Center, VerticalAlignment = VerticalAlignment.Center},
                NotPressedImage = buttonImage,
                PressedImage = buttonImage,
                MouseOverImage = buttonImage,
                Padding = new Thickness(77, 30, 25, 30),
                MinimumWidth = 250f,
            };

            startButton.SetCanvasPinOrigin(new Vector3(0.5f, 0.5f, 1f));
            startButton.SetCanvasRelativePosition(new Vector3(0.5f, 0.7f, 0f));
            startButton.Click += (sender, args) =>
            {
                GameGlobals.GameStartedventKey.Broadcast();
                StartGameMode();
            };

            var mainMenuCanvas = new Canvas();
            mainMenuCanvas.Children.Add(xenkoLogo);
            mainMenuCanvas.Children.Add(startButton);

            mainMenuRoot = new ModalElement
            {
                HorizontalAlignment = HorizontalAlignment.Stretch,
                VerticalAlignment = VerticalAlignment.Stretch,
                Content = mainMenuCanvas
            };
        }

 

③Update関数は毎フレーム実行され、ここでは常に「パイプをくぐり抜けたか?」、「ゲームオーバになったか?」という2つのイベントを確認しています。「TryReceive」はバッファから1つのイベントを受信するために利用され、イベントを受信したかどうかについてBOOLが返ります。「TryReceiveAll」というメソッドも用意されており、こちらはキューから全てのイベントを受信するとのこと。

        public override void Update()
        {
            // Increase the score if a new pipe has been passed
            if (pipePassedListener.TryReceive())
                ++currentScore;

            // move to game over UI
            if (gameOverListener.TryReceive())
            {
                currentScore = 0;
                Entity.Get<UIComponent>().Page = new UIPage { RootElement = gameOverRoot };
            }

            // Update the current score
            scoreTextBlock.Text = "Score : {0,2}".ToFormat(currentScore);
        }

 

 

5.2 パイプ制御

パイプとは「Touch to Start」ボタン押下後のゲーム画面で出現する障害物のことです。パイプは一定の間隔で出現しますが高さがランダムで変化します。その他、スクロール処理についても「PipesScript」に全てまとまっているため、このスクリプトを見ればパイプの動作が全て分かります。

 

5.2.1 プレハブ

パイプは「Pipe」というプレハブとしてAssetsに追加されているのでダブルクリックで内容を見ることができます。スクリプト上で利用する場合は上下に「Pipe」を配置しなければならないので少し手間です。従ってスクリプト上では「Pipe」は利用しておらず、予め「Pipe」を2つ利用して上下に配置したプレハブ「Pipe Set」を利用しているようです。また、パイプには「Rigidbody」コンポーネントが付けられていますが、デフォルトでは非表示に設定されており範囲が見えないので、範囲を可視化したい場合は「Grid and gizmo options」より表示設定を変更することができます。

Rigidbodyを可視化する

 

 

5.2.2 PipesScript

①「Pipe Set」プレハブは下記のコードにより画面サイズに合わせて予め複数個作成されます。

        public override void Start()
        {
            var pipeSetPrefab = Content.Load<prefab>("Pipe Set");

            // Create PipeSets
            sceneWidth = GameGlobals.GamePixelToUnitScale*GraphicsDevice.Presenter.BackBuffer.Width;
            Log.Log(new SiliconStudio.Core.Diagnostics.LogMessage("Height", LogMessageType.Info, GraphicsDevice.Presenter.BackBuffer.Height.ToString()));
            var numberOfPipes = (int) Math.Ceiling(sceneWidth + 2* pipeOvervaluedWidth / GapBetweenPipe);
            for (int i = 0; i < numberOfPipes; i++)
            {
                var pipeSet = pipeSetPrefab.Instantiate()[0];
                pipeSets.Add(pipeSet);
                Entity.AddChild(pipeSet);
            }
            
            // Reset the position of the PipeSets
            Reset();
        }

 

 

②ランダムで高さを変更しています。

        private float GetPipeRandoYPosition()
        {
            return GameGlobals.GamePixelToUnitScale * random.Next(50, 225);
        }

 

 

③スクロールが許可されると時間の経過とともにパイプの座標が更新されていきます。

        public override void Update()
        {
            if (gameOverListener.TryReceive())
                isScrolling = false;

            if (gameStartedListener.TryReceive())
                isScrolling = true;

            if(gameResetListener.TryReceive())
                Reset();

            if (!isScrolling)
                return;

            var elapsedTime = (float) Game.UpdateTime.Elapsed.TotalSeconds;

            for (int i = 0; i < pipeSets.Count; i++)
            {
                var pipeSetTransform = pipeSets[i].Transform;

                // update the position of the pipe
                pipeSetTransform.Position -= new Vector3(elapsedTime * GameGlobals.GameSpeed, 0, 0);
                    
                // move the pipe to the end of screen if not visible anymore
                if (pipeSetTransform.Position.X + pipeOvervaluedWidth/2 < -sceneWidth/2)
                {

                    // When a pipe is determined to be reset,
                    // get its next position by adding an offset to the position
                    // of a pipe which index is before itself.
                    var prevPipeSetIndex =  (i + pipeSets.Count - 1) % pipeSets.Count;

                    var nextPosX = pipeSets[prevPipeSetIndex].Transform.Position.X + GapBetweenPipe;
                    pipeSetTransform.Position = new Vector3(nextPosX, GetPipeRandoYPosition(), 0);
                }
            }
        }

 

 

④パイプの作成数を決定する箇所のログを表示してみると下記のようになっていました。

sceneWidth = GameGlobals.GamePixelToUnitScale*GraphicsDevice.Presenter.BackBuffer.Width; // 6.4 = 0.01 * 640
var numberOfPipes = (int) Math.Ceiling(sceneWidth + 2* pipeOvervaluedWidth / GapBetweenPipe); // 7.0 = Math.Ceiling(6.4 + 2 * 0.25)

 

⑤ログの出し方

using SiliconStudio.Core.Diagnostics;
Log.Log(new LogMessage("PipesScript", LogMessageType.Info, numberOfPipes.ToString()));

 

 

5.3 プレイヤー制御

プレイヤーはAssetsに「Character」というプレハブで追加されています。プレイヤーもパイプ同様に1つのスクリプトで動作しているため理解しやすいです。「CharacterScript」は「AsyncScript」として作成されているスクリプトですので、ゲーム起動時に「CountPassedPipes」、「DetectGameOver」というタスクを実行しておき、「Execute」という一度だけ実行される関数内でゲーム状態を判断してタップ判定や座標の計算を行っているようです。

 

5.3.1 CharacterScript

前述の「CountPassedPipes」、「DetectGameOver」というタスクですが、実際には別のスレッド実行されているということではなく、「Script.AddTask」でUnityで言うコルーチンが動作を開始しているとのこと。そして、内部のawaitによって「パイプを通り抜けた」、「地面、パイプに衝突した」といったことを検出するまで待機しています。また、プレイヤーの衝突判定は常に有効となっているため「CountPassedPipes」内の条件をゲーム起動時やゲームリセット時に満たしてしまうという不具合があります(ゲームリセット時にスコアがいきなり1になっていたりもします)。

 

①パイプを通り抜けたことを検出する時には「CustomFilter1」を利用しています。実は「Pipe Set」には「PassedTrigger」というRigidbodyを追加しただけのエンティティが付けられており、このRigidbodyのCollision Groupに「CustomFilter1」が設定されています。

        public async Task CountPassedPipes()
        {
            var physicsComponent = Entity.Components.Get<PhysicsComponent>();
                Log.Log(new LogMessage("PipesScript", LogMessageType.Info, "Before while"));
            while (Game.IsRunning)
            {
                Log.Log(new LogMessage("PipesScript", LogMessageType.Info, "CountPass"));
                var collision = await physicsComponent.CollisionEnded();
                Log.Log(new LogMessage("PipesScript", LogMessageType.Info, "After collision"));

                if(collision.ColliderA.CollisionGroup == CollisionFilterGroups.CustomFilter1 || // use collision group 1 to distinguish pipe passed trigger from other colliders.
                    collision.ColliderB.CollisionGroup == CollisionFilterGroups.CustomFilter1)
                    GameGlobals.PipePassedEventKey.Broadcast();
            }
        }

 

 

②地面、パイプに衝突したことを検出する時には「DefaultFilter」を利用しています。先ほど「Grid and gizmo options」よりコライダーの形状を可視化したはずですが、地面のコライダーには「Infinite Plane」が設定されているため地面の衝突判定の形状は見ることができません(https://doc.xenko.com/latest/manual/physics/colliders.html)。

        public async Task DetectGameOver()
        {
            var physicsComponent = Entity.Components.Get<PhysicsComponent>();

            while (Game.IsRunning)
            {
                await Script.NextFrame();

                // detect collisions with the pipes
                var collision = await physicsComponent.NewCollision();
                if (collision.ColliderA.CollisionGroup == CollisionFilterGroups.DefaultFilter &&
                    collision.ColliderB.CollisionGroup == CollisionFilterGroups.DefaultFilter)
                {
                    isRunning = false;
                    GameGlobals.GameOverEventKey.Broadcast();                    
                }
            }
        }

 

 

5.4 背景制御

JumpyJetの背景は主に「BackgroundSection(背景1枚分の制御)」スクリプトによって制御されています。背景画像をシーン上に描画させているのは「JumpyJetRenderer」の役割になりますが、シーン上を探しても見つけ出すことができません。通常プロジェクトを作成すると自動的に「Assets/GraphicsCompositor」が追加されますが、このスクリプトの「Entry Points」ノード内にて「JumpyJetRenderer」を指定することになっています。

JumpyJetRendererを指定する

 

これにより、スクリプトから「JumpyJetRenderer」を参照することができるようになりました。シーン上に配置されている「BackgroundScript」を開くと最初に「JumpyJetRenderer」を参照しており、その後はイベントの受信により背景スクロールの開始または停止が実行されます。

    public class BackgroundScript : AsyncScript
    {
        private EventReceiver gameOverListener = new EventReceiver(GameGlobals.GameOverEventKey);
        private EventReceiver gameResetListener = new EventReceiver(GameGlobals.GameResetEventKey);

        public override async Task Execute()
        {
            // Find our JumpyJetRenderer to start/stop parallax background
            var renderer = (JumpyJetRenderer)((SceneCameraRenderer)SceneSystem.GraphicsCompositor.Game).Child;

            while (Game.IsRunning)
            {
                await gameOverListener.ReceiveAsync();
                renderer.StopScrolling();

                await gameResetListener.ReceiveAsync();
                renderer.StartScrolling();
            }
        }
    }

 

JumpyJetRenderer」を完全に理解するためにはXenkoでのレンダリングパイプラインについて把握しておく必要があるので、ここでは背景をどのように動作させているかについて簡単に触れることとします。

①「ParallaxBackgrounds」プロパティにスプライトシートが設定されているため、「InitializeCore」関数内でスクロールさせる4枚の背景画像を生成します。

        protected override void InitializeCore()
        {
            base.InitializeCore();

            var virtualResolution = new Vector3(GraphicsDevice.Presenter.BackBuffer.Width, GraphicsDevice.Presenter.BackBuffer.Height, 20f);

            // Create Parallax Background
            backgroundParallax.Add(new BackgroundSection(ParallaxBackgrounds.Sprites[0], virtualResolution, 100 * GameGlobals.GameSpeed / 4f, Pal0Depth));
            backgroundParallax.Add(new BackgroundSection(ParallaxBackgrounds.Sprites[1], virtualResolution, 100 * GameGlobals.GameSpeed / 3f, Pal1Depth));
            backgroundParallax.Add(new BackgroundSection(ParallaxBackgrounds.Sprites[2], virtualResolution, 100 * GameGlobals.GameSpeed / 1.5f, Pal2Depth));

            // For pal3Sprite: Ground, move it downward so that its bottom edge is at the bottom screen.
            var screenHeight = virtualResolution.Y;
            var pal3Height = ParallaxBackgrounds.Sprites[3].SizeInPixels.Y;
            backgroundParallax.Add(new BackgroundSection(ParallaxBackgrounds.Sprites[3], virtualResolution, 100 * GameGlobals.GameSpeed, Pal3Depth, Vector2.UnitY * (screenHeight - pal3Height) / 2));

            // allocate the sprite batch in charge of drawing the backgrounds.
            spriteBatch = new SpriteBatch(GraphicsDevice) { VirtualResolution = virtualResolution };
        }

 

 

②背景のスクロール処理は「DrawCore」関数内で行われており、パイプのスクロール処理同様に時間の経過とともに座標が更新されます。

       protected override void DrawCore(RenderContext context, RenderDrawContext drawContext)
       {
            var renderSystem = context.RenderSystem;

            // Clear
            drawContext.CommandList.Clear(drawContext.CommandList.DepthStencilBuffer, DepthStencilClearOptions.DepthBuffer);

            // Draw parallax background
            spriteBatch.Begin(drawContext.GraphicsContext);

            float elapsedTime = (float)context.Time.Elapsed.TotalSeconds;
            foreach (var pallaraxBackground in backgroundParallax)
                pallaraxBackground.DrawSprite(elapsedTime, spriteBatch);

            spriteBatch.End();

            // Draw [main view | main stage]
            if (OpaqueRenderStage != null)
                renderSystem.Draw(drawContext, context.RenderView, OpaqueRenderStage);

            // Draw [main view | transparent stage]
            if (TransparentRenderStage != null)
                renderSystem.Draw(drawContext, context.RenderView, TransparentRenderStage);
        }

 

 

6.まとめ

今回は、Xenkoを利用して作られたサンプルプロジェクト「JumpyJet」について見てみました。スクリプトがC#で書かれているためUnityと比較してしまいますが、サンプル内でも継承をうまく使ったテクニックが多く、まだまだ情報が少ないため初心者が学ぶには少し難易度が高いと感じました。エディタをXenko内で実行できる点や、シーン上で複数のアセット、スクリプトタブを複数開いても強制的にまとめられず、2行目に表示されていた点は便利だと感じられました。

3Dに関して触れませんでしたが、Xenkoはグラフィック方面に力を入れているようですので試してみてはいかがでしょうか?今後は、「レンダリングパイプラインエディター」や「ビジュアルスクリプトシステム」も追加されるようですので、ますます使い勝手がよくなっていく可能性があります。

モバイルへのデプロイについてもサポート自体はされていますが、サンプルプロジェクトでビルドエラーが出てしまったりとまだ問題がありそうでした(要調査)。