광고 서비스 코드의 일부입니다. 프로젝트에 따라 달라지는 광고사 SDK와 상관없이 사용할 수 있게 만들어져있습니다.
| |-- ScreenShotOptionExtension.cs
| `-- Widgets/
| `-- WidgetScreenShot.cs
|-- U5.Service.Ads/
| `-- Scripts/
| |-- AdBannerPositionType.cs
| |-- AdControl.cs
| |-- AdServiceImpl.cs
| `-- Impl/
| |-- AdServiceEditor.cs
| |-- Admob/
| | |-- AdServiceAdmob.cs
| | |-- AdServiceAdmob_Banner.cs
| | |-- AdServiceAdmob_Interstitial.cs
| | |-- AdServiceAdmob_RewardVideo.cs
| | `-- FBAdSettings.cs
| |-- AppLovin/
| | |-- AdServiceAppLovin.cs
| | |-- AdServiceAppLovin_Banner.cs
| | |-- AdServiceAppLovin_Interstitial.cs
| | `-- AdServiceAppLovin_RewardVideo.cs
| |-- CrazyGames/
| | |-- AdServiceCrazy.cs
| | |-- AdServiceCrazy_Banner.cs
| | |-- AdServiceCrazy_Interstitial.cs
| | `-- AdServiceCrazy_RewardVideo.cs
| |-- GameDistribution/
| | |-- AdServiceGameDistribution.cs
| | |-- AdServiceGameDistribution_Banner.cs
| | |-- AdServiceGameDistribution_Interstitial.cs
| | `-- AdServiceGameDistribution_RewardVideo.cs
| |-- IronSource/
| | |-- AdServiceIronSource.cs
| | |-- AdServiceIronSource_Banner.cs
| | |-- AdServiceIronSource_Interstitial.cs
| | `-- AdServiceIronSource_RewardVideo.cs
| `-- Mintegral/
| |-- AdServiceMintegral.cs
| |-- AdServiceMintegral_Banner.cs
| |-- AdServiceMintegral_Interstitial.cs
| `-- AdServiceMintegral_RewardVideo.cs
|-- U5.Service.Analytics/
| `-- Scripts/
| |-- AnalyticService.cs
using UnityEngine;
#if UNITY_WEBGL && !UNITY_EDITOR
using D = Unit5.WebPlayerDebug;
#else
using D = Unit5.DebugTool;
#endif
using System;
using System.Collections;
using System.Collections.Generic;
using System.Text;
using Unit5.Relays;
//----------
namespace Unit5
{
//----------
sealed public partial class AdControl : SingletonMB
{
//----------
public enum AdState
{
None,
AdLoaded,
AdFailedToLoad,
AdOpening,
AdFailedToShow,
UserEarnedReward,
AdClosed,
AdWaitReward,
}
//----------
AdServiceImpl _impl = null;
//----------
public AdServiceImpl impl => _impl;
public bool available => (_impl != null && _impl.available);
public bool isInited => (_impl != null && _impl.isInited);
//----------
public IRelayLink onInitialized => _impl?.onInitialized;
public IRelayLink onOpenedAd => _impl?.onOpenedAd;
public int reqCountBanner => (_impl != null ? _impl.reqCountBanner : 0);
public int reqCountInterstitial => (_impl != null ? _impl.reqCountInterstitial : 0);
public int reqCountRewardVideo => (_impl != null ? _impl.reqCountRewardVideo : 0);
//----------
public bool GetGDPRConsent()
{
if(_impl == null)return false;
return _impl.GetGDPRConsent();
}
public void SetGDPRConsent(bool confirm)
{
if(_impl == null)return;
_impl.SetGDPRConsent(confirm);
}
//----------
public void Init(AdServiceImpl impl, Dictionary options=null, bool disabledDefaultAd=false, bool checkGDPRConsent=false, bool autoFetch=true, Action onInitialized=null)
{
_impl = impl;
_impl.Init(options, disabledDefaultAd, checkGDPRConsent, autoFetch, onInitialized);
}
public void Init(Dictionary options=null, bool disabledDefaultAd=false, bool checkGDPRConsent=false, bool autoFetch=true, Action onInitialized=null)
{
if(_impl == null)
{
#if ADS_NONE
_impl = new AdServiceImpl();
#elif ADS_ADMOB
_impl = new AdServiceAdmob();
#elif UNITY_EDITOR || ADS_FAKE
_impl = new AdServiceEditor();
#elif ADS_APPLOVIN
_impl = new AdServiceAppLovin();
#elif ADS_IRONSOURCE
_impl = new AdServiceIronSource();
#elif ADS_MINTEGRAL
_impl = new AdServiceMintegral();
#elif ADS_CRAZYGAMES
_impl = new AdServiceCrazyGames();
#elif ADS_GAMEDISTRIBUTION
_impl = new AdServiceGameDistribution();
#else
_impl = new AdServiceImpl();
#endif
}
_impl.Init(options, disabledDefaultAd, checkGDPRConsent, autoFetch, onInitialized);
}
//----------
public bool showBanner => (_impl != null && _impl.showBanner);
public bool isVisibleBanner => (_impl != null && _impl.isVisibleBanner);
public int bannerHeight => (_impl != null ? _impl.bannerHeight : -1);
public void SetFirstBannerDelay(int duration)
{
if(_impl == null)return;
_impl.SetFirstBannerDelay(duration);
}
public void SetBannerPosition(AdBannerPositionType position)
{
if(_impl == null)return;
_impl.SetBannerPosition(position);
}
public void ShowBanner(bool forceReload=false)
{
if(_impl == null)return;
_impl.ShowBanner(forceReload);
}
public void HideBanner(bool forceDestroy=false)
{
if(_impl == null)return;
_impl.HideBanner(forceDestroy);
}
//----------
public bool isInterstitialTimeout => (_impl != null ? _impl.isInterstitialTimeout : false);
public bool CanShowInterstitial()
{
if(_impl == null)return false;
return _impl.CanShowInterstitial();
}
public void ShowInterstitial(Action onComplete=null, int timeout=10)
{
if(_impl == null)
{
onComplete(false);
return;
}
_impl.ShowInterstitial(onComplete, timeout);
}
public void CancelInterstitial()
{
if(_impl == null)return;
_impl.CancelInterstitial();
}
public void FetchInterstitial()
{
if(_impl == null)return;
_impl.FetchInterstitial();
}
//----------
public bool isRewardVideoTimeout => (_impl != null ? _impl.isRewardVideoTimeout : false);
public bool CanShowRewardVideo()
{
if(_impl == null)return false;
return _impl.CanShowRewardVideo();
}
public void ShowRewardVideo(Action onComplete=null, int timeout=10)
{
if(_impl == null)
{
onComplete(false);
return;
}
_impl.ShowRewardVideo(onComplete, timeout);
}
public void CancelRewardVideo()
{
if(_impl == null)return;
_impl.CancelRewardVideo();
}
public void FetchRewardVideo()
{
if(_impl == null)return;
_impl.FetchRewardVideo();
}
//----------
public void FetchAll()
{
if(_impl == null)return;
_impl.FetchAll();
}
//----------
public void SetNoAdsPurchased(bool purchased)
{
if(_impl == null)return;
_impl.SetNoAdsPurchased(purchased);
}
//----------
public void OpenTestSuite()
{
if(_impl == null)return;
_impl.OpenTestSuite();
}
}
}
크로스 프로모션 서비스 코드의 일부입니다. 배너/동영상 위젯 등의 컴포넌트와 유틸리티들과 서버 측 서비스 코드로 구성되어 있습니다.
| | |-- GoogleImpl.cs
| | `-- UnityImpl.cs
| `-- MultiAnalyticsImpl.cs
|-- U5.Service.CrossPromotion/
| |-- Assets/
| | |-- LAni_cp_LEFT.anim
| | |-- LAni_cp_RIGHT.anim
| | |-- Merriweather-Regular-UNIT5CP.ttf
| | |-- RobotoCondensed-Regular-slim.ttf
| | |-- UNIT5_CP_frame_512.png
| | `-- UNIT5_CP_frame_512.psd
| `-- Scripts/
| |-- PromotionControl.cs
| |-- PromotionInfo.cs
| |-- PromotionReward.cs
| `-- Widgets/
| |-- WidgetCrossBanner.cs
| |-- WidgetCrossInterstitial.cs
| `-- WidgetCrossVideo.cs
|-- U5.Service.IAP/
| |-- Editor/
| | `-- StoreSettingsInspector.cs
using UnityEngine;
#if UNITY_WEBGL && !UNITY_EDITOR
using D = Unit5.WebPlayerDebug;
#else
using D = Unit5.DebugTool;
#endif
using System;
using System.Collections;
using System.Collections.Generic;
using System.Linq;
using Unit5;
using Unit5.MiniJSON;
//----------
namespace Unit5.Service
{
//----------
sealed public class PromotionControl
{
//----------
const string PROMOTION_URL = "_PROMOTION_URL";
const string PROMOTION_CACHE = "_PROMOTION_CACHE";
//----------
static public bool isReady{get; private set;}
static public bool hasData{get; private set;}
//----------
static public List infos{get; private set;}
//----------
static public PromotionInfo Get(bool notInstalled=false)
{
if(!hasData)return null;
//
List samples = (notInstalled ? GetNotInstalled() : infos);
if(samples.IsNullOrEmpty())samples = infos;
if(samples.IsNullOrEmpty())return null;
if(samples.Count == 1)return samples[0];
//
if(notInstalled)return GetNotInstalled().Choice();
int[] weights = samples.Select((d) => d.weight).ToArray();
int weightTotal = weights.Sum();
int dataIndex = MathUtil.RandomChoice(weights, weightTotal);
dataIndex = Mathf.Max(0, Mathf.Min(infos.Count - 1, dataIndex));
return infos[dataIndex];
}
static public PromotionInfo Get(string appId)
{
if(!hasData)return null;
return infos.Find(i => i.appId == appId);
}
static public List GetNotInstalled()
{
if(!hasData)return null;
return infos.Where(i => !IsInstalled(i)).ToList();
}
static public List GetAll(PromotionReward type)
{
if(!hasData)return null;
return infos.Where(i => i.reward == type).ToList();
}
static public List GetAll(bool hasExtra)
{
if(!hasData)return null;
return infos.Where(i => !string.IsNullOrEmpty(i.extra)).ToList();
}
static public List GetAll(PromotionReward type, bool hasExtra)
{
if(!hasData)return null;
return infos.Where(i => i.reward == type && !string.IsNullOrEmpty(i.extra)).ToList();
}
//----------
static public void CheckData(string url, Action callback)
{
if(string.IsNullOrEmpty(url))
{
callback(false);
return;
}
if(hasData)
{
callback(true);
return;
}
//
string oldCacheFilepath = EncryptedPlayerPrefs.GetString(PROMOTION_CACHE, string.Empty);
string newCacheFilepath = RequestUtil.GetCacheFilepath(url);
FetchData(url, newCacheFilepath, (bool success) => {
if(success)
{
if(!string.IsNullOrEmpty(oldCacheFilepath))
{
StreamingAssetUtil.RemoveFile(oldCacheFilepath);
}
EncryptedPlayerPrefs.SetString(PROMOTION_URL, url);
EncryptedPlayerPrefs.SetString(PROMOTION_CACHE, newCacheFilepath);
}
callback((success && hasData));
});
}
//----------
static public void FetchData(string url, Action callback)
{
FetchData(url, null, callback);
}
static public void FetchData(string url, string cacheFilepath, Action callback)
{
RequestUtil.Text(url, cacheFilepath, (string data) => {
#if !NO_DEBUG
D.Log($"[UNIT5::PromotionControl] FetchData:: url={url} / cache={cacheFilepath} / data={data}");
#endif
#if UNITY_EDITOR
if(string.IsNullOrEmpty(data))
{
MakeTestData();
callback(true);
return;
}
#endif
if(string.IsNullOrEmpty(data))
{
isReady = false;
hasData = false;
callback(false);
}
else
{
isReady = true;
try
{
Dictionary json = Json.Deserialize(data) as Dictionary;
string appId = UnityUtil.GetApplicationID();
infos = json.GetDictionaryList("p")
.Select((d) => new PromotionInfo(d))
.Where((i) => i.appBundleId != appId)
.ToList()
;
hasData = (infos.Count > 0);
}
catch
{
hasData = false;
}
callback(hasData);
}
});
}
#if UNITY_EDITOR
static void MakeTestData()
{
isReady = true;
hasData = true;
infos = new List(){
new PromotionInfo(){
appBundleId = "com.ftt.cubie.aos",
appId = "com.ftt.cubie.aos",
appScheme = "cubieadventure",
weight = 1,
appName = "Cubie Adventure",
appDesc = "Adventure with your Cubie and Cupet friends!\nFrom cute looks and immersive gameplay! Cubie Adventure welcomes you!",
appLink = "http://onelink.to/mjnhpp",
imgThumb = "https://share.unit5soft.com/_crosspromotion/ca_thumb.png",
imgBanner = "https://share.unit5soft.com/_crosspromotion/ca_banner.jpg",
imgPopup = "https://share.unit5soft.com/_crosspromotion/ca_full.jpg",
vidClip = "https://share.unit5soft.com/_crosspromotion/ca_clip.mp4",
reward = PromotionReward.None,
extra = "",
},
};
}
#endif
//----------
static public bool IsInstalled(PromotionInfo info)
{
#if !UNITY_EDITOR && (UNITY_IOS || UNITY_TVOS || UNITY_IPHONE)
return UnityUtil.IsInstalled(info.appScheme);
#else
return UnityUtil.IsInstalled(info.appId);
#endif
}
//----------
static public bool IsMarked(string appId)
{
return (EncryptedPlayerPrefs.GetInt("_XP_" + appId, 0) == 1);
}
static public void MarkApp(string appId)
{
EncryptedPlayerPrefs.SetInt("_XP_" + appId, 1);
}
}
}
값이나 상태의 변경 시 발생하는 화면 처리를 단순히 하기 위한 모듈의 일부입니다. 구매버튼, 다국어 이미지 등 프로젝트와 관계없이 일반화가 가능한 많은 부분을 모듈화하여 사용했습니다.
| |-- BaseN.cs
| |-- StringGenerator.cs
| `-- ZBase32.cs
|-- U5.Data.Binding/
| |-- Editor/
| | |-- BindingInfoInspector.cs
| | `-- Components/
| | |-- BindActionInspector.cs
| | |-- BindGameObjectToggleInspector.cs
| | |-- BindSpriteInspector.cs
| | |-- BindTextMeshInspector.cs
| | |-- BindTextureInspector.cs
| | |-- BindUIButtonInspector.cs
| | |-- BindUILabelInspector.cs
| | |-- BindUIProgressBarInspector.cs
| | |-- BindUISpanInspector.cs
| | |-- BindUISpriteInspector.cs
| | |-- BindUITextureInspector.cs
| | |-- BindUIToggleInspector.cs
| | `-- DataSetterInspector.cs
| `-- Scripts/
| |-- BindingFieldType.cs
| |-- BindingInfo.cs
| |-- BindingInfos.cs
| |-- Components/
| | |-- BindAction.cs
| | |-- BindGameObjectToggle.cs
| | |-- BindSprite.cs
| | |-- BindTextMesh.cs
| | |-- BindTexture.cs
| | |-- BindUIButton.cs
| | |-- BindUILabel.cs
| | |-- BindUIProgressBar.cs
| | |-- BindUISpan.cs
| | |-- BindUISprite.cs
| | |-- BindUITexture.cs
| | `-- BindUIToggle.cs
| |-- DataContext.cs
| |-- DataSetter.cs
| |-- IDataContext.cs
| |-- IDataProvider.cs
| `-- PropertyData.cs
|-- U5.Debug/
| `-- Scripts/
| |-- DebugTool.cs
using UnityEngine;
#if UNITY_WEBGL && !UNITY_EDITOR
using D = Unit5.WebPlayerDebug;
#else
using D = Unit5.DebugTool;
#endif
using TMPro;
using System;
using System.Collections;
using System.Collections.Generic;
//----------
namespace Unit5
{
//----------
public class BindTextMesh : DataSetter
{
//----------
[Space(10)]
[SerializeField] TextMeshPro lblText = null;
[SerializeField] UILocalizedLabel lblLocalize = null;
[SerializeField] TweenLabelCounter tweenLabel = null;
[Space(10)]
[SerializeField] string format;
[Space(10)]
[SerializeField] float duration = 1.0f;
[SerializeField] float amountPerDuration = 0.0f;
[SerializeField] float durationMin = 0.3f;
[SerializeField] float durationMax = 2.1f;
//----------
string text;
//----------
override protected void OnValueChanged(string newValue)
{
UpdateText(newValue);
}
//----------
void OnLocalize()
{
UpdateText(text);
}
//----------
void UpdateText(string text)
{
if(lblLocalize != null)
{
UpdateLocalize(text);
}
else if(tweenLabel == null)
{
UpdateDirect(text);
}
else
{
UpdateTween(text);
}
this.text = text;
}
//----------
void UpdateLocalize(string text)
{
lblLocalize.text = text;
}
void UpdateDirect(string text)
{
if(string.IsNullOrEmpty(format))
{
lblText.text = text;
}
else
{
if(format.StartsWith("@"))
{
lblText.text = Localization2.Get(format, text);
}
else
{
lblText.text = string.Format(format, text);
}
}
}
void UpdateTween(string text)
{
int v = (string.IsNullOrEmpty(text) ? 0 : int.Parse(text));
if(isReady)
{
float d = (amountPerDuration > 0.0f ? Mathf.Abs(v - tweenLabel.currentCount) / amountPerDuration : 1.0f) * duration;
if(durationMin > 0.0f)d = Mathf.Max(durationMin, d);
if(durationMax > 0.0f)d = Mathf.Min(durationMax, d);
tweenLabel.Play(d, tweenLabel.currentCount, v);
}
else
{
tweenLabel.from = tweenLabel.to = v;
tweenLabel.Sample(1.0f, true);
isReady = true;
}
}
}
}
화면의 전체나 일부를 캡하는 위젯입니다. 협업 개발의 편의성을 위해 많은 부분을 위젯 형태로 만들어 인스펙터에서 조작하는 것만으로 처리가 가능하게 했습니다.
| | |-- QueryBuilder.cs
| | `-- SQLiteORM.cs
| `-- SQLiteSimple.cs
|-- U5.ScreenShot/
| |-- Editor/
| | `-- WidgetScreenShotInspector.cs
| `-- Scripts/
| |-- AnchorHorizontal.cs
| |-- AnchorVertical.cs
| |-- ScreenShotControl.cs
| |-- ScreenShotOption.cs
| |-- ScreenShotOptionExtension.cs
| `-- Widgets/
| `-- WidgetScreenShot.cs
|-- U5.Service.Ads/
| `-- Scripts/
| |-- AdBannerPositionType.cs
using UnityEngine;
#if UNITY_WEBGL && !UNITY_EDITOR
using D = Unit5.WebPlayerDebug;
#else
using D = Unit5.DebugTool;
#endif
using System;
using System.Collections;
using System.Collections.Generic;
//----------
namespace Unit5
{
//----------
sealed public class WidgetScreenShot : MonoBehaviour
{
//----------
public enum CropMode
{
Option = 0,
Area,
}
//----------
[Space(10)]
[SerializeField] Camera[] cameras = null;
[Space(10)]
[SerializeField] [DelayedAttribute] int size = 320;
[Space(10)]
[SerializeField] CropMode cropMode = CropMode.Option;
[SerializeField] ScreenShotOption cropOption = null;
[SerializeField] SpriteRenderer cropArea = null;
[Space(10)]
[SerializeField] Texture2D overlay = null;
[SerializeField] ScreenShotOption merge = null;
//----------
[NonSerialized] [HideInInspector] public Texture2D lastScreenShot;
//----------
public bool isEnabled => (!cameras.IsNullOrEmpty());
public ScreenShotOption crop => (cropMode == CropMode.Option ? cropOption : CalcArea());
ScreenShotOption CalcArea()
{
ScreenShotOption option = (cropOption == null ? new ScreenShotOption() : cropOption.Clone());
if(cropArea == null)return option;
float fh = Camera.main.orthographicSize * 2f;
float fw = fh / Camera.main.pixelHeight * Camera.main.pixelWidth;
float iw = cropArea.bounds.size.x;
float ih = cropArea.bounds.size.y;
option.anchorX = AnchorHorizontal.Left;
option.anchorY = AnchorVertical.Top;
option.width = iw / fw;
option.height = ih / fh;
option.offsetX = ((fw - iw) * 0.5f - (Camera.main.transform.position.x - cropArea.bounds.center.x)) / fw;
option.offsetY = ((fh - ih) * 0.5f - (Camera.main.transform.position.y - cropArea.bounds.center.y)) / fh;
return option;
}
//----------
void OnDestroy()
{
Clear();
}
//----------
public void Clear()
{
if(lastScreenShot != null)GameObject.Destroy(lastScreenShot);
lastScreenShot = null;
}
//----------
public Texture2D TakeScreenShot()
{
lastScreenShot = TakeCroppedScreenShot(Screen.width, Screen.height);
return lastScreenShot;
}
//----------
public Texture2D TakeCroppedScreenShot(int width, int height)
{
int w = size;
int h = Mathf.RoundToInt(size * (height / (float)width));
return TakeCroppedScreenShot(w, h, crop);
}
public Texture2D TakeCroppedScreenShot(int width, int height, ScreenShotOption option)
{
Texture2D image = CaptureFrame(width, height);
if(option.width == 1.0f && option.height == 1.0f && option.offsetX == 0.0f && option.offsetY == 0.0f)
{
if(overlay != null)
{
MergeOverlay(ref image);
}
return image;
}
Texture2D result = CropImage(ref image, option);
#if UNITY_EDITOR
DestroyImmediate(image);
#else
Destroy(image);
#endif
if(overlay != null)
{
MergeOverlay(ref result);
}
return result;
}
//----------
public Texture2D CaptureFrame(int width, int height)
{
RenderTexture rt = new RenderTexture(width, height, 16, RenderTextureFormat.ARGB32);
foreach(Camera cam in cameras)
{
RenderTexture tt = cam.targetTexture;
cam.targetTexture = rt;
cam.Render();
cam.targetTexture = tt;
}
RenderTexture.active = rt;
Texture2D image = new Texture2D(width, height, TextureFormat.RGB24, false);
image.ReadPixels(new Rect(0, 0, width, height), 0, 0, false);
image.Apply();
RenderTexture.active = null;
#if UNITY_EDITOR
DestroyImmediate(rt);
#else
Destroy(rt);
#endif
return image;
}
public Texture2D CropImage(ref Texture2D source, ScreenShotOption option)
{
Rect rect = option.Crop(source.width, source.height);
Texture2D result = new Texture2D((int)rect.width, (int)rect.height, TextureFormat.RGBA32, false);
result.SetPixels(source.GetPixels((int)rect.x, (int)rect.y, (int)rect.width, (int)rect.height));
result.Apply();
return result;
}
public Texture2D ResizeImage(ref Texture2D source, int width, int height)
{
Texture2D result = new Texture2D(width, height, source.format, false);
float w = (float)width;
float h = (float)height;
for(int y = 0; y < result.height; y += 1)
{
for(int x = 0; x < result.width; x += 1)
{
result.SetPixel(x, y, source.GetPixelBilinear(x / w, y / h));
}
}
result.Apply();
return result;
}
public void MergeOverlay(ref Texture2D source)
{
MergeOverlay(ref source, merge);
}
public void MergeOverlay(ref Texture2D source, ScreenShotOption option)
{
int ow = source.width;
int oh = Mathf.FloorToInt(source.width * (overlay.height / (float)overlay.width));
Rect rect = option.Place(source.width, source.height, ow, oh);
ow = (int)rect.width;
oh = (int)rect.height;
Texture2D resizedOverlay = ResizeImage(ref overlay, ow, oh);
int sx = Mathf.Max(0, Mathf.Min(source.width - ow, (int)rect.x));
int sy = Mathf.Max(0, Mathf.Min(source.height - oh, (int)rect.y));
int sw = Mathf.Min(source.width, sx + ow);
int sh = Mathf.Min(source.height, sy + oh);
int px;
int py;
Color sourceColor;
Color overlayColor;
Color finalColor;
for(int x = sx; x < sw; x += 1)
{
for(int y = sy; y < sh; y += 1)
{
px = x - sx;
py = y - sy;
sourceColor = source.GetPixel(x, y);
overlayColor = resizedOverlay.GetPixel(px, py);
finalColor = Color.Lerp(sourceColor, overlayColor, overlayColor.a / 1.0f);
source.SetPixel(x, y, finalColor);
}
}
source.Apply();
#if UNITY_EDITOR
DestroyImmediate(resizedOverlay);
#else
Destroy(resizedOverlay);
#endif
}
}
}
//====================
// 블럭 게임의 일부입니다
//====================
void RestoreGameStatus(bool passAppResume=false)
{
string data = GameData.GetGameData();
GameData.ClearGameData();
// NOTE: 튜토리얼 이후 보드상태, 가운데에 4놓여있고 다음유닛은 4
// data = "E?|1|6|0|1|1|0;2:2:0|0;2:2:6||0:2|13|0";
// NOTE: 가운데 x인 환경
// data = "E?|1|8|17|3|6|0;6:0:0|0;7:2:6,0;6:2:5,0;4:2:4,0;5:2:3,0;2:2:2,0;6:2:1,0;5:0:6|||14|0";
// D.Log(data);
// DebugTool.Report(data, false);
if(!string.IsNullOrEmpty(data))
{
MakeSavedLevel(data, passAppResume);
}
else
{
if(Game.skipLevelId > 0)
{
int skippedGoal = Game.skipLevelId;
int lastClearGoal = GameData.GetLastClearedGoal(GameSettings.initialGoalA);
if(lastClearGoal < skippedGoal)
{
GameData.SetLastPlayedGoal(skippedGoal);
GameData.SetLastClearedGoal(skippedGoal);
}
int skippedLevel = 1;
for(int i = 0; i < skippedGoal + 10; i += 1)
{
if(skippedGoal != GetGoalLevel(i + 1))continue;
skippedLevel = i + 1;
break;
}
int nextLevel = skippedLevel + 1;
OpenLevelStart(nextLevel, () => {
MakeLevel(nextLevel, null, skippedGoal);
});
}
else
{
OpenLevelStart(1);
}
}
// D.Log(GameData.GetGameData());
}
void SaveGameStatus(bool isPlaying=true)
{
VUnit goalUnit = model.units.Find((u) => (u.type == UnitType.Block && u.level == goal));
List remainUnits = model.units.Where((u) => (u != goalUnit && u != model.currUnit)).ToList();
string serializedNextUnit = (model.currUnit == null ? "" : model.currUnit.GetSerializedData());
if(model.isItemMode && model.keepedUnit != null)serializedNextUnit = model.keepedUnit.GetSerializedData();
string serializedRemainUnits = remainUnits.Select((u) => u.GetSerializedData()).Join(",");
string serializedGoalUnit = (goalUnit == null ? "" : goalUnit.GetSerializedData());
string serializedQueueUnits = unitQueue.Select((u) => $"{((int)u.type)}:{u.level}").Join(",");
string serializedReviceCount = GameData.GetReviveCount().ToString();
GameData.SetGameData($"E?|{stage}|{goal}|{GameData.GetMergeCount()}|{GameData.GetUserLevel()}|{GameData.GetScore()}|{serializedNextUnit}|{serializedRemainUnits}|{serializedGoalUnit}|{serializedQueueUnits}|{GameData.guestId}|{serializedReviceCount}");
}
}
//====================
void MergeCoins(List mergeFrom, VUnit mergeTo)
{
mergeTo.SetDirty();
Vector3 targetPos = mergeTo.localPos;
//
int mergeCount = mergeFrom.Count;
int coinIncrAmount = 0;
for(int i = 0; i < mergeCount; i += 1)
{
coinIncrAmount += mergeFrom[i].level - 1 + 3;
}
if(mergeFrom.Count == 0)
{
mergeTo.PlayAnim(VUnit.ANIM_STATE_MERGE_OUT, () => {
mergeTo.IncrLevel(coinIncrAmount);
mergeTo.PlayAnim(VUnit.ANIM_STATE_MERGE_NEW);
});
mergeTo.SetDirty(false);
}
else
{
for(int i = 0; i < mergeCount; i += 1)
{
if(i < mergeCount - 1)
{
MergeTo(mergeFrom[i], targetPos);
}
else
{
MergeTo(mergeFrom[i], targetPos, () => {
mergeTo.PlayAnim(VUnit.ANIM_STATE_MERGE_OUT, () => {
mergeTo.IncrLevel(coinIncrAmount);
mergeTo.PlayAnim(VUnit.ANIM_STATE_MERGE_NEW);
});
mergeTo.SetDirty(false);
});
}
}
}
//
Effect.Spawn(VfxEffectType.Merge, targetPos);
Effect.Play(model.GetComboSFX());
Effect.Vibrate((model.GetCombo() > 2 ? VibrationStyle.HEAVY : VibrationStyle.MEDIUM));
//
model.ReorderUnits(mergeTo);
}
//====================
async UniTask WaitEndOfMovingAsync()
{
while(true)
{
await UniTask.Yield();
int bCount = model.units.Count((u) => u.isMoving);
int kCount = model.removedUnits.Count((u) => u.isMoving);
if(kCount == 0 && !model.removedUnits.IsNullOrEmpty())
{
model.removedUnits.ForEach((u) => u.Release());
model.removedUnits = new List();
}
if(bCount + kCount == 0)break;
}
model.ReorderUnits();
CheckArrows();
SaveGameStatus();
}
async UniTaskVoid CheckMoveAsync(VUnit unit)
{
navigationController.ToggleBlockerPanel(true);
//
int currentMergeCount = GameData.GetMergeCount();
//
isWaitingDropAndHit = true;
currDroppedUnit = unit;
//
if(model.units.Any((u) => u.isMoving))
{
await WaitEndOfMovingAsync();
}
//
while(true)
{
int changed = 0;
//
if(DropUnit())
{
changed += 1;
await WaitEndOfMovingAsync();
}
//
if(CheckHit())
{
changed += 1;
await WaitEndOfMovingAsync();
}
//
if(changed == 0)break;
}
// NOTE: 딜레이 없고, 콤보는 한번 drop 에서만
model.UpdateCombo();
await WaitEndOfMovingAsync();
//
navigationController.ToggleBlockerPanel(false);
//
isWaitingDropAndHit = false;
currDroppedUnit = null;
//
model.CalcActionIds();
//
CheckArrows();
SaveGameStatus();
//
if(model.CheckDead() || model.isTimeOver)
{
WillGameOver();
if(model.isTimeOver)
{
OnTimeOver();
}
else
{
Effect.Spawn(VfxUIEffectType.BoardFull, () => {
OnGameOver();
});
}
}
else
{
// int x = (model.IsEmptyCol(model.centerCol, true) ? model.centerCol : -1);
// NOTE: 마지막 떨어진 곳 사용, 안되면 빈곳
int x = model.GetEmptyCol(model.lastCol);
VUnit spawnedUnit = (x == -1 ? null : SpawnUnit(x));
GameData.AddEarnedUnit(spawnedUnit.level);
model.currUnit = spawnedUnit;
//
env.ShowNextUnit(GetNextUnit(true));
FillNextUnits();
model.ReorderUnits(model.currUnit, true);
CheckArrows();
SaveGameStatus();
}
}
//====================
// 포커 게임의 일부입니다.
//====================
void ReplaceCard(Card target)
{
DoReplaceCardAsync(target).Forget();
}
async UniTaskVoid DoReplaceCardAsync(Card target)
{
CancellationToken ct = gameObject.GetCancellationTokenOnDestroy();
//
target.order = 301;
int x = target.gx;
int y = target.gy;
float delay = 0.0f;
await MoveBoardCardToTrash.Create(target, Vector3.zero, ref delay).Play().AwaitForComplete(cancellationToken: ct);
model.RemoveCard(target);
//
Card card = null;
Sequence seq = DOTween.Sequence();
bool hasNextCard = model.hasNextCard;
if(hasNextCard)
{
card = model.GetCardFromDeck(x, y);
MoveDeckCardToBoard.Create(seq, card, new Vector3(env.GetRealX(x), env.GetRealY(y), 0), card.gy * env.cols + card.gx, ref delay);
hasNextCard = model.hasNextCard;
}
if(hasNextCard)
{
card = model.GetCardFromDeck(-1, -1, true);
if(!card.isFront)OpenDeckCard.Create(seq, card, ref delay);
}
if(seq.Duration() > 0.0f)
{
await seq.Play().AwaitForComplete(cancellationToken: ct);
}
//
model.UpdateOrders();
//
if(!hasNextCard)
{
WillGameOver();
OnOutOfCards();
}
}
//====================
async UniTaskVoid DoNextAsync(List hands)
{
IncrQuest(hands.Count);
//
await DoMoveHandsToTrashAsync(hands);
await DoDropBoardCardsAsync();
await DoMoveDeckToBoardAsync();
//
if(!model.hasNextCard)
{
WillGameOver();
OnOutOfCards();
}
}
async UniTask DoMoveHandsToTrashAsync(List hands)
{
List cards;
HandType result = HandSolver.Solve(ref hands, out cards);
if(hands[0].data.Equals(CardData.JOKER))hands[0].SetData(cards[0]);
hands.Sort();
if(result != HandType.ROYAL_STRAIGHT_FLUSH_NO_WILD && result != HandType.ROYAL_STRAIGHT_FLUSH && result != HandType.STRAIGHT_FLUSH && result != HandType.STRAIGHT)hands.Reverse();
for(int i = 0; i < hands.Count; i += 1)hands[i].order = Mathf.RoundToInt(301 + i);
Dictionary> groups = new Dictionary>();
Dictionary ranks;
int[] rks;
switch(result)
{
case HandType.ROYAL_STRAIGHT_FLUSH_NO_WILD:
case HandType.FIVE_OF_A_KIND:
case HandType.ROYAL_STRAIGHT_FLUSH:
case HandType.STRAIGHT_FLUSH:
case HandType.FLUSH:
case HandType.STRAIGHT:
groups[1] = hands;
break;
case HandType.FOUR_OF_A_KIND:
case HandType.THREE_OF_A_KIND:
case HandType.ONE_PAIR:
ranks = new Dictionary();
hands.ForEach((c) => {
if(!ranks.ContainsKey(c.data.rank))ranks[c.data.rank] = 0;
ranks[c.data.rank] += 1;
});
rks = ranks.Keys.OrderBy((r) => -ranks[r]).ToArray();
groups[1] = hands.Where((c) => (c.data.rank == rks[0])).ToList();
break;
case HandType.FULL_HOUSE:
case HandType.TWO_PAIR:
ranks = new Dictionary();
hands.ForEach((c) => {
if(!ranks.ContainsKey(c.data.rank))ranks[c.data.rank] = 0;
ranks[c.data.rank] += 1;
});
rks = ranks.Keys.OrderBy((r) => -ranks[r]).ToArray();
groups[1] = hands.Where((c) => (c.data.rank == rks[0])).ToList();
groups[2] = hands.Where((c) => (c.data.rank == rks[1])).ToList();
break;
}
Sequence seq = DOTween.Sequence();
float margin = -env.cardMargin;
float cardWidth = -env.cardWidth;
float groupScale = -env.groupScale;
float sx = ((hands.Count - 1) * margin) * 0.5f;
float delay = 0.0f;
env.SetHandBackground(true);
env.SetHandGroup1(false);
env.SetHandGroup2(false);
if(groups.ContainsKey(1))
{
env.SetHandGroup1(
true,
groups[1].Select((c) => new Vector3(sx + hands.IndexOf(c) * cardWidth, 0.0f, 0.0f)).Avg(),
new Vector3(groups[1].Count * groupScale, groupScale, 0.0f)
);
}
if(groups.ContainsKey(2))
{
env.SetHandGroup2(
true,
groups[2].Select((c) => new Vector3(sx + hands.IndexOf(c) * cardWidth, 0.0f, 0.0f)).Avg(),
new Vector3(groups[2].Count * groupScale, groupScale, 0.0f)
);
}
ShowResult.Create(seq, env.handBackground);
for(int i = 0; i < hands.Count; i += 1)
{
MoveBoardCardToResult.Create(seq, hands[i], new Vector3(sx + i * 6.0f, 0.0f, 0.0f), ref delay);
}
await seq.Play().AwaitForComplete(cancellationToken: ct);
await UniTask.Delay(600, cancellationToken: ct);
hands.ForEach((c) => c.Release());
env.SetHandBackground(false);
}
async UniTask DoDropBoardCardsAsync()
{
Card card = null;
//
float delay = 0.0f;
Sequence seq = DOTween.Sequence();
for(int y = env.rows - 1; y > -1; y -= 1)
{
for(int x = 0; x < env.cols; x += 1)
{
card = model.GetCard(x, y);
if(card == null)continue;
int ny = -1;
for(int by = y + 1; by < env.rows; by += 1)
{
if(env.IsInside(x, by) && model.GetCard(x, by) != null)break;
ny = by;
}
if(ny == -1)continue;
if(card.gy == ny)continue;
model.MoveCard(card, x, ny);
DropBoardCard.Create(seq, card, env.GetRealY(card.gy), card.gy - y, ref delay);
}
}
await seq.Play().AwaitForComplete(cancellationToken: ct);
//
model.UpdateOrders();
}
async UniTask DoMoveDeckToBoardAsync()
{
Card card = null;
//
float delay = 0.0f;
Sequence seq = DOTween.Sequence();
bool hasNextCard = model.hasNextCard;
if(hasNextCard)
{
int loopLimit = model.gridPositions.Count;
int x, y;
for(int i = 0; i < loopLimit; i += 1)
{
x = model.gridPositions[i].x;
y = model.gridPositions[i].y;
if(model.GetCard(x, y) != null)continue;
//
card = model.GetCardFromDeck(x, y);
if(card.isFront)
{
MoveDeckCardToBoard.Create(seq, card, new Vector3(env.GetRealX(x), env.GetRealY(y), 0), card.gy * env.cols + card.gx, delay);
delay += MoveDeckCardToBoard.NextDelay();
}
else
{
OpenDeckCard.Create(seq, card, delay);
MoveDeckCardToBoard.Create(seq, card, new Vector3(env.GetRealX(x), env.GetRealY(y), 0), card.gy * env.cols + card.gx, delay + OpenDeckCard.NextDelay());
delay += MoveDeckCardToBoard.NextDelay();
}
hasNextCard = model.hasNextCard;
//
if(!hasNextCard)break;
}
}
if(hasNextCard)
{
card = model.GetCardFromDeck(-1, -1, true);
if(!card.isFront)OpenDeckCard.Create(seq, card, ref delay);
}
if(seq.Duration() > 0.0f)
{
await seq.Play().AwaitForComplete(cancellationToken: ct);
}
//
model.UpdateOrders();
}
async UniTask _DoMoveTrashToDeck()
{
model.MovePoolToDeck();
//
float delay = 0.0f;
Sequence seq = DOTween.Sequence();
for(int i = 0; i < model.decks.Count; i += 1)
{
MoveTrashCardToDeck.Create(model.decks[i], new Vector3(env.deckX + i * env.deckSpan, env.deckY, 0), ref delay);
}
await seq.Play().AwaitForComplete(cancellationToken: ct);
//
await OpenDeckCard.Create(model.GetCardFromDeck(-1, -1, true)).Play().AwaitForComplete(cancellationToken: ct);
}
//====================
// 퍼즐 게임의 일부입니다.
//====================
HashSet checkedPairs = new HashSet();
HashSet cachedRemovableUnits = new HashSet();
VUnit[] targetUnits;
bool waitRemoveConnectUnits = false;
void UpdateJoints()
{
bool hasRemovableUnits = (!removableUnits.IsNullOrEmpty());
if(hasRemovableUnits)removableUnitJoints = new List();
cachedRemovableUnits.Clear();
for(int i = 0; i < removableUnits.Count; i += 1)cachedRemovableUnits.Add(removableUnits[i]);
checkedPairs.Clear();
int pairId = 0;
VUnit unit1 = null;
VUnit unit2 = null;
UnitBallJoint joint = null;
Vector3 pos1 = Constants.V0;
Vector3 pos2 = Constants.V0;
bool forceRemoveJoint = false;
float dist = 0.0f;
int distCache = 0;
float distPxl = 0.0f;
float distSpanPxl = 0.0f;
int jointType = 0;
targetUnits = units.Where((u) => (u.type == UnitType.Ball)).ToArray();
int total = targetUnits.Length;
int i, j;
for(i = 0; i < total; i += 1)
{
unit1 = targetUnits[i];
for(j = total - 1; j > -1; j -= 1)
{
if(i == j)continue;
//
unit2 = targetUnits[j];
pairId = (1 + Mathf.Max(unit1.hash, unit2.hash)) * 1000 + (1 + Mathf.Min(unit1.hash, unit2.hash));
if(checkedPairs.Contains(pairId))continue;
checkedPairs.Add(pairId);
if(!unit1.Equals(unit2))continue;
if(!VUnit.CanConnect(unit1, unit2))
{
RemoveJoint(pairId);
continue;
}
if(!unit1.isReady || !unit2.isReady)
{
RemoveJoint(pairId);
continue;
}
if(unit1.willRemove || unit2.willRemove)
{
RemoveJoint(pairId);
continue;
}
//
forceRemoveJoint = (hasRemovableUnits ? (cachedRemovableUnits.Contains(unit1) || cachedRemovableUnits.Contains(unit2)) : false);
pos1 = unit1.pos;
pos2 = unit2.pos;
dist = Vector3.Distance(pos1, pos2);
if(dist > env.unitDistanceMax || forceRemoveJoint)
{
RemoveJoint(pairId, forceRemoveJoint);
continue;
}
distCache = Mathf.FloorToInt(dist * 10000);
if(cachedJointDistances.ContainsKey(pairId) && cachedJointDistances[pairId] == distCache)continue;
cachedJointDistances[pairId] = distCache;
distPxl = dist * env.TO_PXL;
distSpanPxl = distPxl - env.unitPxlSize;
//
if(!cachedJoints.ContainsKey(pairId))cachedJoints[pairId] = SpawnJoint(env.canvas, unit1.shape);
joint = cachedJoints[pairId];
jointType = env.CalcJointType(distSpanPxl);
joint.UpdateLinks(unit1.shape, jointType, pos1, pos2, (distPxl - env.unitSpansEx[jointType]) * env.TO_UNT);
}
}
if(hasRemovableUnits)
{
waitRemoveConnectUnits = true;
units = units.Except(removableUnits).ToList();
units.ForEach((u) => {
u.isEnabled = false;
u.ResetAllVelocity();
});
//
removableUnits.ForEach((u) => u.isEnabled = false);
RemoveConnected(
new List(removableUnits.ToArray()),
new List(removableUnitJoints.ToArray())
);
removableUnits.Clear();
removableConnectedUnits.Clear();
removableUnitJoints.Clear();
}
else
{
if(waitRemoveConnectUnits)
{
units.ForEach((u) => u.isEnabled = true);
waitRemoveConnectUnits = false;
}
}
}
void UpdateRemovableUnitJoints()
{
if(removableConnectedUnits.IsNullOrEmpty())return;
//
checkedPairs.Clear();
int pairId = 0;
VUnit unit1 = null;
VUnit unit2 = null;
UnitBallJoint joint = null;
Vector3 pos1 = Constants.V0;
Vector3 pos2 = Constants.V0;
float dist = 0.0f;
int distCache = 0;
float distPxl = 0.0f;
float distSpanPxl = 0.0f;
int jointType = 0;
int total = removableConnectedUnits.Count;
int i, j;
for(i = 0; i < total; i += 1)
{
unit1 = removableConnectedUnits[i];
for(j = total - 1; j > -1; j -= 1)
{
if(i == j)continue;
//
unit2 = removableConnectedUnits[j];
pairId = (1 + Mathf.Max(unit1.hash, unit2.hash)) * 1000 + (1 + Mathf.Min(unit1.hash, unit2.hash));
if(checkedPairs.Contains(pairId))continue;
checkedPairs.Add(pairId);
//
pos1 = unit1.pos;
pos2 = unit2.pos;
dist = Vector3.Distance(pos1, pos2);
if(dist > env.unitDistanceMax)
{
RemoveJoint(pairId);
continue;
}
distCache = Mathf.FloorToInt(dist * 10000);
if(cachedJointDistances.ContainsKey(pairId) && cachedJointDistances[pairId] == distCache)continue;
cachedJointDistances[pairId] = distCache;
distPxl = dist * env.TO_PXL;
distSpanPxl = distPxl - env.unitPxlSize;
//
if(!cachedJoints.ContainsKey(pairId))cachedJoints[pairId] = SpawnJoint(env.canvas, unit1.shape);
joint = cachedJoints[pairId];
jointType = env.CalcJointType(distSpanPxl);
joint.UpdateLinks(unit1.shape, jointType, pos1, pos2, (distPxl - env.unitSpansEx[jointType]) * env.TO_UNT);
}
}
}
void RemoveJoint(int pairId, bool forceRemoveJoint=false)
{
if(cachedJointDistances.ContainsKey(pairId))cachedJointDistances.Remove(pairId);
if(cachedJoints.ContainsKey(pairId))
{
if(forceRemoveJoint)
{
removableUnitJoints.Add(cachedJoints[pairId]);
}
else
{
cachedJoints[pairId].Release();
}
cachedJoints.Remove(pairId);
}
}
void RemoveConnected(List units, List joints)
{
// TODO: 이 흐름을 더 합리적으로
List sideEffectedUnits = new List();
units.ForEach((u) => {
if(u.type != UnitType.Stone && u.type != UnitType.Minion)
{
sideEffectedUnits.AddRange(FindNeighborUnits(units, u, env.explodeDistanceMax));
}
KillUnit(u);
ReleaseUnit(u);
});
joints.ForEach((j) => j.Release(false));
//
sideEffectedUnits = sideEffectedUnits.Distinct().Where((u) => (u != null && !u.willRemove)).ToList();
if(sideEffectedUnits.IsNullOrEmpty())
{
units.ForEach((u) => u.isEnabled = true);
waitRemoveConnectUnits = false;
}
else
{
TaskUtil.DelayCall(0.15f, () => {
sideEffectedUnits.ForEach((u) => {
// if(u.type == UnitType.Ball && (u.behavior == UnitBehaviorType.Anchor || u.behavior == UnitBehaviorType.Freeze))
if(u.behavior == UnitBehaviorType.Anchor || u.behavior == UnitBehaviorType.Freeze)
{
ActKillUnit(u);
}
else if(u.type == UnitType.Bomb)
{
ActBomb(u);
}
else if(u.type == UnitType.Stone)
{
ActKillUnit(u);
}
});
});
}
}
//====================
// BusJam 류의 퍼즐 게임의 일부입니다.
//====================
async UniTaskVoid CheckReady(Action onChecked=null)
{
waitGameReady = true;
//
ctsCheckReady = new CancellationTokenSource();
var linkedTokenSource = CancellationTokenSource.CreateLinkedTokenSource(ctsCheckReady.Token, gameObject.GetCancellationTokenOnDestroy());
// unit이 움직이고
if(refUnits.Any((g) => g.isBusy))
{
await UniTask.WaitUntil(() => !refUnits.Any((g) => g.isBusy), cancellationToken: linkedTokenSource.Token);
UpdateMovable();
}
//
while(true)
{
// box가 클리어면 나가고 다음것 들어오고
if(boxes[0].isComplete)
{
MoveBox();
if(boxes.Any((g) => g.isBusy))
{
await UniTask.WaitUntil(() => !boxes.Any((g) => g.isBusy), cancellationToken: linkedTokenSource.Token);
UpdateMovable();
}
boxes.RemoveAt(0);
// 새로운 box에 맞는 unit이 슬롯에 있으면 이동시키고
if(!boxes.IsNullOrEmpty())
{
CheckSlotUnitMove();
if(refUnits.Any((g) => g.isBusy))
{
await UniTask.WaitUntil(() => !refUnits.Any((g) => g.isBusy), cancellationToken: linkedTokenSource.Token);
UpdateMovable();
}
}
// 다음 box가 없으면 클리어
if(boxes.IsNullOrEmpty())
{
ResetCancellationTokenSource();
WillGameClear();
return;
}
}
if(!boxes[0].isComplete)break;
}
// slot이 꽉찼으면 게임오버
if(env.slot.slotRemain == 0)
{
ResetCancellationTokenSource();
WillGameOver();
return;
}
// unit 상태 변경
UpdateSecretState();
UpdateChainState();
UpdateFreezeState();
UpdateBombState();
await UniTask.WaitUntil(() => !refUnits.Any((g) => g.isBusy), cancellationToken: linkedTokenSource.Token);
// bomb터진 unit 있으면 게임오버
if(refUnits.Any((u) => u.isDead))
{
ResetCancellationTokenSource();
WillGameOver();
return;
}
// lock 상태 변경
if(lockGroups.Count > 0)
{
UpdateLockGroupState();
if(refUnits.Any((g) => g.isBusy))
{
await UniTask.WaitUntil(() => !refUnits.Any((g) => g.isBusy), cancellationToken: linkedTokenSource.Token);
}
if(refSpawners.Any((g) => g.isBusy))
{
await UniTask.WaitUntil(() => !refSpawners.Any((g) => g.isBusy), cancellationToken: linkedTokenSource.Token);
}
}
// spawner 동작
if(refSpawners.Count > 0 && refSpawners.Any((g) => g.canSpawn))
{
SpawnUnit();
// unit 상태 변경: 이건 생성된 유닛의 이동이 secret해제 애니랑 관계없다는 가정
UpdateSecretState();
if(refUnits.Any((g) => g.isBusy))
{
await UniTask.WaitUntil(() => !refUnits.Any((g) => g.isBusy), cancellationToken: linkedTokenSource.Token);
UpdateMovable();
}
if(refSpawners.Any((g) => g.isBusy))
{
await UniTask.WaitUntil(() => !refSpawners.Any((g) => g.isBusy), cancellationToken: linkedTokenSource.Token);
}
}
//
waitGameReady = false;
//
// NOTE: 최초 레벨 생성할때와 continue에서만 사용
if(onChecked != null)onChecked();
}