[Add] GameLoop base

This commit is contained in:
2026-03-18 09:13:48 +07:00
parent c819c0d045
commit 537ae1ce5c
28 changed files with 997 additions and 40 deletions
+48
View File
@@ -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<RunBalanceConfigSO>();
}
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 5917f01e056f9c24988a1e0e726becc0
+293
View File
@@ -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<RunState> OnRunStarted;
public event Action<RunState> OnBetStarted;
public event Action<RunState> OnShopOpened;
public event Action<RunStageState> OnStageStarted;
public event Action<RunStageState, CategoryDefinition, ScoreResult> OnStageCleared;
public event Action<RunStageState> OnStageFailed;
public event Action<int> OnStoredRollsChanged;
public event Action<int> OnCurrencyChanged;
public event Action<int, int> OnCycleCompleted;
public event Action<int> OnQuotaChanged;
public event Action<RunState> OnRunFailed;
public event Action<RunPhase> 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<IDice> dice)
{
if (!_state.IsActive || _state.IsFailed || _state.Phase != RunPhase.Rolling)
return;
SetPhase(RunPhase.CategorySelection);
if (!HasRemainingRollCapacity() && !HasAnyScorableCategory(dice))
FailCurrentStage();
}
public bool CanScoreCategory(IReadOnlyList<IDice> 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<IDice> dice, CategoryDefinition category)
{
return _scoringSystem.PreviewScore(
dice,
category,
_state.CurrentRoll,
GetProgressOrdinal(),
CurrentCurrency);
}
public bool HasAnyScorableCategory(IReadOnlyList<IDice> 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<bool> TryScoreCategoryAsync(IReadOnlyList<IDice> 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);
}
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 303eb3c5c68f41b4183853b80f5bdf0d
+17
View File
@@ -0,0 +1,17 @@
namespace YachtDice.Run
{
public enum RunPhase
{
None,
StartBet,
Shop,
StageStart,
Rolling,
CategorySelection,
StageResolved,
BetResolved,
CycleResolved,
RunFailed,
RunCompleted,
}
}
+2
View File
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 3538676a7c72a1f4088981a1a33c6126
+19
View File
@@ -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; }
}
}
+2
View File
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: efe1d4387ae5ef94da49a7fcd867a3cb
+9
View File
@@ -0,0 +1,9 @@
namespace YachtDice.Run
{
public enum RunStageType
{
Base = 0,
Mid = 1,
Final = 2,
}
}
+2
View File
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 279fbb0d4676dcf4eb0224a8535f8a86
+24
View File
@@ -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;
}
}
+2
View File
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 308eb21a80d1c2a4283e6de6aac0d026
+39
View File
@@ -0,0 +1,39 @@
using System;
namespace YachtDice.Run
{
public sealed class StoredRollBank
{
public int Value { get; private set; }
public event Action<int> 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);
}
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: e46140b5dd26bfc469334aabd4ab51b8