}
}
@code {
private readonly Dictionary _holdStates = new(StringComparer.Ordinal)
{
[MatchEngine.ColorWhite] = new(),
[MatchEngine.ColorBlack] = new(),
};
private CancellationTokenSource? _tickerCancellation;
private bool _ready;
private bool ShowHelpModal;
private bool ShowResultModal;
private string? _resultModalKey;
private MatchState? Match => Store.Current;
protected override async Task OnAfterRenderAsync(bool firstRender)
{
if (!firstRender)
{
return;
}
await Store.EnsureLoadedAsync();
_ready = true;
if (Match is null)
{
Navigation.NavigateTo("/application.html", replace: true);
return;
}
if (string.IsNullOrEmpty(Match.Result) && Match.Phase != MatchEngine.PhaseCube)
{
Navigation.NavigateTo("/chrono.html", replace: true);
return;
}
_tickerCancellation = new CancellationTokenSource();
_ = RunTickerAsync(_tickerCancellation.Token);
await TryPlayPendingAlertAsync();
UpdateResultModalState();
StateHasChanged();
}
public async ValueTask DisposeAsync()
{
if (_tickerCancellation is not null)
{
_tickerCancellation.Cancel();
_tickerCancellation.Dispose();
}
await Store.FlushIfDueAsync(0);
}
private async Task RunTickerAsync(CancellationToken cancellationToken)
{
try
{
using var timer = new PeriodicTimer(TimeSpan.FromMilliseconds(100));
while (await timer.WaitForNextTickAsync(cancellationToken))
{
var match = Match;
if (match is null)
{
continue;
}
if (string.IsNullOrEmpty(match.Result) && match.Phase != MatchEngine.PhaseCube)
{
await Store.SaveAsync();
await InvokeAsync(() => Navigation.NavigateTo("/chrono.html", replace: true));
return;
}
UpdateResultModalState();
await Store.FlushIfDueAsync();
await InvokeAsync(StateHasChanged);
}
}
catch (OperationCanceledException)
{
}
}
private void OpenHelpModal()
=> ShowHelpModal = true;
private void CloseHelpModal()
=> ShowHelpModal = false;
private void CloseResultModal()
=> ShowResultModal = false;
private async Task HandlePrimaryActionAsync()
{
if (Match is null)
{
return;
}
if (!string.IsNullOrEmpty(Match.Result))
{
Navigation.NavigateTo("/application.html");
return;
}
if (Match.Phase != MatchEngine.PhaseCube)
{
Navigation.NavigateTo("/chrono.html", replace: true);
return;
}
if (BuildResultView() is not null)
{
ShowResultModal = true;
}
}
private async Task HandleCubePointerDownAsync(string color)
{
var match = Match;
if (match is null)
{
return;
}
await Browser.PrimeAudioAsync();
await TryPlayPendingAlertAsync();
if (!string.IsNullOrEmpty(match.Result) || match.Phase != MatchEngine.PhaseCube)
{
return;
}
if ((color == MatchEngine.ColorWhite ? match.Cube.Times.White : match.Cube.Times.Black) is not null)
{
return;
}
var playerState = color == MatchEngine.ColorWhite ? match.Cube.PlayerState.White : match.Cube.PlayerState.Black;
if (playerState.Running)
{
return;
}
_holdStates[color].Armed = true;
_holdStates[color].StartedAt = MatchEngine.NowUnixMs();
}
private async Task HandleCubePointerUpAsync(string color)
{
var match = Match;
if (match is null)
{
return;
}
await Browser.PrimeAudioAsync();
await TryPlayPendingAlertAsync();
if (!string.IsNullOrEmpty(match.Result) || match.Phase != MatchEngine.PhaseCube)
{
CancelCubeHold(color);
return;
}
if ((color == MatchEngine.ColorWhite ? match.Cube.Times.White : match.Cube.Times.Black) is not null)
{
CancelCubeHold(color);
return;
}
var playerState = color == MatchEngine.ColorWhite ? match.Cube.PlayerState.White : match.Cube.PlayerState.Black;
if (playerState.Running)
{
MatchEngine.CaptureCubeTime(match, color);
CancelCubeHold(color);
await PersistCubeAsync();
return;
}
var ready = IsHoldReady(color);
CancelCubeHold(color);
if (!ready)
{
return;
}
MatchEngine.StartCubeTimer(match, color);
await PersistCubeAsync();
}
private void CancelCubeHold(string color)
{
_holdStates[color].Armed = false;
_holdStates[color].StartedAt = 0;
}
private async Task ApplyResultAsync()
{
var match = Match;
var resultView = BuildResultView();
if (match is null || resultView is null)
{
ShowResultModal = false;
return;
}
if (resultView.ReplayRequired)
{
ShowResultModal = false;
_resultModalKey = null;
MatchEngine.ReplayCubePhase(match);
await PersistCubeAsync();
await TryPlayPendingAlertAsync();
return;
}
ShowResultModal = false;
MatchEngine.ApplyCubeOutcome(match);
Store.MarkDirty();
await Store.SaveAsync();
Navigation.NavigateTo("/chrono.html");
}
private async Task ReplayCubeAsync()
{
var match = Match;
if (match is null || match.Phase != MatchEngine.PhaseCube)
{
return;
}
ShowResultModal = false;
_resultModalKey = null;
MatchEngine.ReplayCubePhase(match);
await PersistCubeAsync();
await TryPlayPendingAlertAsync();
}
private async Task ResetMatchAsync()
{
await Store.ClearAsync();
Navigation.NavigateTo("/application.html", replace: true);
}
private async Task PersistCubeAsync()
{
Store.MarkDirty();
await Store.SaveAsync();
UpdateResultModalState();
StateHasChanged();
}
private async Task TryPlayPendingAlertAsync()
{
var match = Match;
if (match is null || !match.Cube.PhaseAlertPending)
{
return;
}
if (!await Browser.PlayCubePhaseAlertAsync())
{
return;
}
match.Cube.PhaseAlertPending = false;
Store.MarkDirty();
await Store.SaveAsync();
}
private CubeSummaryView BuildSummary()
{
var match = Match!;
var blockHeading = MatchEngine.FormatBlockHeading(match, match.BlockNumber);
var subtitle = $"{blockHeading} - {MatchEngine.Modes[match.Config.Mode].Label} - {MatchEngine.RenderModeContext(match)}";
if (!string.IsNullOrEmpty(match.Result))
{
return new CubeSummaryView(
match.Cube.Number is not null ? $"Cube n{match.Cube.Number}" : "Phase cube",
subtitle,
"Resultat",
MatchEngine.ResultText(match),
"Termine",
MatchEngine.ResultText(match),
"Retournez a la configuration pour relancer une rencontre.",
"Retour a l'accueil",
"Le match est termine.",
false);
}
if (match.Cube.Times.White is not null && match.Cube.Times.Black is not null && match.Config.Mode == MatchEngine.ModeTwice && match.Cube.Times.White == match.Cube.Times.Black)
{
return new CubeSummaryView(
match.Cube.Number is not null ? $"Cube n{match.Cube.Number}" : "Phase cube",
subtitle,
"Decision",
"Egalite parfaite",
"Reglement",
"Rejouer la phase cube",
"Le mode Twice impose de relancer immediatement la phase cube en cas d'egalite parfaite.",
"Voir le resume du cube",
"Le mode Twice impose de relancer immediatement la phase cube en cas d'egalite parfaite.",
false);
}
if (match.Cube.Times.White is not null && match.Cube.Times.Black is not null)
{
var preview = match.Config.Mode == MatchEngine.ModeTime
? MatchEngine.GetTimeAdjustmentPreview(match, match.Cube.Times.White.Value, match.Cube.Times.Black.Value)
: null;
return preview is null
? new CubeSummaryView(
match.Cube.Number is not null ? $"Cube n{match.Cube.Number}" : "Phase cube",
subtitle,
"Decision",
"Phase cube complete",
"Suite",
"Ouvrir la page chrono",
"Appliquer le resultat du cube pour preparer le Block suivant.",
"Voir le resume du cube",
"Appliquer le resultat du cube pour preparer le Block suivant.",
false)
: new CubeSummaryView(
match.Cube.Number is not null ? $"Cube n{match.Cube.Number}" : "Phase cube",
subtitle,
"Vainqueur cube",
preview.Winner is not null ? MatchEngine.PlayerName(match, preview.Winner) : "Egalite",
"Impact chrono",
preview.BlockType == "minus" ? "Bloc - a appliquer" : "Bloc + a appliquer",
$"Blanc {MatchEngine.FormatSignedStopwatch(preview.WhiteDelta)} -> {MatchEngine.FormatSignedClock(preview.WhiteAfter)}. Noir {MatchEngine.FormatSignedStopwatch(preview.BlackDelta)} -> {MatchEngine.FormatSignedClock(preview.BlackAfter)}.",
"Voir le resume du cube",
$"Blanc {MatchEngine.FormatSignedStopwatch(preview.WhiteDelta)} -> {MatchEngine.FormatSignedClock(preview.WhiteAfter)}. Noir {MatchEngine.FormatSignedStopwatch(preview.BlackDelta)} -> {MatchEngine.FormatSignedClock(preview.BlackAfter)}.",
false);
}
if (match.Cube.Running)
{
return new CubeSummaryView(
match.Cube.Number is not null ? $"Cube n{match.Cube.Number}" : "Phase cube",
subtitle,
"Etat",
"Chronos lances",
"Arrets",
"Chaque joueur se chronometre",
"Chaque joueur demarre en relachant sa zone, puis retape sa zone une fois le cube termine.",
"Attendre les deux temps",
"Chaque joueur demarre en relachant sa zone, puis retape sa zone une fois le cube termine.",
true);
}
if (match.Cube.Times.White is not null || match.Cube.Times.Black is not null)
{
return new CubeSummaryView(
match.Cube.Number is not null ? $"Cube n{match.Cube.Number}" : "Phase cube",
subtitle,
"Etat",
"Un temps saisi",
"Suite",
"Attendre l'autre joueur",
"Le deuxieme joueur peut encore maintenir puis relacher sa zone pour demarrer son propre chrono.",
"Attendre le deuxieme temps",
"Le deuxieme joueur peut encore maintenir puis relacher sa zone pour demarrer son propre chrono.",
true);
}
return new CubeSummaryView(
match.Cube.Number is not null ? $"Cube n{match.Cube.Number}" : "Phase cube",
subtitle,
"Etat",
"Pret",
"Depart libre",
"Chaque joueur lance son chrono",
"Au debut de sa resolution, chaque joueur maintient sa grande zone puis la relache pour demarrer son propre chrono.",
"En attente des joueurs",
"Au debut de sa resolution, chaque joueur maintient sa grande zone puis la relache pour demarrer son propre chrono.",
true);
}
private CubeZoneView BuildZone(string color)
{
var match = Match!;
var playerState = color == MatchEngine.ColorWhite ? match.Cube.PlayerState.White : match.Cube.PlayerState.Black;
var time = color == MatchEngine.ColorWhite ? match.Cube.Times.White : match.Cube.Times.Black;
var hold = _holdStates[color];
var holdReady = IsHoldReady(color);
var holdArmed = hold.Armed && !holdReady && !playerState.Running && time is null && match.Phase == MatchEngine.PhaseCube && string.IsNullOrEmpty(match.Result);
var holdProgress = hold.Armed && time is null && !playerState.Running
? Math.Min((MatchEngine.NowUnixMs() - hold.StartedAt) / (double)MatchEngine.CubeStartHoldMs, 1d)
: 0d;
if (!string.IsNullOrEmpty(match.Result))
{
return new CubeZoneView(
MatchEngine.PlayerName(match, color),
MatchEngine.FormatCubePlayerTime(match, color),
MatchEngine.RenderCubeMeta(match, color),
MatchEngine.ResultText(match),
"Le match est termine.",
true,
false,
false,
"--cube-hold-progress: 0");
}
if (match.Phase != MatchEngine.PhaseCube)
{
return new CubeZoneView(
MatchEngine.PlayerName(match, color),
MatchEngine.FormatCubePlayerTime(match, color),
MatchEngine.RenderCubeMeta(match, color),
"Retour chrono",
"La page cube est terminee.",
true,
false,
false,
"--cube-hold-progress: 0");
}
if (time is not null)
{
return new CubeZoneView(
MatchEngine.PlayerName(match, color),
MatchEngine.FormatCubePlayerTime(match, color),
MatchEngine.RenderCubeMeta(match, color),
"Temps enregistre",
"Ce joueur a deja termine son cube.",
true,
false,
false,
"--cube-hold-progress: 1");
}
if (playerState.Running)
{
return new CubeZoneView(
MatchEngine.PlayerName(match, color),
MatchEngine.FormatCubePlayerTime(match, color),
MatchEngine.RenderCubeMeta(match, color),
"J'ai fini le cube",
"Tape au moment exact ou le cube est resolu.",
false,
false,
false,
"--cube-hold-progress: 1");
}
if (holdReady)
{
return new CubeZoneView(
MatchEngine.PlayerName(match, color),
MatchEngine.FormatCubePlayerTime(match, color),
MatchEngine.RenderCubeMeta(match, color),
"Relachez pour demarrer",
"Le chrono partira des que vous levez le doigt.",
false,
true,
true,
"--cube-hold-progress: 1");
}
if (holdArmed)
{
return new CubeZoneView(
MatchEngine.PlayerName(match, color),
MatchEngine.FormatCubePlayerTime(match, color),
MatchEngine.RenderCubeMeta(match, color),
"Maintenez 2 s...",
"Gardez le doigt pose 2 secondes, jusqu'a la fin de la barre.",
false,
true,
false,
$"--cube-hold-progress: {holdProgress.ToString("0.###", CultureInfo.InvariantCulture)}");
}
return new CubeZoneView(
MatchEngine.PlayerName(match, color),
MatchEngine.FormatCubePlayerTime(match, color),
MatchEngine.RenderCubeMeta(match, color),
"Maintenir 2 s pour demarrer",
"Maintenez la grande zone 2 secondes, puis relachez pour lancer votre chrono.",
false,
false,
false,
"--cube-hold-progress: 0");
}
private CubeResultView? BuildResultView()
{
var match = Match;
if (match is null || match.Phase != MatchEngine.PhaseCube)
{
return null;
}
var white = match.Cube.Times.White;
var black = match.Cube.Times.Black;
if (white is null || black is null)
{
return null;
}
var whiteName = MatchEngine.PlayerName(match, MatchEngine.ColorWhite);
var blackName = MatchEngine.PlayerName(match, MatchEngine.ColorBlack);
if (match.Config.Mode == MatchEngine.ModeTime)
{
var preview = MatchEngine.GetTimeAdjustmentPreview(match, white.Value, black.Value);
if (preview is null)
{
return null;
}
return new CubeResultView(
$"time:{match.BlockNumber}:{match.Cube.Round}:{white}:{black}",
"Resume du cube",
preview.Winner is not null ? MatchEngine.PlayerName(match, preview.Winner) : "Egalite",
preview.BlockType == "minus" ? "Bloc - a appliquer" : "Bloc + a appliquer",
"Validez ce resume pour appliquer les impacts chrono puis revenir a la page chrono.",
"Appliquer et ouvrir la page chrono",
whiteName,
blackName,
$"Temps cube {MatchEngine.FormatStopwatch(white.Value)}",
$"Temps cube {MatchEngine.FormatStopwatch(black.Value)}",
$"Impact chrono {MatchEngine.FormatSignedStopwatch(preview.WhiteDelta)}",
$"Impact chrono {MatchEngine.FormatSignedStopwatch(preview.BlackDelta)}",
$"Chrono apres {MatchEngine.FormatSignedClock(preview.WhiteAfter)}",
$"Chrono apres {MatchEngine.FormatSignedClock(preview.BlackAfter)}",
false);
}
var winner = white < black ? MatchEngine.ColorWhite : black < white ? MatchEngine.ColorBlack : null;
var tie = winner is null;
return new CubeResultView(
$"twice:{match.BlockNumber}:{match.Cube.Round}:{white}:{black}",
tie ? "Egalite parfaite" : "Resume du cube",
winner is not null ? MatchEngine.PlayerName(match, winner) : "Egalite parfaite",
tie ? "Rejouer la phase cube" : $"{MatchEngine.PlayerName(match, winner!)} ouvrira le Block suivant",
tie ? "Le reglement Twice impose de rejouer immediatement la phase cube." : "Validez ce resultat pour preparer le Block suivant.",
tie ? "Rejouer la phase cube" : "Appliquer et ouvrir la page chrono",
whiteName,
blackName,
$"Temps cube {MatchEngine.FormatStopwatch(white.Value)}",
$"Temps cube {MatchEngine.FormatStopwatch(black.Value)}",
winner == MatchEngine.ColorWhite ? "Gagne la phase cube" : tie ? "Egalite parfaite" : "Ne gagne pas la phase cube",
winner == MatchEngine.ColorBlack ? "Gagne la phase cube" : tie ? "Egalite parfaite" : "Ne gagne pas la phase cube",
"Aucun impact chrono en mode Twice",
"Aucun impact chrono en mode Twice",
tie);
}
private void UpdateResultModalState()
{
var resultView = BuildResultView();
if (resultView is null)
{
ShowResultModal = false;
_resultModalKey = null;
return;
}
if (_resultModalKey != resultView.Key)
{
_resultModalKey = resultView.Key;
ShowResultModal = true;
}
}
private bool IsHoldReady(string color)
=> _holdStates[color].Armed && MatchEngine.NowUnixMs() - _holdStates[color].StartedAt >= MatchEngine.CubeStartHoldMs;
private static string BoolString(bool value)
=> value ? "true" : "false";
private sealed class CubeHoldState
{
public bool Armed { get; set; }
public long StartedAt { get; set; }
}
private sealed record CubeSummaryView(
string Title,
string Subtitle,
string CenterLabel,
string CenterValue,
string SpineLabel,
string SpineHeadline,
string SpineText,
string PrimaryButtonText,
string HelpStatus,
bool PrimaryDisabled);
private sealed record CubeZoneView(
string Name,
string ResultText,
string MetaText,
string ButtonText,
string Hint,
bool Disabled,
bool HoldArmed,
bool HoldReady,
string ProgressStyle);
private sealed record CubeResultView(
string Key,
string Title,
string Winner,
string Outcome,
string Summary,
string ActionLabel,
string WhiteName,
string BlackName,
string WhiteTime,
string BlackTime,
string WhiteDetail,
string BlackDetail,
string WhiteClock,
string BlackClock,
bool ReplayRequired);
}