글쓴이 보관물: Toughman

투호 게임

개요

이전에 Unity 로 제작했었던 동전 던지기 게임을 응용해서 투호 게임을 만들어보면 좋겠다는 생각이 들어 진행하게 되었습니다.

게임 엔진 선택

Unreal 엔진으로 이전할 생각을 가지고 있었지만 새롭게 공부해야 하는 부담이 있었습니다. Unity 엔진으로 제작하면 시간이 단축될 것 같았지만 나중을 생각하면 Unreal 엔진으로 제작하는 것이 합리적이라는 판단이 들었습니다.

Unity 엔진에서 Unreal 엔진으로

Unreal 엔진을 설치해 보니 개인적으로 Unity 엔진보다는 무겁다는 생각이 들었습니다. Unreal 엔진에 대한 지식이 없어 일단 Unity와 비교해서 공부를 시작했습니다.

두 엔진을 비교한 자료 위주로 보았습니다. Unity 엔진에서 사용하는 개체(Prefab…)에 대해서 대응되는 Unreal 엔진의 개체에 대한 설명이 전체적인 개념을 이해하는데 많은 도움이 되었습니다.

투호 게임 화면

개발 과정

Unreal 엔진을 많이 다루어 보면서 익숙해 지려고 노력했습니다. 프로그래밍을 주로 하는 필자는 노드를 이어서 로직을 만드는 Blueprint 가 쉽게 와 닿지 않았습니다.

동영상 강좌도 보고 실습을 해 보면서 간단한 것은 C++ 코드 없이 구현할 수 있겠다는 생각이 들었습니다. 하지만 결국 모두 C++ 로 하거나 Blueprint 로 구현하는 것은 불가능하고 둘을 적절하게 조합하는 것이 최선이라는 생각이 들었습니다.

게임 로직은 최대한 C++ 로 작성했고 게임의 설정과 관련한 부분이나 화면에 보여지는 부분은 Blueprint 로 구현했습니다. 작업이 진행될 수록 Unity 엔진과는 차이가 느껴졌습니다. 처음에는 시행착오가 많았는데 점점 익숙해 지면서 속도가 붙었습니다.

가장 어려웠던 부분은 화살이 항아리에 들어가 있는지 여부의 판단이었습니다. 여러가지 방법을 적용해 보았으나 정확하게 판단이 되지 않았습니다. 계속 찾아보면서 결국 잘 판단이 되도록 했고 사용자가 기다리는 시간을 최소로 줄였습니다.

최적화 과정

이전에는 일단 배포를 목표로 했었습니다. 그러나 이제는 배포 전에 최대한 최적화를 많이 진행하게 되었습니다. 간단한 부분이지만 어떻게 하는 것이 더 효율적인가 고민, 적용하기를 반복했습니다. 최적화 작업을 무한하게 할 수는 없으므로 적절한 선에서 마무리하고 배포 했습니다.

배포 과정에서도 또 다른 문제를 마주했지만 그 또한 필요한 과정이라 생각했습니다.

Unreal 엔진으로 처음 만든 게임인데 아마 개선해야 할 사항이 많을 것 같습니다. 하지만 개인적으로 Unity 엔진으로 처음 만든 게임보다는 완성도가 더 높다고 생각되었습니다. 조금 더 마무리 작업에 공을 들인 것이 그 차이를 만들어 낸 것 같았습니다.

후기

지나서 보니 Unity 엔진에 경험이 있었기 때문에 그래도 빠르게 Unreal 엔진에 적응할 수 있었던 것 같습니다. 필자와 같은 상황에 계신 분이 있을 것 같은데 너무 겁먹을 필요는 없는 것 같습니다.

개인적으로는 초반의 큰 고개 하나(Unity 엔진과 Unreal 엔진의 차이)와 여러 개의 작은 고개(Unreal 엔진만의 기능)만 잘 넘으시면 큰 문제 없이 Unreal 엔진에 익숙해 지실 것으로 생각됩니다.

다음 주소에서 게임 정보를 확인하실 수 있습니다. 하단에 배너 광고가 포함되어 있습니다.

https://play.google.com/store/apps/details?d=jaeyoung.kim.throwarrow

Unreal Android aab 파일 업로드 시 경고 해결

개요

Unreal 로 개발을 마치고 플레이 스토어에 aab 파일을 올렸는데 경고 메시지가 나타났습니다. 무시해도 당분간은 문제가 없습니다. 하지만 업데이트는 계속해야 하고 결국 해결해야 할 문제라는 생각이 들어 조치하기로 했습니다.

첫 번째 경고 메시지

첫 번째 경고 메시지는 다음과 같습니다.

androidx.fragment:fragment (androidx.fragment:fragment) 개발자가 1.0.0 버전이 오래되었다고 신고했습니다. 신작을 게시하기 전에 다음 버전 중 하나로 업그레이드하는 것이 좋습니다.1.1.0+ 사용 중인 SDK에 대해 자세히 알아보고 Google Play SDK 색인 정보를 바탕으로 SDK를 선택하세요.

내용 그대로 오래된 버전으로 지정되어 발생한 경고입니다. Engine\Source\ThirdParty\AndroidPermission\permission_library\additions.gradle 파일을 열어서 다음의 내용을 추가합니다.

dependencies {
    ...
    constraints.implementation 'androidx.fragment:fragment:1.3.6'
}

Unreal Engine 5.3.2 기준으로 fragment 버전을 최신(1.6.2)으로 하면 빌드 오류가 발생했습니다. 1.3.6 으로 지정하면 정상적으로 빌드됩니다.

나머지 경고 메시지

앱 화면 하단에 배너 광고가 나타나도록 했습니다. 다른 경고 메시지는 애드몹(Admob)에 관련된 것 이었습니다.

Google Mobile Ads (GMA) SDK (com.google.android.gms:play-services-ads) 개발자가 18.1.0 버전이 오래되었다고 신고했습니다. 이 버전의 앱을 출시한 지 90일이 지나면 새 버전(20.0.0+)으로 업그레이드할 때까지 이 SDK를 포함한 새 버전을 출시할 수 없습니다. 사용 중인 SDK에 대해 자세히 알아보고 Google Play SDK 색인 정보를 바탕으로 SDK를 선택하세요.

Google Mobile Ads (GMA) SDK (com.google.android.gms:play-services-ads) 개발자가 SDK 버전 18.1.0에 다음 메모를 추가했습니다. As of June 30th 2023, this version is sunset. For more information, please visit https://developers.google.com/admob/android/deprecation. 이 버전의 앱을 출시한 지 90일이 지나면 새 버전으로 업그레이드할 때까지 이 SDK를 포함한 새 버전을 출시할 수 없습니다. 사용 중인 SDK에 대해 자세히 알아보고 Google Play SDK 색인 정보를 바탕으로 SDK를 선택하세요.

Google Mobile Ads (GMA) SDK (com.google.android.gms:play-services-ads-lite) 개발자가 18.1.0 버전이 오래되었다고 신고했습니다. 이 버전의 앱을 출시한 지 90일이 지나면 새 버전(20.0.0+)으로 업그레이드할 때까지 이 SDK를 포함한 새 버전을 출시할 수 없습니다. 사용 중인 SDK에 대해 자세히 알아보고 Google Play SDK 색인 정보를 바탕으로 SDK를 선택하세요.

나머지 경고 메시지 해결 방법

이 글에서 안내하는 해결 방법은 배너 광고에만 해당합니다. Engine\Source\Runtime\Advertising\Android\AndroidAdvertising\AndroidAdvertising_APL.xml 파일을 열어 implementation(‘com.google.android.gms:play-services-ads:18.0.1’) 을 implementation(‘com.google.android.gms:play-services-ads:22.6.0’) 로 변경합니다.

이 상태에서 빌드하면 InterstitialAd 관련 오류가 발생합니다. 원인은 com.google.android.gms.ads.InterstitialAd 이 20.0.0+ 에서 deprecated 되어 AndroidAdvertising_APL.xml 파일내의 삽입광고 구현부분과 라이브러리 버전 20.0.0+ 과의 불일치 때문입니다.

삽입광고를 사용하시는 분들은 새로 변경된 라이브러리 버전에 맞추어 수정하셔야 합니다. <![CDATA[ , ]]>로 감싸진 부분이 애드몹 관련 자바 코드 부분입니다. 필자는 삽입 광고 부분을 모두 주석 처리 했습니다. 추가로 더 이상 지원되지 않는 메소드의 @Override 부분도 다음과 같이 주석 처리 했습니다.

/*
@Override
public void onAdFailedToLoad(int errorCode)
{
    adIsAvailable = false;
    adIsRequested = false;

    // don't immediately request a new ad on failure, wait until the next show
    updateAdVisibility(false);
}
*/

AndroidAdvertising_APL.xml 파일을 수정한 경우 Unreal Engine이 실행 중이면 바로 반영되지 않습니다. 다시 엔진을 시작하고 빌드한 후 aab 파일을 올리면 위의 경고 메시지가 나타나지 않는 것을 알 수 있습니다.

삽입 광고 관련한 부분을 변경된 라이브러리에 맞추어 수정된 파일이 있지 않을까 해서 검색을 좀 해보았는데 발견하지 못하였습니다. 엔진이 업데이트 되면 이 불일치 부분도 해결되었으면 좋겠습니다.

참고로 AndroidAdvertising_APL.xml 파일의 수정된 부분 전체의 내용입니다.

/* 위쪽 import 부분 */
import com.google.android.gms.common.GoogleApiAvailability;
import com.google.android.gms.ads.MobileAds;
import com.google.android.gms.ads.initialization.InitializationStatus;
import com.google.android.gms.ads.initialization.OnInitializationCompleteListener;
import com.google.android.gms.ads.AdRequest;
import com.google.android.gms.ads.AdView;
import com.google.android.gms.ads.AdSize;
import com.google.android.gms.ads.AdListener;
//import com.google.android.gms.ads.InterstitialAd;
import com.google.android.gms.ads.interstitial.InterstitialAd;
import com.google.android.gms.ads.RequestConfiguration;
import com.google.android.gms.ads.identifier.AdvertisingIdClient;
import com.google.android.gms.ads.identifier.AdvertisingIdClient.Info;

    /** AdMob support */
    private PopupWindow adPopupWindow;
    private AdView adView;
    private boolean adInit = false;
    private LinearLayout adLayout;
    private int adGravity = Gravity.TOP;
    //private InterstitialAd interstitialAd;
    //private boolean isInterstitialAdLoaded = false;
    //private boolean isInterstitialAdRequested = false;
    //private AdRequest interstitialAdRequest;
    private String advertisingID = null;

	/** true when the application has requested that an ad be displayed */
	private boolean adWantsToBeShown = false;

	/** true when an ad is available to be displayed */
	private boolean adIsAvailable = false;

	/** true when an ad request is in flight */
	private boolean adIsRequested = false;

	// handle ad popup visibility and requests
	private void updateAdVisibility(boolean loadIfNeeded)
	{
		if (!adInit || (adPopupWindow == null))
		{
			return;
		}

		// request an ad if we don't have one available or requested, but would like one
		if (adWantsToBeShown && !adIsAvailable && !adIsRequested && loadIfNeeded)
		{
			AdRequest adRequest = new AdRequest.Builder().build();		// add test devices here
			_activity.adView.loadAd(adRequest);

			adIsRequested = true;
		}

		if (adIsAvailable && adWantsToBeShown)
		{
			if (adPopupWindow.isShowing())
			{
				return;
			}

			adPopupWindow.showAtLocation(activityLayout, adGravity, 0, 0);
			// don't call update on 7.0 to work around this issue: https://code.google.com/p/android/issues/detail?id=221001
			if (ANDROID_BUILD_VERSION != 24) {
				adPopupWindow.update();
			}
		}
		else
		{
			if (!adPopupWindow.isShowing())
			{
				return;
			}

			adPopupWindow.dismiss();
			adPopupWindow.update();
		}
	}

	public void AndroidThunkJava_ShowAdBanner(String AdMobAdUnitID, boolean bShowOnBottonOfScreen)
	{
		Log.debug("In AndroidThunkJava_ShowAdBanner");
		Log.debug("AdID: " + AdMobAdUnitID);

		adGravity = bShowOnBottonOfScreen ? Gravity.BOTTOM : Gravity.TOP;

		if (adInit)
		{
			// already created, make it visible
			_activity.runOnUiThread(new Runnable()
			{
				@Override
				public void run()
				{
					if ((adPopupWindow == null) || adPopupWindow.isShowing())
					{
						return;
					}

					adWantsToBeShown = true;
					updateAdVisibility(true);
				}
			});

			return;
		}

		// init our AdMob window
		adView = new AdView(this);
		adView.setAdUnitId(AdMobAdUnitID);
		adView.setAdSize(AdSize.BANNER);

		if (adView != null)
		{
			_activity.runOnUiThread(new Runnable()
			{
				@Override
				public void run()
				{
					adInit = true;

					final DisplayMetrics dm = getResources().getDisplayMetrics();
					final float scale = dm.density;
					adPopupWindow = new PopupWindow(_activity);
					adPopupWindow.setWidth((int)(320*scale));
					adPopupWindow.setHeight((int)(50*scale));
					adPopupWindow.setClippingEnabled(false);

					adLayout = new LinearLayout(_activity);

					final int padding = (int)(-5*scale);
					adLayout.setPadding(padding,padding,padding,padding);

					MarginLayoutParams params = new MarginLayoutParams(LayoutParams.WRAP_CONTENT, LayoutParams.WRAP_CONTENT);;

					params.setMargins(0,0,0,0);

					adLayout.setOrientation(LinearLayout.VERTICAL);
					adLayout.addView(adView, params);
					adPopupWindow.setContentView(adLayout);

					// set up our ad callbacks
					_activity.adView.setAdListener(new AdListener()
					{
						 @Override
						public void onAdLoaded()
						{
							adIsAvailable = true;
							adIsRequested = false;

							updateAdVisibility(true);
						}

						/*
						 @Override
						public void onAdFailedToLoad(int errorCode)
						{
							adIsAvailable = false;
							adIsRequested = false;

							// don't immediately request a new ad on failure, wait until the next show
							updateAdVisibility(false);
						}
						*/
					});

					adWantsToBeShown = true;
					updateAdVisibility(true);
				}
			});
		}
	}

	public void AndroidThunkJava_HideAdBanner()
	{
		Log.debug("In AndroidThunkJava_HideAdBanner");

		if (!adInit)
		{
			return;
		}

		_activity.runOnUiThread(new Runnable()
		{
			@Override
			public void run()
			{
				adWantsToBeShown = false;
				updateAdVisibility(true);
			}
		});
	}

	public void AndroidThunkJava_CloseAdBanner()
	{
		Log.debug("In AndroidThunkJava_CloseAdBanner");

		if (!adInit)
		{
			return;
		}

		// currently the same as hide.  should we do a full teardown?
		_activity.runOnUiThread(new Runnable()
		{
			@Override
			public void run()
			{
				adWantsToBeShown = false;
				updateAdVisibility(true);
			}
		});
	}

	public void AndroidThunkJava_LoadInterstitialAd(String AdMobAdUnitID)
	{
		/*
		interstitialAdRequest = new AdRequest.Builder().build();

		interstitialAd = new InterstitialAd(this);
		isInterstitialAdLoaded = false;
		isInterstitialAdRequested = true;
		interstitialAd.setAdUnitId(AdMobAdUnitID);

		_activity.runOnUiThread(new Runnable()
		{
			@Override
			public void run()
			{
				interstitialAd.loadAd(interstitialAdRequest);
			}
		});

		interstitialAd.setAdListener(new AdListener()
		{
			@Override
			public void onAdFailedToLoad(int errorCode)
			{
				Log.debug("Interstitial Ad failed to load, errocode: " + errorCode);
				isInterstitialAdLoaded = false;
				isInterstitialAdRequested = false;
			}
			@Override
			public void onAdLoaded()
			{
				//track if the ad is loaded since we can only called interstitialAd.isLoaded() from the uiThread
				isInterstitialAdLoaded = true;
				isInterstitialAdRequested = false;
			}
		});
		*/
	}

	public boolean AndroidThunkJava_IsInterstitialAdAvailable()
	{
		//return interstitialAd != null && isInterstitialAdLoaded;
		return false;
	}

	public boolean AndroidThunkJava_IsInterstitialAdRequested()
	{
		//return interstitialAd != null && isInterstitialAdRequested;
		return false;
	}

	public void AndroidThunkJava_ShowInterstitialAd()
	{
		/*
		if(isInterstitialAdLoaded)
		{
			_activity.runOnUiThread(new Runnable()
			{
				@Override
				public void run()
				{
					interstitialAd.show();
				}
			});
		}
		else
		{
			Log.debug("Interstitial Ad is not available to show - call LoadInterstitialAd or wait for it to finish loading");
		}
		*/
	}

	private GetAdvertisingIdTask AdTask = null;

	private class GetAdvertisingIdTask extends android.os.AsyncTask<String, Integer, String>
	{
		@Override
		protected String doInBackground(String... values)
		{
			AdvertisingIdClient.Info adInfo = null;
			try
			{
				adInfo = AdvertisingIdClient.getAdvertisingIdInfo(GameActivity.Get().getApplicationContext());
				if (adInfo.isLimitAdTrackingEnabled())
				{
					Log.debug("GetAdvertisingId: User opted out of ad tracking");
					adInfo = null;
				}
				Log.debug("GetAdvertisingID: success");
			}
			catch (Exception e) {
				Log.debug("GetAdvertisingId failed: " + e.getMessage());
			}
			return (adInfo == null) ? "" : adInfo.getId();
		}

		@Override
		protected void onPostExecute(String s)
		{
			advertisingID = s;
		}
	}

	public String AndroidThunkJava_GetAdvertisingId()
	{
		try
		{
			AdTask.get();
		}
		catch (Exception e)
		{
			advertisingID = null;
		}

		return advertisingID;
	}
]]>

별 내용이 없어 보이는데 생각보다 해결에 시간이 많이 소요되었습니다. 필자와 같은 문제를 겪고 계신 분들께 도움이 되었으면 좋겠습니다.