[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
+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);
}
}
}