diff --git a/Assets/Scripts/DI/GameLifetimeScope.cs b/Assets/Scripts/DI/GameLifetimeScope.cs index 791074a..4608c1f 100644 --- a/Assets/Scripts/DI/GameLifetimeScope.cs +++ b/Assets/Scripts/DI/GameLifetimeScope.cs @@ -11,6 +11,7 @@ using YachtDice.Modifiers.Definition; using YachtDice.Modifiers.Pipeline; using YachtDice.Modifiers.Runtime; using YachtDice.Player; +using YachtDice.Run; using YachtDice.Scoring; using YachtDice.Shop; using YachtDice.UI; @@ -25,6 +26,7 @@ namespace YachtDice.DI [SerializeField] private CategoryCatalog categoryCatalog; [SerializeField] private DiceCatalog diceCatalog; [SerializeField] private ShopCatalog shopCatalog; + [SerializeField] private RunBalanceConfigSO runBalanceConfig; [Header("Scene References")] [SerializeField] private ScoringSystem scoringSystem; @@ -45,6 +47,7 @@ namespace YachtDice.DI builder.RegisterInstance(categoryCatalog); builder.RegisterInstance(diceCatalog); builder.RegisterInstance(shopCatalog); + builder.RegisterInstance(runBalanceConfig != null ? runBalanceConfig : RunBalanceConfigSO.CreateDefault()); // Core modifier services builder.Register(Lifetime.Singleton) @@ -62,6 +65,10 @@ namespace YachtDice.DI // Shop builder.Register(Lifetime.Singleton); + // Run loop + builder.Register(Lifetime.Singleton); + builder.Register(Lifetime.Singleton); + // Presentation services builder.Register(Lifetime.Singleton); builder.Register(Lifetime.Singleton); diff --git a/Assets/Scripts/Game/GameLoopController.cs b/Assets/Scripts/Game/GameLoopController.cs index 1118379..22daeb8 100644 --- a/Assets/Scripts/Game/GameLoopController.cs +++ b/Assets/Scripts/Game/GameLoopController.cs @@ -2,58 +2,105 @@ using System; using UnityEngine; using VContainer; using YachtDice.Categories; +using YachtDice.Run; using YachtDice.Scoring; namespace YachtDice.Game { public class GameLoopController : MonoBehaviour { - [Header("Settings")] - [SerializeField] private int maxRollsPerTurn = 3; - private DiceManager _diceManager; + private RunLoopService _runLoopService; private ScoringSystem _scoringSystem; - public int CurrentRoll { get; private set; } - public int CurrentTurn { get; private set; } - public int MaxRollsPerTurn => maxRollsPerTurn; + public int CurrentRoll => _runLoopService != null ? _runLoopService.State.CurrentRoll : 0; + public int CurrentTurn => _runLoopService != null ? _runLoopService.State.StageNumber : 0; + public int CurrentBet => _runLoopService != null ? _runLoopService.State.BetIndex : 0; + public int CurrentStage => _runLoopService != null ? _runLoopService.State.StageNumber : 0; + public int CurrentStageTarget => _runLoopService != null ? _runLoopService.State.CurrentStageTarget : 0; + public int CurrentBaseQuota => _runLoopService != null ? _runLoopService.State.BaseQuota : 0; + public int StoredRolls => _runLoopService != null ? _runLoopService.State.StoredRolls : 0; + public int MaxRollsPerTurn => _runLoopService != null ? _runLoopService.State.CurrentStageRollBudget : 0; + public RunPhase CurrentPhase => _runLoopService != null ? _runLoopService.State.Phase : RunPhase.None; - public bool CanRoll => CurrentRoll < maxRollsPerTurn && !_diceManager.IsAnyRolling; - public bool CanScore => CurrentRoll > 0 && !_diceManager.IsAnyRolling; - public bool IsGameOver => _scoringSystem.IsComplete; + public bool CanRoll => _runLoopService != null && _runLoopService.CanRoll() && !_diceManager.IsAnyRolling; + public bool CanScore => _runLoopService != null && CurrentPhase == RunPhase.CategorySelection && !_diceManager.IsAnyRolling; + public bool IsGameOver => _runLoopService != null && _runLoopService.State.IsFailed; + public bool IsShopOpen => CurrentPhase == RunPhase.Shop; public event Action OnTurnStarted; public event Action OnRollComplete; public event Action OnScored; public event Action OnGameOver; + public event Action OnBetStarted; + public event Action OnShopOpened; + public event Action OnShopClosed; + public event Action OnStoredRollsChanged; + public event Action OnCurrencyChanged; + public event Action OnQuotaChanged; + public event Action OnCycleCompleted; + public event Action OnPhaseChanged; + [Inject] - public void Construct(DiceManager diceManager, ScoringSystem scoringSystem) + public void Construct(DiceManager diceManager, RunLoopService runLoopService, ScoringSystem scoringSystem) { - this._diceManager = diceManager; - this._scoringSystem = scoringSystem; + _diceManager = diceManager; + _runLoopService = runLoopService; + _scoringSystem = scoringSystem; + + _runLoopService.OnStageStarted += HandleStageStarted; + _runLoopService.OnStageCleared += HandleStageCleared; + _runLoopService.OnRunFailed += HandleRunFailed; + _runLoopService.OnBetStarted += HandleBetStarted; + _runLoopService.OnShopOpened += HandleShopOpened; + _runLoopService.OnStoredRollsChanged += HandleStoredRollsChanged; + _runLoopService.OnCurrencyChanged += HandleCurrencyChanged; + _runLoopService.OnQuotaChanged += HandleQuotaChanged; + _runLoopService.OnCycleCompleted += HandleCycleCompleted; + _runLoopService.OnPhaseChanged += HandlePhaseChanged; + } + + private void OnDestroy() + { + if (_runLoopService == null) + return; + + _runLoopService.OnStageStarted -= HandleStageStarted; + _runLoopService.OnStageCleared -= HandleStageCleared; + _runLoopService.OnRunFailed -= HandleRunFailed; + _runLoopService.OnBetStarted -= HandleBetStarted; + _runLoopService.OnShopOpened -= HandleShopOpened; + _runLoopService.OnStoredRollsChanged -= HandleStoredRollsChanged; + _runLoopService.OnCurrencyChanged -= HandleCurrencyChanged; + _runLoopService.OnQuotaChanged -= HandleQuotaChanged; + _runLoopService.OnCycleCompleted -= HandleCycleCompleted; + _runLoopService.OnPhaseChanged -= HandlePhaseChanged; } public void StartNewGame() { - _scoringSystem.ResetScorecard(); - CurrentTurn = 0; - StartNewTurn(); + _diceManager.UnlockAll(); + _runLoopService.StartNewRun(); } - private void StartNewTurn() + public void CompleteShop() { - CurrentTurn++; - CurrentRoll = 0; - _diceManager.UnlockAll(); - OnTurnStarted?.Invoke(CurrentTurn); + if (!IsShopOpen) + return; + + _runLoopService.CompleteShop(); + OnShopClosed?.Invoke(); } public void Roll() { - if (!CanRoll) return; + if (!CanRoll) + return; + + if (!_runLoopService.TryBeginRoll()) + return; - CurrentRoll++; _diceManager.OnAllDiceSettled += HandleAllDiceSettled; _diceManager.RollUnlocked(); } @@ -61,6 +108,7 @@ namespace YachtDice.Game private void HandleAllDiceSettled() { _diceManager.OnAllDiceSettled -= HandleAllDiceSettled; + _runLoopService.NotifyRollResolved(_diceManager.GetDice()); OnRollComplete?.Invoke(CurrentRoll); } @@ -71,25 +119,79 @@ namespace YachtDice.Game _diceManager.ToggleLock(index); } - public void ScoreInCategory(CategoryDefinition category) + public bool CanScoreCategory(CategoryDefinition category) { - if (!CanScore) return; - if (_scoringSystem.IsCategoryUsed(category)) return; + return _runLoopService != null && _runLoopService.CanScoreCategory(_diceManager.GetDice(), category); + } - var dice = _diceManager.GetDice(); - var result = _scoringSystem.ScoreCategory(dice, category); + public ScoreResult PreviewCategory(CategoryDefinition category) + { + return _runLoopService.PreviewScore(_diceManager.GetDice(), category); + } + public async void ScoreInCategory(CategoryDefinition category) + { + if (!CanScore) + return; + + await _runLoopService.TryScoreCategoryAsync(_diceManager.GetDice(), category); + } + + public bool CanOpenShopManually() + { + return IsShopOpen; + } + + private void HandleStageStarted(RunStageState stage) + { + _diceManager.UnlockAll(); + OnTurnStarted?.Invoke(stage.Index + 1); + } + + private void HandleStageCleared(RunStageState stage, CategoryDefinition category, ScoreResult result) + { + _diceManager.UnlockAll(); OnScored?.Invoke(category, result.FinalScore); + } - if (_scoringSystem.IsComplete) - { - var total = _scoringSystem.TotalScore; - OnGameOver?.Invoke(total); - } - else - { - StartNewTurn(); - } + private void HandleRunFailed(RunState state) + { + OnGameOver?.Invoke(_scoringSystem != null ? _scoringSystem.TotalScore : 0); + } + + private void HandleBetStarted(RunState state) + { + OnBetStarted?.Invoke(state.BetIndex); + } + + private void HandleShopOpened(RunState state) + { + OnShopOpened?.Invoke(); + } + + private void HandleStoredRollsChanged(int value) + { + OnStoredRollsChanged?.Invoke(value); + } + + private void HandleCurrencyChanged(int value) + { + OnCurrencyChanged?.Invoke(value); + } + + private void HandleQuotaChanged(int value) + { + OnQuotaChanged?.Invoke(value); + } + + private void HandleCycleCompleted(int bonus, int storedRolls) + { + OnCycleCompleted?.Invoke(bonus, storedRolls); + } + + private void HandlePhaseChanged(RunPhase phase) + { + OnPhaseChanged?.Invoke(phase); } } } diff --git a/Assets/Scripts/Run.meta b/Assets/Scripts/Run.meta new file mode 100644 index 0000000..31ec2a1 --- /dev/null +++ b/Assets/Scripts/Run.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: a4fce5c4a7443cb418371ed455b52146 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Scripts/Run/RunBalanceConfigSO.cs b/Assets/Scripts/Run/RunBalanceConfigSO.cs new file mode 100644 index 0000000..e7f27c9 --- /dev/null +++ b/Assets/Scripts/Run/RunBalanceConfigSO.cs @@ -0,0 +1,48 @@ +using UnityEngine; + +namespace YachtDice.Run +{ + [CreateAssetMenu(fileName = "RunBalanceConfig", menuName = "YachtDice/Run/Balance Config")] + public class RunBalanceConfigSO : ScriptableObject + { + [Header("Quota")] + [SerializeField] private int startingBaseQuota = 30; + [SerializeField] private int quotaGrowthPerCycle = 30; + + [Header("Stage Targets")] + [SerializeField] private float[] stageTargetMultipliers = { 1f, 1.5f, 2.5f }; + + [Header("Rewards")] + [SerializeField] private int stageClearReward = 25; + [SerializeField] private int cycleStoredRollBonusMultiplier = 10; + + [Header("Roll Economy")] + [SerializeField] private int baseRollsPerStage = 3; + + public int StartingBaseQuota => startingBaseQuota; + public int QuotaGrowthPerCycle => quotaGrowthPerCycle; + public int StageClearReward => stageClearReward; + public int CycleStoredRollBonusMultiplier => cycleStoredRollBonusMultiplier; + public int BaseRollsPerStage => baseRollsPerStage; + public int StageCount => stageTargetMultipliers != null ? stageTargetMultipliers.Length : 0; + + public int GetStageTarget(int baseQuota, int stageIndex) + { + if (stageTargetMultipliers == null || stageTargetMultipliers.Length == 0) + return baseQuota; + + var clampedIndex = Mathf.Clamp(stageIndex, 0, stageTargetMultipliers.Length - 1); + return Mathf.CeilToInt(baseQuota * stageTargetMultipliers[clampedIndex]); + } + + public int GetNextQuota(int currentQuota) + { + return currentQuota + quotaGrowthPerCycle; + } + + public static RunBalanceConfigSO CreateDefault() + { + return CreateInstance(); + } + } +} diff --git a/Assets/Scripts/Run/RunBalanceConfigSO.cs.meta b/Assets/Scripts/Run/RunBalanceConfigSO.cs.meta new file mode 100644 index 0000000..3d55fab --- /dev/null +++ b/Assets/Scripts/Run/RunBalanceConfigSO.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 5917f01e056f9c24988a1e0e726becc0 \ No newline at end of file diff --git a/Assets/Scripts/Run/RunLoopService.cs b/Assets/Scripts/Run/RunLoopService.cs new file mode 100644 index 0000000..c0b4b8f --- /dev/null +++ b/Assets/Scripts/Run/RunLoopService.cs @@ -0,0 +1,293 @@ +using System; +using System.Collections.Generic; +using Cysharp.Threading.Tasks; +using YachtDice.Categories; +using YachtDice.Dice; +using YachtDice.Economy; +using YachtDice.Scoring; + +namespace YachtDice.Run +{ + public sealed class RunLoopService + { + private readonly RunBalanceConfigSO _config; + private readonly ScoringSystem _scoringSystem; + private readonly CurrencyBank _currencyBank; + private readonly StoredRollBank _storedRollBank; + private readonly RunState _state = new(); + + public RunLoopService( + RunBalanceConfigSO config, + ScoringSystem scoringSystem, + CurrencyBank currencyBank, + StoredRollBank storedRollBank) + { + _config = config != null ? config : RunBalanceConfigSO.CreateDefault(); + _scoringSystem = scoringSystem; + _currencyBank = currencyBank; + _storedRollBank = storedRollBank; + + if (_storedRollBank != null) + _storedRollBank.OnChanged += HandleStoredRollsChanged; + + if (_currencyBank != null) + _currencyBank.OnBalanceChanged += HandleCurrencyChanged; + } + + public RunState State => _state; + public int CurrentCurrency => _currencyBank != null ? _currencyBank.Balance : 0; + + public event Action OnRunStarted; + public event Action OnBetStarted; + public event Action OnShopOpened; + public event Action OnStageStarted; + public event Action OnStageCleared; + public event Action OnStageFailed; + public event Action OnStoredRollsChanged; + public event Action OnCurrencyChanged; + public event Action OnCycleCompleted; + public event Action OnQuotaChanged; + public event Action OnRunFailed; + public event Action OnPhaseChanged; + public event Action OnCategoriesRefreshed; + + public void StartNewRun() + { + _scoringSystem.ResetScorecard(); + _storedRollBank.Reset(); + + _state.BaseQuota = _config.StartingBaseQuota; + _state.BetIndex = 0; + _state.StageIndex = 0; + _state.CurrentRoll = 0; + _state.CurrentStageRollBudget = _config.BaseRollsPerStage; + _state.CurrentStageTarget = _config.GetStageTarget(_state.BaseQuota, 0); + _state.StoredRolls = _storedRollBank.Value; + _state.IsActive = true; + _state.IsFailed = false; + SetPhase(RunPhase.None); + + OnRunStarted?.Invoke(_state); + StartNextBet(); + } + + public void CompleteShop() + { + if (_state.Phase != RunPhase.Shop || !_state.IsActive) + return; + + StartStage(0); + } + + public bool CanRoll() + { + if (!_state.IsActive || _state.IsFailed) + return false; + + return _state.Phase == RunPhase.StageStart + || _state.Phase == RunPhase.CategorySelection + || _state.Phase == RunPhase.Rolling; + } + + public bool TryBeginRoll() + { + if (!CanRoll()) + return false; + + if (_state.CurrentRoll >= _state.CurrentStageRollBudget) + { + if (!_storedRollBank.TrySpend(1)) + return false; + + _state.CurrentStageRollBudget += 1; + _state.StoredRolls = _storedRollBank.Value; + } + + _state.CurrentRoll += 1; + SetPhase(RunPhase.Rolling); + return true; + } + + public void NotifyRollResolved(IReadOnlyList dice) + { + if (!_state.IsActive || _state.IsFailed || _state.Phase != RunPhase.Rolling) + return; + + SetPhase(RunPhase.CategorySelection); + + if (!HasRemainingRollCapacity() && !HasAnyScorableCategory(dice)) + FailCurrentStage(); + } + + public bool CanScoreCategory(IReadOnlyList dice, CategoryDefinition category) + { + if (!_state.IsActive || _state.IsFailed || _state.Phase != RunPhase.CategorySelection) + return false; + + if (category == null || _scoringSystem.IsCategoryUsed(category)) + return false; + + if (_state.CurrentRoll <= 0) + return false; + + var preview = PreviewScore(dice, category); + return preview.FinalScore >= _state.CurrentStageTarget; + } + + public ScoreResult PreviewScore(IReadOnlyList dice, CategoryDefinition category) + { + return _scoringSystem.PreviewScore( + dice, + category, + _state.CurrentRoll, + GetProgressOrdinal(), + CurrentCurrency); + } + + public bool HasAnyScorableCategory(IReadOnlyList dice) + { + var catalog = _scoringSystem.Catalog; + if (catalog == null) + return false; + + var categories = catalog.All; + for (var i = 0; i < categories.Count; i++) + { + if (CanScoreCategory(dice, categories[i])) + return true; + } + + return false; + } + + public bool HasRemainingRollCapacity() + { + return _state.CurrentRoll < _state.CurrentStageRollBudget || _storedRollBank.Value > 0; + } + + public async UniTask TryScoreCategoryAsync(IReadOnlyList dice, CategoryDefinition category) + { + if (!CanScoreCategory(dice, category)) + return false; + + var clearedStage = GetCurrentStageState(); + var result = await _scoringSystem.ScoreCategoryAsync( + dice, + category, + _state.CurrentRoll, + GetProgressOrdinal(), + CurrentCurrency); + + var remainingRolls = Math.Max(0, _state.CurrentStageRollBudget - _state.CurrentRoll); + if (remainingRolls > 0) + _storedRollBank.Add(remainingRolls); + + if (_config.StageClearReward > 0 && _currencyBank != null) + _currencyBank.Add(_config.StageClearReward); + + SetPhase(RunPhase.StageResolved); + OnStageCleared?.Invoke(clearedStage, category, result); + + if (_state.StageIndex >= _config.StageCount - 1) + { + ResolveBet(); + } + else + { + StartStage(_state.StageIndex + 1); + } + + return true; + } + + public void FailCurrentStage() + { + if (!_state.IsActive || _state.IsFailed) + return; + + var failedStage = GetCurrentStageState(); + _state.IsActive = false; + _state.IsFailed = true; + SetPhase(RunPhase.RunFailed); + + OnStageFailed?.Invoke(failedStage); + OnRunFailed?.Invoke(_state); + } + + private void StartNextBet() + { + _state.BetIndex += 1; + _state.StageIndex = 0; + _state.CurrentRoll = 0; + _state.CurrentStageRollBudget = _config.BaseRollsPerStage; + _state.CurrentStageTarget = _config.GetStageTarget(_state.BaseQuota, 0); + + SetPhase(RunPhase.StartBet); + OnCategoriesRefreshed?.Invoke(); + OnBetStarted?.Invoke(_state); + + SetPhase(RunPhase.Shop); + OnShopOpened?.Invoke(_state); + } + + private void StartStage(int stageIndex) + { + _state.StageIndex = stageIndex; + _state.CurrentRoll = 0; + _state.CurrentStageRollBudget = _config.BaseRollsPerStage; + _state.CurrentStageTarget = _config.GetStageTarget(_state.BaseQuota, stageIndex); + + SetPhase(RunPhase.StageStart); + OnStageStarted?.Invoke(GetCurrentStageState()); + } + + private void ResolveBet() + { + SetPhase(RunPhase.BetResolved); + + if (_scoringSystem.IsComplete) + { + var bonus = _storedRollBank.Value * _config.CycleStoredRollBonusMultiplier; + if (bonus > 0 && _currencyBank != null) + _currencyBank.Add(bonus); + + OnCycleCompleted?.Invoke(bonus, _storedRollBank.Value); + + _scoringSystem.ResetScorecard(); + var nextQuota = _config.GetNextQuota(_state.BaseQuota); + _state.BaseQuota = nextQuota; + OnQuotaChanged?.Invoke(nextQuota); + SetPhase(RunPhase.CycleResolved); + } + + StartNextBet(); + } + + private RunStageState GetCurrentStageState() + { + return new RunStageState(_state.StageIndex, (RunStageType)Math.Min(_state.StageIndex, 2), _state.CurrentStageTarget); + } + + private int GetProgressOrdinal() + { + return ((_state.BetIndex - 1) * Math.Max(1, _config.StageCount)) + _state.StageIndex + 1; + } + + private void SetPhase(RunPhase phase) + { + _state.Phase = phase; + OnPhaseChanged?.Invoke(phase); + } + + private void HandleStoredRollsChanged(int value) + { + _state.StoredRolls = value; + OnStoredRollsChanged?.Invoke(value); + } + + private void HandleCurrencyChanged(int value) + { + OnCurrencyChanged?.Invoke(value); + } + } +} diff --git a/Assets/Scripts/Run/RunLoopService.cs.meta b/Assets/Scripts/Run/RunLoopService.cs.meta new file mode 100644 index 0000000..3335c19 --- /dev/null +++ b/Assets/Scripts/Run/RunLoopService.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 303eb3c5c68f41b4183853b80f5bdf0d \ No newline at end of file diff --git a/Assets/Scripts/Run/RunPhase.cs b/Assets/Scripts/Run/RunPhase.cs new file mode 100644 index 0000000..35cc401 --- /dev/null +++ b/Assets/Scripts/Run/RunPhase.cs @@ -0,0 +1,17 @@ +namespace YachtDice.Run +{ + public enum RunPhase + { + None, + StartBet, + Shop, + StageStart, + Rolling, + CategorySelection, + StageResolved, + BetResolved, + CycleResolved, + RunFailed, + RunCompleted, + } +} diff --git a/Assets/Scripts/Run/RunPhase.cs.meta b/Assets/Scripts/Run/RunPhase.cs.meta new file mode 100644 index 0000000..1351a40 --- /dev/null +++ b/Assets/Scripts/Run/RunPhase.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 3538676a7c72a1f4088981a1a33c6126 \ No newline at end of file diff --git a/Assets/Scripts/Run/RunStageState.cs b/Assets/Scripts/Run/RunStageState.cs new file mode 100644 index 0000000..a35ead4 --- /dev/null +++ b/Assets/Scripts/Run/RunStageState.cs @@ -0,0 +1,19 @@ +using System; + +namespace YachtDice.Run +{ + [Serializable] + public readonly struct RunStageState + { + public RunStageState(int index, RunStageType stageType, int target) + { + Index = index; + StageType = stageType; + Target = target; + } + + public int Index { get; } + public RunStageType StageType { get; } + public int Target { get; } + } +} diff --git a/Assets/Scripts/Run/RunStageState.cs.meta b/Assets/Scripts/Run/RunStageState.cs.meta new file mode 100644 index 0000000..e063982 --- /dev/null +++ b/Assets/Scripts/Run/RunStageState.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: efe1d4387ae5ef94da49a7fcd867a3cb \ No newline at end of file diff --git a/Assets/Scripts/Run/RunStageType.cs b/Assets/Scripts/Run/RunStageType.cs new file mode 100644 index 0000000..688858b --- /dev/null +++ b/Assets/Scripts/Run/RunStageType.cs @@ -0,0 +1,9 @@ +namespace YachtDice.Run +{ + public enum RunStageType + { + Base = 0, + Mid = 1, + Final = 2, + } +} diff --git a/Assets/Scripts/Run/RunStageType.cs.meta b/Assets/Scripts/Run/RunStageType.cs.meta new file mode 100644 index 0000000..8570ea9 --- /dev/null +++ b/Assets/Scripts/Run/RunStageType.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 279fbb0d4676dcf4eb0224a8535f8a86 \ No newline at end of file diff --git a/Assets/Scripts/Run/RunState.cs b/Assets/Scripts/Run/RunState.cs new file mode 100644 index 0000000..d9cc862 --- /dev/null +++ b/Assets/Scripts/Run/RunState.cs @@ -0,0 +1,24 @@ +using System; + +namespace YachtDice.Run +{ + [Serializable] + public sealed class RunState + { + public int BaseQuota; + public int BetIndex; + public int StageIndex; + public int CurrentStageTarget; + public int CurrentRoll; + public int CurrentStageRollBudget; + public int StoredRolls; + public bool IsActive; + public bool IsFailed; + public RunPhase Phase; + + public int StageNumber => StageIndex + 1; + public bool IsInShop => Phase == RunPhase.Shop; + public bool IsCategorySelection => Phase == RunPhase.CategorySelection; + public bool IsRollingState => Phase == RunPhase.StageStart || Phase == RunPhase.Rolling || Phase == RunPhase.CategorySelection; + } +} diff --git a/Assets/Scripts/Run/RunState.cs.meta b/Assets/Scripts/Run/RunState.cs.meta new file mode 100644 index 0000000..d9d93b3 --- /dev/null +++ b/Assets/Scripts/Run/RunState.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 308eb21a80d1c2a4283e6de6aac0d026 \ No newline at end of file diff --git a/Assets/Scripts/Run/StoredRollBank.cs b/Assets/Scripts/Run/StoredRollBank.cs new file mode 100644 index 0000000..8b44a34 --- /dev/null +++ b/Assets/Scripts/Run/StoredRollBank.cs @@ -0,0 +1,39 @@ +using System; + +namespace YachtDice.Run +{ + public sealed class StoredRollBank + { + public int Value { get; private set; } + + public event Action OnChanged; + + public void Reset() + { + SetValue(0); + } + + public void Add(int amount) + { + if (amount <= 0) + return; + + SetValue(Value + amount); + } + + public bool TrySpend(int amount) + { + if (amount <= 0 || Value < amount) + return false; + + SetValue(Value - amount); + return true; + } + + private void SetValue(int value) + { + Value = Math.Max(0, value); + OnChanged?.Invoke(Value); + } + } +} diff --git a/Assets/Scripts/Run/StoredRollBank.cs.meta b/Assets/Scripts/Run/StoredRollBank.cs.meta new file mode 100644 index 0000000..11f9b28 --- /dev/null +++ b/Assets/Scripts/Run/StoredRollBank.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: e46140b5dd26bfc469334aabd4ab51b8 \ No newline at end of file diff --git a/Assets/Scripts/Shop/ShopController.cs b/Assets/Scripts/Shop/ShopController.cs index a894666..ffd9d0b 100644 --- a/Assets/Scripts/Shop/ShopController.cs +++ b/Assets/Scripts/Shop/ShopController.cs @@ -54,6 +54,20 @@ namespace YachtDice.Shop shopView.Show(); } + public void Open() + { + if (shopView == null) return; + shopView.Show(); + } + + public void Close() + { + if (shopView == null) return; + shopView.Hide(); + } + + public bool IsOpen => shopView != null && shopView.IsVisible; + private void HandleBuyClicked(IShopItem item) { _model.TryPurchase(item); diff --git a/Assets/Scripts/Tests/Editor/RunLoopServiceTests.cs b/Assets/Scripts/Tests/Editor/RunLoopServiceTests.cs new file mode 100644 index 0000000..d88d784 --- /dev/null +++ b/Assets/Scripts/Tests/Editor/RunLoopServiceTests.cs @@ -0,0 +1,220 @@ +using System.Collections.Generic; +using NUnit.Framework; +using UnityEngine; +using YachtDice.Categories; +using YachtDice.Dice; +using YachtDice.Economy; +using YachtDice.Run; +using YachtDice.Scoring; + +namespace YachtDice.Tests +{ + public sealed class RunLoopServiceTests + { + private readonly List _createdAssets = new(); + private readonly IReadOnlyList _emptyDice = new IDice[0]; + + private CurrencyBank _currencyBank; + private ScoringSystem _scoringSystem; + private StoredRollBank _storedRollBank; + private RunBalanceConfigSO _config; + + [SetUp] + public void SetUp() + { + var bankGo = new GameObject("CurrencyBank"); + _currencyBank = bankGo.AddComponent(); + _currencyBank.SetBalance(0); + + var scoringGo = new GameObject("ScoringSystem"); + _scoringSystem = scoringGo.AddComponent(); + + _storedRollBank = new StoredRollBank(); + _config = RunBalanceConfigSO.CreateDefault(); + _createdAssets.Add(_config); + } + + [TearDown] + public void TearDown() + { + foreach (var scoring in Object.FindObjectsByType(FindObjectsSortMode.None)) + Object.DestroyImmediate(scoring.gameObject); + + foreach (var bank in Object.FindObjectsByType(FindObjectsSortMode.None)) + Object.DestroyImmediate(bank.gameObject); + + for (var i = 0; i < _createdAssets.Count; i++) + { + if (_createdAssets[i] != null) + Object.DestroyImmediate(_createdAssets[i]); + } + + _createdAssets.Clear(); + } + + [Test] + public void StartNewRun_EntersShopWithConfigQuota() + { + var categories = CreateFixedCatalog(100, 100, 100); + var service = CreateService(categories); + + service.StartNewRun(); + + Assert.AreEqual(30, service.State.BaseQuota); + Assert.AreEqual(1, service.State.BetIndex); + Assert.AreEqual(RunPhase.Shop, service.State.Phase); + Assert.AreEqual(0, service.State.StoredRolls); + } + + [Test] + public void ClearedStage_AwardsCurrencyAndBanksUnusedRolls() + { + var categories = CreateFixedCatalog(100, 100, 100); + var service = CreateService(categories); + + service.StartNewRun(); + service.CompleteShop(); + service.TryBeginRoll(); + service.NotifyRollResolved(_emptyDice); + + var cleared = service.TryScoreCategoryAsync(_emptyDice, categories.All[0]).GetAwaiter().GetResult(); + + Assert.IsTrue(cleared); + Assert.AreEqual(25, _currencyBank.Balance); + Assert.AreEqual(2, service.State.StoredRolls); + Assert.AreEqual(2, service.State.StageNumber); + Assert.AreEqual(RunPhase.StageStart, service.State.Phase); + } + + [Test] + public void StoredRolls_CanBeConsumedOnLaterStage() + { + var categories = CreateFixedCatalog(100, 100, 100); + var service = CreateService(categories); + + service.StartNewRun(); + service.CompleteShop(); + service.TryBeginRoll(); + service.NotifyRollResolved(_emptyDice); + service.TryScoreCategoryAsync(_emptyDice, categories.All[0]).GetAwaiter().GetResult(); + + for (var i = 0; i < 4; i++) + { + Assert.IsTrue(service.TryBeginRoll()); + service.NotifyRollResolved(_emptyDice); + } + + service.TryScoreCategoryAsync(_emptyDice, categories.All[1]).GetAwaiter().GetResult(); + + Assert.AreEqual(1, service.State.StoredRolls); + Assert.AreEqual(50, _currencyBank.Balance); + } + + [Test] + public void CompletedCycle_GrantsBonusRaisesQuotaAndStartsNextBet() + { + var categories = CreateFixedCatalog(100, 100, 100); + var service = CreateService(categories); + + service.StartNewRun(); + service.CompleteShop(); + + ClearStageInOneRoll(service, categories.All[0]); + ClearStageInOneRoll(service, categories.All[1]); + ClearStageInOneRoll(service, categories.All[2]); + + Assert.AreEqual(135, _currencyBank.Balance); + Assert.AreEqual(60, service.State.BaseQuota); + Assert.AreEqual(2, service.State.BetIndex); + Assert.AreEqual(RunPhase.Shop, service.State.Phase); + Assert.AreEqual(6, service.State.StoredRolls); + Assert.AreEqual(0, _scoringSystem.CategoriesFilledCount); + } + + [Test] + public void FailedStage_EndsRunWhenNoTargetCanBeMet() + { + var categories = CreateFixedCatalog(10); + var service = CreateService(categories); + + service.StartNewRun(); + service.CompleteShop(); + + for (var i = 0; i < 3; i++) + { + Assert.IsTrue(service.TryBeginRoll()); + service.NotifyRollResolved(_emptyDice); + } + + Assert.IsTrue(service.State.IsFailed); + Assert.AreEqual(RunPhase.RunFailed, service.State.Phase); + } + + [Test] + public void Shop_ReopensOnlyAtStartOfNextBet() + { + var categories = CreateFixedCatalog(100, 100, 100, 100); + var service = CreateService(categories); + + service.StartNewRun(); + Assert.AreEqual(RunPhase.Shop, service.State.Phase); + + service.CompleteShop(); + ClearStageInOneRoll(service, categories.All[0]); + Assert.AreEqual(RunPhase.StageStart, service.State.Phase); + + ClearStageInOneRoll(service, categories.All[1]); + Assert.AreEqual(RunPhase.StageStart, service.State.Phase); + + ClearStageInOneRoll(service, categories.All[2]); + Assert.AreEqual(RunPhase.Shop, service.State.Phase); + Assert.AreEqual(2, service.State.BetIndex); + } + + private RunLoopService CreateService(CategoryCatalog catalog) + { + _scoringSystem.Construct(null, null, catalog, _currencyBank); + return new RunLoopService(_config, _scoringSystem, _currencyBank, _storedRollBank); + } + + private CategoryCatalog CreateFixedCatalog(params int[] scores) + { + var categories = new List(); + for (var i = 0; i < scores.Length; i++) + { + var category = FixedScoreCategory.Create($"cat_{i}", scores[i]); + _createdAssets.Add(category); + categories.Add(category); + } + + var catalog = CategoryCatalog.CreateForTest(categories); + _createdAssets.Add(catalog); + return catalog; + } + + private void ClearStageInOneRoll(RunLoopService service, CategoryDefinition category) + { + Assert.IsTrue(service.TryBeginRoll()); + service.NotifyRollResolved(_emptyDice); + Assert.IsTrue(service.TryScoreCategoryAsync(_emptyDice, category).GetAwaiter().GetResult()); + } + + private sealed class FixedScoreCategory : CategoryDefinition + { + [SerializeField] private int fixedScore; + + public override int Calculate(IReadOnlyList dice) + { + return fixedScore; + } + + public static FixedScoreCategory Create(string id, int score) + { + var category = CreateInstance(); + category.SetTestData(id, id); + category.fixedScore = score; + return category; + } + } + } +} diff --git a/Assets/Scripts/Tests/Editor/RunLoopServiceTests.cs.meta b/Assets/Scripts/Tests/Editor/RunLoopServiceTests.cs.meta new file mode 100644 index 0000000..dfb05d4 --- /dev/null +++ b/Assets/Scripts/Tests/Editor/RunLoopServiceTests.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 10f09d1ab020a9f498748f79ae8c5730 \ No newline at end of file diff --git a/Assets/Scripts/UI/DicePanelView.cs b/Assets/Scripts/UI/DicePanelView.cs index 0062b00..f2d7c76 100644 --- a/Assets/Scripts/UI/DicePanelView.cs +++ b/Assets/Scripts/UI/DicePanelView.cs @@ -82,6 +82,11 @@ namespace YachtDice.UI rollButtonText.text = $"Бросок {currentRoll + 1}/{maxRolls}"; } + public void SetRollButtonInteractable(bool interactable) + { + rollButton.interactable = interactable; + } + public void ResetForNewTurn() { for (int i = 0; i < diceValues.Length; i++) diff --git a/Assets/Scripts/UI/GameInfoView.cs b/Assets/Scripts/UI/GameInfoView.cs index 9c74f84..a5d6283 100644 --- a/Assets/Scripts/UI/GameInfoView.cs +++ b/Assets/Scripts/UI/GameInfoView.cs @@ -42,12 +42,24 @@ namespace YachtDice.UI turnText.text = $"Ход {turn} / {maxTurns}"; } + public void SetRunInfoText(string text) + { + if (turnText != null) + turnText.text = text; + } + public void SetCurrencyText(int amount) { if (currencyText != null) currencyText.text = amount.ToString(); } + public void SetShopButtonInteractable(bool interactable) + { + if (shopButton != null) + shopButton.interactable = interactable; + } + public void ShowGameOver(int finalScore) { gameOverPanel.SetActive(true); diff --git a/Assets/Scripts/UI/GamePresentationRoot.cs b/Assets/Scripts/UI/GamePresentationRoot.cs index 2fdb4c6..a0862a2 100644 --- a/Assets/Scripts/UI/GamePresentationRoot.cs +++ b/Assets/Scripts/UI/GamePresentationRoot.cs @@ -63,7 +63,7 @@ namespace YachtDice.UI private void Start() { _dicePanelPresenter = new DicePanelPresenter(dicePanelView, _gameLoopController, _diceManager); - _scoreCardPresenter = new ScoreCardPresenter(scoreCardView, _categoryCatalog, _scoringSystem, _diceManager); + _scoreCardPresenter = new ScoreCardPresenter(scoreCardView, _gameLoopController, _categoryCatalog, _scoringSystem, _diceManager); _gameInfoPresenter = new GameInfoPresenter(gameInfoView); _gameFlowPresenter = new GameFlowPresenter( diff --git a/Assets/Scripts/UI/Presentation/DicePanelPresenter.cs b/Assets/Scripts/UI/Presentation/DicePanelPresenter.cs index 1446720..4ba147d 100644 --- a/Assets/Scripts/UI/Presentation/DicePanelPresenter.cs +++ b/Assets/Scripts/UI/Presentation/DicePanelPresenter.cs @@ -69,6 +69,12 @@ namespace YachtDice.UI.Presentation _view.SetDiceLocked(index, isLocked); } + public void SetRollingEnabled(bool enabled) + { + _view.SetRollButtonInteractable(enabled); + _view.SetDiceInteractable(enabled && _gameLoopController.CurrentRoll > 0); + } + private void HandleRollClicked() { RollClicked?.Invoke(); diff --git a/Assets/Scripts/UI/Presentation/GameFlowPresenter.cs b/Assets/Scripts/UI/Presentation/GameFlowPresenter.cs index 2ea4fc3..5713adc 100644 --- a/Assets/Scripts/UI/Presentation/GameFlowPresenter.cs +++ b/Assets/Scripts/UI/Presentation/GameFlowPresenter.cs @@ -4,6 +4,7 @@ using YachtDice.Economy; using YachtDice.Game; using YachtDice.Inventory; using YachtDice.Player; +using YachtDice.Run; using YachtDice.Shop; namespace YachtDice.UI.Presentation @@ -57,6 +58,13 @@ namespace YachtDice.UI.Presentation _gameLoopController.OnRollComplete += HandleRollComplete; _gameLoopController.OnScored += HandleScored; _gameLoopController.OnGameOver += HandleGameOver; + _gameLoopController.OnBetStarted += HandleBetStarted; + _gameLoopController.OnShopOpened += HandleShopOpened; + _gameLoopController.OnShopClosed += HandleShopClosed; + _gameLoopController.OnStoredRollsChanged += HandleStoredRollsChanged; + _gameLoopController.OnQuotaChanged += HandleQuotaChanged; + _gameLoopController.OnCycleCompleted += HandleCycleCompleted; + _gameLoopController.OnPhaseChanged += HandlePhaseChanged; _dicePanelPresenter.RollClicked += HandleRollClicked; _dicePanelPresenter.DiceToggled += HandleDiceToggled; @@ -70,6 +78,7 @@ namespace YachtDice.UI.Presentation _saveService.Load(); _gameInfoPresenter.SetCurrencyText(_currencyBank.Balance); + _gameInfoPresenter.SetShopButtonInteractable(false); _gameLoopController.StartNewGame(); } @@ -79,6 +88,13 @@ namespace YachtDice.UI.Presentation _gameLoopController.OnRollComplete -= HandleRollComplete; _gameLoopController.OnScored -= HandleScored; _gameLoopController.OnGameOver -= HandleGameOver; + _gameLoopController.OnBetStarted -= HandleBetStarted; + _gameLoopController.OnShopOpened -= HandleShopOpened; + _gameLoopController.OnShopClosed -= HandleShopClosed; + _gameLoopController.OnStoredRollsChanged -= HandleStoredRollsChanged; + _gameLoopController.OnQuotaChanged -= HandleQuotaChanged; + _gameLoopController.OnCycleCompleted -= HandleCycleCompleted; + _gameLoopController.OnPhaseChanged -= HandlePhaseChanged; _dicePanelPresenter.RollClicked -= HandleRollClicked; _dicePanelPresenter.DiceToggled -= HandleDiceToggled; @@ -95,8 +111,9 @@ namespace YachtDice.UI.Presentation private void HandleTurnStarted(int turn) { - _gameInfoPresenter.SetTurnText(turn, _categoryCatalog.Count); + UpdateRunInfoText(); _dicePanelPresenter.ResetForNewTurn(); + _dicePanelPresenter.SetRollingEnabled(true); _scoreCardPresenter.ClearAllPreviews(); } @@ -110,6 +127,7 @@ namespace YachtDice.UI.Presentation { _scoreCardPresenter.SetCategoryScored(category, finalScore); _scoreCardPresenter.UpdateTotalDisplay(_scoreSummaryService.Calculate()); + UpdateRunInfoText(); _saveService.Save(); } @@ -118,6 +136,8 @@ namespace YachtDice.UI.Presentation _dicePanelPresenter.HandleGameOver(); _scoreCardPresenter.SetAllInteractable(false); _gameInfoPresenter.ShowGameOver(_scoreSummaryService.Calculate().DisplayTotal); + _shopController.Close(); + _gameInfoPresenter.SetShopButtonInteractable(false); _saveService.Save(); } @@ -142,6 +162,8 @@ namespace YachtDice.UI.Presentation private void HandleNewGameClicked() { _gameInfoPresenter.HideGameOver(); + _shopController.Close(); + _gameInfoPresenter.SetShopButtonInteractable(false); _scoreCardPresenter.ResetAll(); _dicePanelPresenter.ResetForNewGame(); _gameLoopController.StartNewGame(); @@ -149,7 +171,18 @@ namespace YachtDice.UI.Presentation private void HandleShopClicked() { - _shopController.ToggleVisibility(); + if (!_gameLoopController.CanOpenShopManually()) + return; + + if (_shopController.IsOpen) + { + _shopController.Close(); + _gameLoopController.CompleteShop(); + } + else + { + _shopController.Open(); + } } private void HandleInventoryClicked() @@ -160,11 +193,63 @@ namespace YachtDice.UI.Presentation private void HandleCurrencyChanged(int newBalance) { _gameInfoPresenter.SetCurrencyText(newBalance); + UpdateRunInfoText(); } private void HandlePlayerChangedForSave() { _saveService.Save(); } + + private void HandleBetStarted(int betIndex) + { + UpdateRunInfoText(); + } + + private void HandleShopOpened() + { + _shopController.Open(); + _gameInfoPresenter.SetShopButtonInteractable(true); + _dicePanelPresenter.SetRollingEnabled(false); + UpdateRunInfoText(); + } + + private void HandleShopClosed() + { + _shopController.Close(); + _gameInfoPresenter.SetShopButtonInteractable(false); + UpdateRunInfoText(); + } + + private void HandleStoredRollsChanged(int value) + { + UpdateRunInfoText(); + } + + private void HandleQuotaChanged(int value) + { + UpdateRunInfoText(); + } + + private void HandleCycleCompleted(int bonus, int storedRolls) + { + _scoreCardPresenter.ResetAll(); + _scoreCardPresenter.UpdateTotalDisplay(_scoreSummaryService.Calculate()); + UpdateRunInfoText(); + } + + private void HandlePhaseChanged(RunPhase phase) + { + if (phase != RunPhase.Shop) + _gameInfoPresenter.SetShopButtonInteractable(false); + + UpdateRunInfoText(); + } + + private void UpdateRunInfoText() + { + var info = $"Bet {_gameLoopController.CurrentBet} | Stage {_gameLoopController.CurrentStage}/3 | Target {_gameLoopController.CurrentStageTarget} | Quota {_gameLoopController.CurrentBaseQuota} | Bank {_gameLoopController.StoredRolls}"; + _gameInfoPresenter.SetRunInfoText(info); + } } } diff --git a/Assets/Scripts/UI/Presentation/GameInfoPresenter.cs b/Assets/Scripts/UI/Presentation/GameInfoPresenter.cs index 5bb4ba7..acb4cd5 100644 --- a/Assets/Scripts/UI/Presentation/GameInfoPresenter.cs +++ b/Assets/Scripts/UI/Presentation/GameInfoPresenter.cs @@ -34,11 +34,21 @@ namespace YachtDice.UI.Presentation _view.SetTurnText(turn, maxTurns); } + public void SetRunInfoText(string text) + { + _view.SetRunInfoText(text); + } + public void SetCurrencyText(int amount) { _view.SetCurrencyText(amount); } + public void SetShopButtonInteractable(bool interactable) + { + _view.SetShopButtonInteractable(interactable); + } + public void ShowGameOver(int finalScore) { _view.ShowGameOver(finalScore); diff --git a/Assets/Scripts/UI/Presentation/ScoreCardPresenter.cs b/Assets/Scripts/UI/Presentation/ScoreCardPresenter.cs index f300c27..d975ae7 100644 --- a/Assets/Scripts/UI/Presentation/ScoreCardPresenter.cs +++ b/Assets/Scripts/UI/Presentation/ScoreCardPresenter.cs @@ -9,6 +9,7 @@ namespace YachtDice.UI.Presentation public sealed class ScoreCardPresenter : IDisposable { private readonly ScoreCardView _view; + private readonly GameLoopController _gameLoopController; private readonly CategoryCatalog _categoryCatalog; private readonly ScoringSystem _scoringSystem; private readonly DiceManager _diceManager; @@ -17,11 +18,13 @@ namespace YachtDice.UI.Presentation public ScoreCardPresenter( ScoreCardView view, + GameLoopController gameLoopController, CategoryCatalog categoryCatalog, ScoringSystem scoringSystem, DiceManager diceManager) { _view = view; + _gameLoopController = gameLoopController; _categoryCatalog = categoryCatalog; _scoringSystem = scoringSystem; _diceManager = diceManager; @@ -55,11 +58,12 @@ namespace YachtDice.UI.Presentation if (_scoringSystem.IsCategoryUsed(category)) continue; - var result = _scoringSystem.PreviewScore(dice, category); + var result = _gameLoopController.PreviewCategory(category); previews[category] = result.FinalScore; } _view.UpdatePreviews(previews); + SetAvailableCategoriesInteractable(); } public void SetCategoryScored(CategoryDefinition category, int finalScore) @@ -72,6 +76,19 @@ namespace YachtDice.UI.Presentation _view.SetAllInteractable(interactable); } + public void SetAvailableCategoriesInteractable() + { + var allCategories = _categoryCatalog.All; + for (var i = 0; i < allCategories.Count; i++) + { + var category = allCategories[i]; + if (_scoringSystem.IsCategoryUsed(category)) + continue; + + _view.SetCategoryInteractable(category, _gameLoopController.CanScoreCategory(category)); + } + } + public void UpdateTotalDisplay(ScoreSummary summary) { _view.UpdateTotalDisplay(summary.DisplayTotal); diff --git a/Assets/Scripts/UI/ScoreCardView.cs b/Assets/Scripts/UI/ScoreCardView.cs index 945c670..ca6714b 100644 --- a/Assets/Scripts/UI/ScoreCardView.cs +++ b/Assets/Scripts/UI/ScoreCardView.cs @@ -74,6 +74,12 @@ namespace YachtDice.UI t.SetInteractable(interactable); } + public void SetCategoryInteractable(CategoryDefinition category, bool interactable) + { + if (_categoryToRowIndex != null && _categoryToRowIndex.TryGetValue(category, out int index)) + categoryRows[index].SetInteractable(interactable); + } + public void UpdateTotalDisplay(int totalScore) { totalScoreText.text = totalScore.ToString();