커스터머 스폰 관련 데이터와 스테이트 분리

This commit is contained in:
Jeonghyeon Ha 2025-08-19 16:52:09 +09:00
parent 2102690ba0
commit 1fd5279cd2
24 changed files with 228 additions and 153 deletions

View File

@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: f5775216947354447bf06ea842721c73
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

Binary file not shown.

View File

@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: e5e868658773b4297852eba8396d6f73
NativeFormatImporter:
externalObjects: {}
mainObjectFileID: 11400000
userData:
assetBundleName:
assetBundleVariant:

Binary file not shown.

Binary file not shown.

View File

@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: a335d9a9b913a4e8292845e8a6362dd9
NativeFormatImporter:
externalObjects: {}
mainObjectFileID: 11400000
userData:
assetBundleName:
assetBundleVariant:

Binary file not shown.

View File

@ -5,14 +5,12 @@ namespace DDD
public class RestaurantNpcCharacter : RestaurantCharacter public class RestaurantNpcCharacter : RestaurantCharacter
{ {
protected BehaviorTree _behaviorTree; protected BehaviorTree _behaviorTree;
protected SpineController _spineController;
protected override void Awake() protected override void Awake()
{ {
base.Awake(); base.Awake();
_behaviorTree = GetComponent<BehaviorTree>(); _behaviorTree = GetComponent<BehaviorTree>();
_spineController = GetComponent<SpineController>();
} }
} }
} }

View File

@ -8,9 +8,12 @@ public class RestaurantCharacter : MonoBehaviour, IGameCharacter, IInteractor
[EnumToggleButtons, SerializeField] protected InteractionType _interactionType; [EnumToggleButtons, SerializeField] protected InteractionType _interactionType;
RestaurantCharacterInteraction _interactionComponent; RestaurantCharacterInteraction _interactionComponent;
protected SpineController _spineController;
protected virtual void Awake() protected virtual void Awake()
{ {
_interactionComponent = GetComponent<RestaurantCharacterInteraction>(); _interactionComponent = GetComponent<RestaurantCharacterInteraction>();
_spineController = GetComponent<SpineController>();
} }
protected virtual void Start() protected virtual void Start()

View File

@ -1,3 +1,7 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks; using System.Threading.Tasks;
using UnityEngine; using UnityEngine;
@ -5,30 +9,133 @@ namespace DDD
{ {
public class RestaurantRunController : FlowController public class RestaurantRunController : FlowController
{ {
RestaurantCustomerState _restaurantCustomerStateSo; private RestaurantCustomerState _restaurantCustomerStateSo;
private RestaurantRunState _restaurantRunStateSo;
private CancellationTokenSource _cts;
// Runtime dependencies used by the execution logic
private LevelDataSo _levelDataSo;
private CustomerDataSo _customerDataSo;
private CustomerPoolDataSo _customerPoolDataSo;
private ICustomerFactory _iCustomerFactory;
private ISpawnPointProvider _spawnPointProvider;
// Debug-only: last built schedule (kept in controller to keep State pure)
private SpawnSchedule _spawnSchedule;
public override Task InitializeController() public override Task InitializeController()
{ {
_restaurantCustomerStateSo = RestaurantState.Instance.CustomerState; _restaurantCustomerStateSo = RestaurantState.Instance.CustomerState;
_restaurantRunStateSo = RestaurantState.Instance.RunState;
return Task.CompletedTask; return Task.CompletedTask;
} }
public override Task InitializeState() public override Task InitializeState()
{ {
_spawnPointProvider ??= new SpawnPointProvider();
_restaurantRunStateSo.InitializeSpawnPoint(_spawnPointProvider.GetSpawnPoint());
return Task.CompletedTask; return Task.CompletedTask;
} }
public override async Task OnReadyNewFlow(GameFlowState newFlowState) public override async Task OnReadyNewFlow(GameFlowState newFlowState)
{ {
var restaurantCustomerStateHandle = _restaurantCustomerStateSo.OnReadyNewFlow(newFlowState); if (newFlowState == GameFlowState.RunRestaurant)
await Task.WhenAll(restaurantCustomerStateHandle); {
_cts?.Cancel();
_cts?.Dispose();
_cts = new CancellationTokenSource();
await StartSpawnLoopAsync(_cts.Token);
}
} }
public override async Task OnExitCurrentFlow(GameFlowState exitingFlowState)
public override Task OnExitCurrentFlow(GameFlowState exitingFlowState)
{ {
if (exitingFlowState == GameFlowState.RunRestaurant) if (exitingFlowState == GameFlowState.RunRestaurant)
{ {
var restaurantCustomerStateHandle = _restaurantCustomerStateSo.OnExitCurrentFlow(exitingFlowState); _cts?.Cancel();
await Task.WhenAll(restaurantCustomerStateHandle); _cts?.Dispose();
_cts = null;
} }
return Task.CompletedTask;
}
private async Task StartSpawnLoopAsync(CancellationToken token)
{
_iCustomerFactory ??= new CustomerFactory();
var currentGameLevel = GameState.Instance.LevelState.Level;
_levelDataSo ??= DataManager.Instance.GetDataSo<LevelDataSo>();
_customerDataSo ??= DataManager.Instance.GetDataSo<CustomerDataSo>();
_customerPoolDataSo ??= DataManager.Instance.GetDataSo<CustomerPoolDataSo>();
var currentLevelData = _levelDataSo.GetDataList().FirstOrDefault(data => data.Level == currentGameLevel);
Debug.Assert(currentLevelData != null, "currentLevelData is null");
if (currentLevelData == null) return; // 안전 가드
var normalPool = _customerPoolDataSo.GetDataById(currentLevelData.CustomerPool);
var specialPool = _customerPoolDataSo.GetDataById(currentLevelData.SpecialCustomerPool);
await RunSpawnLoopAsync(currentLevelData, normalPool, specialPool, token);
}
private async Task RunSpawnLoopAsync(LevelData levelData, CustomerPoolData normalPool, CustomerPoolData specialPool, CancellationToken token)
{
var runData = RestaurantData.Instance? RestaurantData.Instance.RunData : null;
float firstDelay = Mathf.Max(0f, runData? runData.FirstSpawnDelaySeconds : 5f);
if (firstDelay > 0)
{
await Awaitable.WaitForSecondsAsync(firstDelay, token);
}
var scheduleBuilder = CreateBuilder(levelData.SpawnType);
int randomSeed = Environment.TickCount;
SpawnSchedule MakeSchedule() => scheduleBuilder.Build(new SpawnScheduleBuildArgs
{
NormalIds = (IReadOnlyList<string>) (normalPool?.ValidCustomers) ?? Array.Empty<string>(),
SpecialIds = (IReadOnlyList<string>) (specialPool?.ValidCustomers) ?? Array.Empty<string>(),
NormalQuota = Math.Max(0, normalPool?.CustomerLimitCount ?? 0),
SpecialQuota = Math.Max(0, specialPool?.CustomerLimitCount ?? 0),
Seed = ++randomSeed
});
_spawnSchedule = MakeSchedule();
float wait = Mathf.Max(0.1f, levelData.CustomerRespawnTime);
while (token.IsCancellationRequested == false)
{
if (Application.isPlaying == false)
{
break;
}
if (_spawnSchedule.TryDequeue(out var customerId) == false) break;
if (_customerDataSo.TryGetDataById(customerId, out var customerData))
{
var rotation = Quaternion.identity;
_ = _iCustomerFactory.CreateAsync(new CustomerSpawnArgs
{
CustomerData = customerData,
Position = _restaurantRunStateSo.SpawnPoint,
Rotation = rotation,
Parent = null
});
}
await Task.Delay(TimeSpan.FromSeconds(wait), token);
}
}
private static ISpawnScheduleBuilder CreateBuilder(SpawnType type)
{
return type switch
{
SpawnType.Random => new RandomSpawnScheduleBuilder(),
SpawnType.Regular => new RegularSpawnScheduleBuilder(),
_ => new RandomSpawnScheduleBuilder()
};
} }
} }
} }

View File

@ -29,7 +29,21 @@ public async Task<GameObject> CreateAsync(CustomerSpawnArgs args)
{ {
if (!_customerPrefab) if (!_customerPrefab)
{ {
_customerPrefab = await AssetManager.LoadAsset<GameObject>(DataConstants.CustomerNpcPrefab); var customerDataAsset = RestaurantData.Instance ? RestaurantData.Instance.CustomerData : null;
if (customerDataAsset == null || customerDataAsset.CustomerPrefab == null)
{
Debug.LogError("[CustomerFactory] RestaurantCustomerData or its CustomerPrefab reference is not set or not loaded.");
return null;
}
var handle = customerDataAsset.CustomerPrefab.LoadAssetAsync<GameObject>();
await handle.Task;
if (handle.Result == null)
{
Debug.LogError("[CustomerFactory] Failed to load customer prefab from AssetReference.");
return null;
}
_customerPrefab = handle.Result;
} }
var newCustomer = Object.Instantiate(_customerPrefab, args.Position, args.Rotation, args.Parent); var newCustomer = Object.Instantiate(_customerPrefab, args.Position, args.Rotation, args.Parent);

View File

@ -2,7 +2,7 @@
namespace DDD namespace DDD
{ {
[CreateAssetMenu(fileName = "RestaurantControllerData", menuName = "ScriptableObjects/RestaurantControllerData", order = 0)] [CreateAssetMenu(fileName = "RestaurantControllerData", menuName = "RestaurantData/RestaurantControllerData", order = 0)]
public class RestaurantControllerDataSo : ScriptableObject public class RestaurantControllerDataSo : ScriptableObject
{ {

View File

@ -0,0 +1,14 @@
using UnityEngine;
using UnityEngine.AddressableAssets;
using UnityEngine.ResourceManagement.ResourceProviders;
namespace DDD
{
[CreateAssetMenu(fileName = "RestaurantCustomerData", menuName = "RestaurantData/RestaurantCustomerData", order = 0)]
public class RestaurantCustomerData : ScriptableObject
{
[SerializeField] private AssetReferenceGameObject _customerPrefab;
public AssetReferenceGameObject CustomerPrefab => _customerPrefab;
}
}

View File

@ -0,0 +1,3 @@
fileFormatVersion: 2
guid: 3e9cf283e323467ea2e67b94905fbfcd
timeCreated: 1755587223

View File

@ -0,0 +1,15 @@
using Sirenix.OdinInspector;
using UnityEngine;
namespace DDD
{
[CreateAssetMenu(fileName = "RestaurantRunData", menuName = "RestaurantData/RestaurantRunData")]
public class RestaurantRunData : ScriptableObject
{
[Title("스폰 제어 (Persistent Data)")]
[Tooltip("플로우 시작 후 첫 손님이 등장하기까지 대기 시간(초)")]
[SerializeField] private float _firstSpawnDelaySeconds = 5f;
public float FirstSpawnDelaySeconds => _firstSpawnDelaySeconds;
}
}

View File

@ -0,0 +1,3 @@
fileFormatVersion: 2
guid: d0a7d53d11af4847860694610b3138f2
timeCreated: 1755583002

View File

@ -9,9 +9,13 @@ public class RestaurantData : ScriptSingleton<RestaurantData>
{ {
[SerializeField] private AssetReference _restaurantPlayerData; [SerializeField] private AssetReference _restaurantPlayerData;
[SerializeField] private AssetReference _restaurantManagementData; [SerializeField] private AssetReference _restaurantManagementData;
[SerializeField] private AssetReference _restaurantRunData;
[SerializeField] private AssetReference _restaurantCustomerData;
public RestaurantPlayerData PlayerData { get; private set; } public RestaurantPlayerData PlayerData { get; private set; }
public RestaurantManagementData ManagementData { get; private set; } public RestaurantManagementData ManagementData { get; private set; }
public RestaurantRunData RunData { get; private set; }
public RestaurantCustomerData CustomerData { get; private set; }
private bool _isLoaded; private bool _isLoaded;
@ -24,15 +28,25 @@ public async Task LoadData()
var restaurantPlayerDataHandle = _restaurantPlayerData.LoadAssetAsync<RestaurantPlayerData>(); var restaurantPlayerDataHandle = _restaurantPlayerData.LoadAssetAsync<RestaurantPlayerData>();
var restaurantManagementDataHandle = _restaurantManagementData.LoadAssetAsync<RestaurantManagementData>(); var restaurantManagementDataHandle = _restaurantManagementData.LoadAssetAsync<RestaurantManagementData>();
var restaurantRunDataHandle = _restaurantRunData.LoadAssetAsync<RestaurantRunData>();
var restaurantCustomerDataHandle = _restaurantCustomerData.LoadAssetAsync<RestaurantCustomerData>();
await restaurantPlayerDataHandle.Task; await Task.WhenAll(
await restaurantManagementDataHandle.Task; restaurantPlayerDataHandle.Task,
restaurantManagementDataHandle.Task,
restaurantRunDataHandle.Task,
restaurantCustomerDataHandle.Task
);
PlayerData = restaurantPlayerDataHandle.Result; PlayerData = restaurantPlayerDataHandle.Result;
ManagementData = restaurantManagementDataHandle.Result; ManagementData = restaurantManagementDataHandle.Result;
RunData = restaurantRunDataHandle.Result;
CustomerData = restaurantCustomerDataHandle.Result;
Debug.Assert(PlayerData != null, "RestaurantPlayerData is null"); Debug.Assert(PlayerData != null, "RestaurantPlayerData is null");
Debug.Assert(ManagementData != null, "RestaurantManagementData is null"); Debug.Assert(ManagementData != null, "RestaurantManagementData is null");
Debug.Assert(RunData != null, "RestaurantRunData is null");
Debug.Assert(CustomerData != null, "RestaurantCustomerData is null");
_isLoaded = true; _isLoaded = true;
} }
@ -43,6 +57,8 @@ private void OnDisable()
_restaurantPlayerData.ReleaseAsset(); _restaurantPlayerData.ReleaseAsset();
_restaurantManagementData.ReleaseAsset(); _restaurantManagementData.ReleaseAsset();
_restaurantRunData.ReleaseAsset();
_restaurantCustomerData.ReleaseAsset();
_isLoaded = false; _isLoaded = false;
} }

View File

@ -1,140 +1,9 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Sirenix.OdinInspector; using Sirenix.OdinInspector;
using UnityEngine; using UnityEngine;
namespace DDD namespace DDD
{ {
public class RestaurantCustomerState : ScriptableObject, IGameFlowHandler public class RestaurantCustomerState : ScriptableObject
{ {
[Title("스폰 제어")]
[Tooltip("플로우 시작 후 첫 손님이 등장하기까지 대기 시간(초)")]
[SerializeField] private float _firstSpawnDelaySeconds = 5f;
[SerializeField] private Vector3 _spawnPoint = new(5f, 0f, 4f);
[Title("디버그")]
[ReadOnly, SerializeField] private SpawnSchedule _spawnSchedule;
private LevelDataSo _levelDataSo;
private CustomerDataSo _customerDataSo;
private CustomerPoolDataSo _customerPoolDataSo;
private ICustomerFactory _iCustomerFactory;
private CancellationTokenSource _spawnLoopCancellationTokenSource;
public async Task OnReadyNewFlow(GameFlowState newFlowState)
{
if (newFlowState == GameFlowState.RunRestaurant)
{
await InitializeRunRestaurant();
}
}
public Task OnExitCurrentFlow(GameFlowState exitingFlowState)
{
if (exitingFlowState == GameFlowState.RunRestaurant)
{
_spawnLoopCancellationTokenSource?.Cancel();
_spawnLoopCancellationTokenSource?.Dispose();
_spawnLoopCancellationTokenSource = null;
}
return Task.CompletedTask;
}
private async Task InitializeRunRestaurant()
{
_iCustomerFactory = new CustomerFactory();
var currentGameLevel = GameState.Instance.LevelState.Level;
if (_levelDataSo == null)
{
_levelDataSo = DataManager.Instance.GetDataSo<LevelDataSo>();
}
if (_customerDataSo == null)
{
_customerDataSo = DataManager.Instance.GetDataSo<CustomerDataSo>();
}
if (_customerPoolDataSo == null)
{
_customerPoolDataSo = DataManager.Instance.GetDataSo<CustomerPoolDataSo>();
}
var currentLevelData = _levelDataSo.GetDataList().FirstOrDefault(data => data.Level == currentGameLevel);
Debug.Assert(currentLevelData != null, "currentLevelData is null");
var normalPool = _customerPoolDataSo.GetDataById(currentLevelData.CustomerPool);
var specialPool = _customerPoolDataSo.GetDataById(currentLevelData.SpecialCustomerPool);
_spawnLoopCancellationTokenSource?.Cancel();
_spawnLoopCancellationTokenSource = new CancellationTokenSource();
await RunSpawnLoopAsync(currentLevelData, normalPool, specialPool, _spawnLoopCancellationTokenSource.Token);
}
private async Task RunSpawnLoopAsync(LevelData levelData, CustomerPoolData normalPool, CustomerPoolData specialPool, CancellationToken token)
{
if (_firstSpawnDelaySeconds > 0)
{
await Awaitable.WaitForSecondsAsync(_firstSpawnDelaySeconds, token);
}
var scheduleBuilder = CreateBuilder(levelData.SpawnType);
int randomSeed = Environment.TickCount;
SpawnSchedule MakeSchedule() => scheduleBuilder.Build(new SpawnScheduleBuildArgs
{
NormalIds = (IReadOnlyList<string>) (normalPool?.ValidCustomers) ?? Array.Empty<string>(),
SpecialIds = (IReadOnlyList<string>) (specialPool?.ValidCustomers) ?? Array.Empty<string>(),
NormalQuota = Math.Max(0, normalPool?.CustomerLimitCount ?? 0),
SpecialQuota = Math.Max(0, specialPool?.CustomerLimitCount ?? 0),
Seed = ++randomSeed
});
_spawnSchedule = MakeSchedule();
float wait = Mathf.Max(0.1f, levelData.CustomerRespawnTime);
while (token.IsCancellationRequested == false)
{
if (Application.isPlaying == false)
{
_spawnLoopCancellationTokenSource?.Cancel();
_spawnLoopCancellationTokenSource?.Dispose();
_spawnLoopCancellationTokenSource = null;
break;
}
if (_spawnSchedule.TryDequeue(out var customerId) == false) break;
if (_customerDataSo.TryGetDataById(customerId, out var customerData))
{
var rotation = Quaternion.identity;
_ = _iCustomerFactory.CreateAsync(new CustomerSpawnArgs
{
CustomerData = customerData,
Position = _spawnPoint,
Rotation = rotation,
Parent = null
});
}
await Task.Delay(TimeSpan.FromSeconds(wait), token);
}
}
private ISpawnScheduleBuilder CreateBuilder(SpawnType type)
{
return type switch
{
SpawnType.Random => new RandomSpawnScheduleBuilder(),
SpawnType.Regular => new RegularSpawnScheduleBuilder(),
_ => new RandomSpawnScheduleBuilder()
};
}
} }
} }

View File

@ -4,6 +4,12 @@ namespace DDD
{ {
public class RestaurantRunState : ScriptableObject public class RestaurantRunState : ScriptableObject
{ {
private Vector3 _spawnPoint = new(5f, 0f, 4f);
public Vector3 SpawnPoint => _spawnPoint;
public void InitializeSpawnPoint(Vector3 spawnPoint)
{
_spawnPoint = spawnPoint;
}
} }
} }

View File

@ -23,7 +23,6 @@ public static class DataConstants
public const string TasteDataSo = "TasteDataSo"; public const string TasteDataSo = "TasteDataSo";
public const string EnvironmentDataSo = "EnvironmentDataSo"; public const string EnvironmentDataSo = "EnvironmentDataSo";
public const string LevelDataSo = "LevelDataSo"; public const string LevelDataSo = "LevelDataSo";
public const string CustomerDataSo = "CustomerDataSo";
public const string CustomerPoolDataSo = "CustomerPoolDataSo"; public const string CustomerPoolDataSo = "CustomerPoolDataSo";
public const string UiInputBindingSo = "UiInputBindingSo"; public const string UiInputBindingSo = "UiInputBindingSo";
@ -31,8 +30,6 @@ public static class DataConstants
public const string AtlasLabel = "Atlas"; public const string AtlasLabel = "Atlas";
public const string BasePropSpriteMaterial = "BasePropSpriteMaterial"; public const string BasePropSpriteMaterial = "BasePropSpriteMaterial";
public const string CustomerNpcPrefab = "CustomerNpc";
} }
public static class RestaurantPlayerAnimationType public static class RestaurantPlayerAnimationType