Migre le projet vers Blazor WebAssembly en .NET 10

This commit is contained in:
2026-04-13 21:29:12 +02:00
parent b11056097d
commit 90f17c9c89
26 changed files with 4314 additions and 94 deletions

View File

@@ -0,0 +1,6 @@
<Router AppAssembly="@typeof(App).Assembly" NotFoundPage="typeof(Pages.NotFound)">
<Found Context="routeData">
<RouteView RouteData="@routeData" DefaultLayout="@typeof(Layout.MainLayout)" />
<FocusOnNavigate RouteData="@routeData" Selector="h1" />
</Found>
</Router>

View File

@@ -0,0 +1,28 @@
<Project Sdk="Microsoft.NET.Sdk.BlazorWebAssembly">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<OverrideHtmlAssetPlaceholders>true</OverrideHtmlAssetPlaceholders>
<RootNamespace>ChessCubing.App</RootNamespace>
<AssemblyName>ChessCubing.App</AssemblyName>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.AspNetCore.Components.WebAssembly" Version="10.0.2" />
<PackageReference Include="Microsoft.AspNetCore.Components.WebAssembly.DevServer" Version="10.0.2" PrivateAssets="all" />
</ItemGroup>
<ItemGroup>
<Content Include="..\favicon.png" Link="wwwroot/favicon.png" CopyToOutputDirectory="PreserveNewest" CopyToPublishDirectory="PreserveNewest" />
<Content Include="..\logo.png" Link="wwwroot/logo.png" CopyToOutputDirectory="PreserveNewest" CopyToPublishDirectory="PreserveNewest" />
<Content Include="..\transparent.png" Link="wwwroot/transparent.png" CopyToOutputDirectory="PreserveNewest" CopyToPublishDirectory="PreserveNewest" />
<Content Include="..\styles.css" Link="wwwroot/styles.css" CopyToOutputDirectory="PreserveNewest" CopyToPublishDirectory="PreserveNewest" />
<Content Include="..\site.webmanifest" Link="wwwroot/site.webmanifest" CopyToOutputDirectory="PreserveNewest" CopyToPublishDirectory="PreserveNewest" />
<Content Include="..\ChessCubing_Time_Reglement_Officiel_V1-1.pdf" Link="wwwroot/ChessCubing_Time_Reglement_Officiel_V1-1.pdf" CopyToOutputDirectory="PreserveNewest" CopyToPublishDirectory="PreserveNewest" />
<Content Include="..\ChessCubing_Twice_Reglement_Officiel_V2-1.pdf" Link="wwwroot/ChessCubing_Twice_Reglement_Officiel_V2-1.pdf" CopyToOutputDirectory="PreserveNewest" CopyToPublishDirectory="PreserveNewest" />
<Content Include="..\ethan\**" Link="wwwroot/ethan/%(RecursiveDir)%(Filename)%(Extension)" CopyToOutputDirectory="PreserveNewest" CopyToPublishDirectory="PreserveNewest" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,28 @@
@inject BrowserBridge Browser
@code {
[Parameter]
public string? Page { get; set; }
[Parameter]
public string? BodyClass { get; set; }
private string? _lastSignature;
protected override async Task OnAfterRenderAsync(bool firstRender)
{
if (firstRender)
{
await Browser.StartViewportAsync();
}
var signature = $"{Page ?? string.Empty}|{BodyClass ?? string.Empty}";
if (signature == _lastSignature)
{
return;
}
_lastSignature = signature;
await Browser.SetBodyStateAsync(Page, BodyClass);
}
}

View File

@@ -0,0 +1,3 @@
@inherits LayoutComponentBase
@Body

View File

@@ -0,0 +1,285 @@
using System.Text.Json.Serialization;
using ChessCubing.App.Services;
namespace ChessCubing.App.Models;
public sealed class MatchConfig
{
[JsonPropertyName("matchLabel")]
public string MatchLabel { get; set; } = "Rencontre ChessCubing";
[JsonPropertyName("competitionMode")]
public bool CompetitionMode { get; set; }
[JsonPropertyName("mode")]
public string Mode { get; set; } = MatchEngine.ModeTwice;
[JsonPropertyName("preset")]
public string Preset { get; set; } = MatchEngine.PresetFast;
[JsonPropertyName("blockDurationMs")]
public long BlockDurationMs { get; set; } = MatchEngine.DefaultBlockDurationMs;
[JsonPropertyName("moveLimitMs")]
public long MoveLimitMs { get; set; } = MatchEngine.DefaultMoveLimitMs;
[JsonPropertyName("timeInitialMs")]
public long TimeInitialMs { get; set; } = MatchEngine.TimeModeInitialClockMs;
[JsonPropertyName("whiteName")]
public string WhiteName { get; set; } = "Blanc";
[JsonPropertyName("blackName")]
public string BlackName { get; set; } = "Noir";
[JsonPropertyName("arbiterName")]
public string ArbiterName { get; set; } = string.Empty;
[JsonPropertyName("eventName")]
public string EventName { get; set; } = string.Empty;
[JsonPropertyName("notes")]
public string Notes { get; set; } = string.Empty;
}
public sealed class MatchState
{
[JsonPropertyName("schemaVersion")]
public int SchemaVersion { get; set; } = 3;
[JsonPropertyName("config")]
public MatchConfig Config { get; set; } = new();
[JsonPropertyName("phase")]
public string Phase { get; set; } = MatchEngine.PhaseBlock;
[JsonPropertyName("running")]
public bool Running { get; set; }
[JsonPropertyName("lastTickAt")]
public long? LastTickAt { get; set; }
[JsonPropertyName("blockNumber")]
public int BlockNumber { get; set; } = 1;
[JsonPropertyName("currentTurn")]
public string CurrentTurn { get; set; } = MatchEngine.ColorWhite;
[JsonPropertyName("blockRemainingMs")]
public long BlockRemainingMs { get; set; } = MatchEngine.DefaultBlockDurationMs;
[JsonPropertyName("moveRemainingMs")]
public long MoveRemainingMs { get; set; } = MatchEngine.DefaultMoveLimitMs;
[JsonPropertyName("quota")]
public int Quota { get; set; } = 6;
[JsonPropertyName("moves")]
public PlayerIntPair Moves { get; set; } = new();
[JsonPropertyName("clocks")]
public PlayerLongPair? Clocks { get; set; }
[JsonPropertyName("lastMover")]
public string? LastMover { get; set; }
[JsonPropertyName("awaitingBlockClosure")]
public bool AwaitingBlockClosure { get; set; }
[JsonPropertyName("closureReason")]
public string ClosureReason { get; set; } = string.Empty;
[JsonPropertyName("result")]
public string? Result { get; set; }
[JsonPropertyName("cube")]
public CubeState Cube { get; set; } = new();
[JsonPropertyName("doubleCoup")]
public DoubleCoupState DoubleCoup { get; set; } = new();
[JsonPropertyName("history")]
public List<MatchHistoryEntry> History { get; set; } = [];
}
public sealed class PlayerIntPair
{
[JsonPropertyName("white")]
public int White { get; set; }
[JsonPropertyName("black")]
public int Black { get; set; }
}
public sealed class PlayerLongPair
{
[JsonPropertyName("white")]
public long White { get; set; }
[JsonPropertyName("black")]
public long Black { get; set; }
}
public sealed class PlayerNullableLongPair
{
[JsonPropertyName("white")]
public long? White { get; set; }
[JsonPropertyName("black")]
public long? Black { get; set; }
}
public sealed class CubeState
{
[JsonPropertyName("number")]
public int? Number { get; set; }
[JsonPropertyName("running")]
public bool Running { get; set; }
[JsonPropertyName("startedAt")]
public long? StartedAt { get; set; }
[JsonPropertyName("elapsedMs")]
public long ElapsedMs { get; set; }
[JsonPropertyName("phaseAlertPending")]
public bool PhaseAlertPending { get; set; }
[JsonPropertyName("times")]
public PlayerNullableLongPair Times { get; set; } = new();
[JsonPropertyName("playerState")]
public CubePlayerStates PlayerState { get; set; } = new();
[JsonPropertyName("round")]
public int Round { get; set; } = 1;
[JsonPropertyName("history")]
public List<CubeHistoryEntry> History { get; set; } = [];
}
public sealed class CubePlayerStates
{
[JsonPropertyName("white")]
public CubePlayerState White { get; set; } = new();
[JsonPropertyName("black")]
public CubePlayerState Black { get; set; } = new();
}
public sealed class CubePlayerState
{
[JsonPropertyName("running")]
public bool Running { get; set; }
[JsonPropertyName("startedAt")]
public long? StartedAt { get; set; }
[JsonPropertyName("elapsedMs")]
public long ElapsedMs { get; set; }
}
public sealed class DoubleCoupState
{
[JsonPropertyName("eligible")]
public bool Eligible { get; set; }
[JsonPropertyName("step")]
public int Step { get; set; }
[JsonPropertyName("starter")]
public string Starter { get; set; } = MatchEngine.ColorWhite;
}
public sealed class MatchHistoryEntry
{
[JsonPropertyName("message")]
public string Message { get; set; } = string.Empty;
[JsonPropertyName("time")]
public string Time { get; set; } = string.Empty;
}
public sealed class CubeHistoryEntry
{
[JsonPropertyName("blockNumber")]
public int BlockNumber { get; set; }
[JsonPropertyName("number")]
public int? Number { get; set; }
[JsonPropertyName("white")]
public long? White { get; set; }
[JsonPropertyName("black")]
public long? Black { get; set; }
}
public sealed class SetupFormModel
{
public bool CompetitionMode { get; set; }
public string MatchLabel { get; set; } = string.Empty;
public string Mode { get; set; } = MatchEngine.ModeTwice;
public string Preset { get; set; } = MatchEngine.PresetFast;
public int BlockMinutes { get; set; } = 3;
public int MoveSeconds { get; set; } = 20;
public int TimeInitialMinutes { get; set; } = 10;
public string WhiteName { get; set; } = "Blanc";
public string BlackName { get; set; } = "Noir";
public string ArbiterName { get; set; } = string.Empty;
public string EventName { get; set; } = string.Empty;
public string Notes { get; set; } = string.Empty;
public MatchConfig ToMatchConfig()
{
return new MatchConfig
{
MatchLabel = MatchEngine.SanitizeText(MatchLabel) is { Length: > 0 } label ? label : "Rencontre ChessCubing",
CompetitionMode = CompetitionMode,
Mode = Mode,
Preset = Preset,
BlockDurationMs = MatchEngine.NormalizeDurationMs(BlockMinutes * 60_000L, MatchEngine.DefaultBlockDurationMs),
MoveLimitMs = MatchEngine.NormalizeDurationMs(MoveSeconds * 1_000L, MatchEngine.DefaultMoveLimitMs),
TimeInitialMs = MatchEngine.NormalizeDurationMs(TimeInitialMinutes * 60_000L, MatchEngine.TimeModeInitialClockMs),
WhiteName = MatchEngine.SanitizeText(WhiteName) is { Length: > 0 } white ? white : "Blanc",
BlackName = MatchEngine.SanitizeText(BlackName) is { Length: > 0 } black ? black : "Noir",
ArbiterName = MatchEngine.SanitizeText(ArbiterName),
EventName = MatchEngine.SanitizeText(EventName),
Notes = MatchEngine.SanitizeText(Notes),
};
}
public static SetupFormModel CreateDemo()
{
return new SetupFormModel
{
CompetitionMode = true,
MatchLabel = "Demo officielle ChessCubing",
Mode = MatchEngine.ModeTwice,
Preset = MatchEngine.PresetFreeze,
BlockMinutes = 3,
MoveSeconds = 20,
TimeInitialMinutes = 10,
WhiteName = "Nora",
BlackName = "Leo",
ArbiterName = "Arbitre demo",
EventName = "Session telephone",
Notes = "8 cubes verifies, variante prete, tirage au sort effectue.",
};
}
}
public sealed record MatchPresetInfo(string Label, int Quota, string Description);
public sealed record MatchModeInfo(string Label, string Subtitle);
public sealed record TimeAdjustmentPreview(
string BlockType,
string? Winner,
long CappedWhite,
long CappedBlack,
long WhiteDelta,
long BlackDelta,
long WhiteAfter,
long BlackAfter);

View File

@@ -0,0 +1,352 @@
@page "/application"
@page "/application.html"
@inject BrowserBridge Browser
@inject MatchStore Store
@inject NavigationManager Navigation
<PageTitle>ChessCubing Arena | Application</PageTitle>
<PageBody Page="setup" BodyClass="@SetupBodyClass" />
<div class="ambient ambient-left"></div>
<div class="ambient ambient-right"></div>
<div class="setup-shell">
<header class="hero hero-setup">
<div class="hero-copy">
<a class="logo-lockup" href="index.html" aria-label="Accueil ChessCubing">
<img class="hero-logo-icon" src="logo.png" alt="Icone ChessCubing" />
<img class="hero-logo" src="transparent.png" alt="Logo ChessCubing" />
</a>
<p class="eyebrow">Application officielle de match</p>
<h1>ChessCubing Arena</h1>
<div class="hero-actions">
<a class="button ghost" href="index.html">Accueil du site</a>
<a class="button secondary" href="reglement.html">Consulter le reglement</a>
</div>
</div>
<aside class="hero-preview">
<div class="preview-card">
<p class="micro-label">Flux de match</p>
<ol class="phase-list">
<li>Configurer la rencontre</li>
<li>Passer a la page chrono</li>
<li>Basculer automatiquement sur la page cube</li>
<li>Revenir sur la page chrono pour le Block suivant</li>
</ol>
</div>
</aside>
</header>
<main class="setup-grid">
<section class="panel">
<div class="section-heading">
<div>
<p class="eyebrow">Nouvelle rencontre</p>
<h2>Configuration</h2>
</div>
<p class="section-copy">
Les reglages ci-dessous preparent les pages chrono et cube.
</p>
</div>
<form class="setup-form" @onsubmit="HandleSubmit">
<label class="option-card competition-option span-2 @(Form.CompetitionMode ? "is-selected" : string.Empty)">
<input @bind="Form.CompetitionMode" id="competitionMode" name="competitionMode" type="checkbox" />
<strong>Mode competition</strong>
<span>
Affiche le nom de la rencontre, l'arbitre, le club ou l'evenement
et les notes d'organisation.
</span>
</label>
@if (Form.CompetitionMode)
{
<label class="field span-2" id="matchLabelField">
<span>Nom de la rencontre</span>
<input @bind="Form.MatchLabel" @bind:event="oninput" name="matchLabel" type="text" maxlength="80" placeholder="Open ChessCubing de Paris" />
</label>
}
<fieldset class="field span-2">
<legend>Mode officiel</legend>
<div class="option-grid mode-grid">
<label class="option-card @(Form.Mode == MatchEngine.ModeTwice ? "is-selected" : string.Empty)">
<input type="radio" name="mode" checked="@(Form.Mode == MatchEngine.ModeTwice)" @onchange="() => SetMode(MatchEngine.ModeTwice)" />
<strong>ChessCubing Twice</strong>
<span>
Le gagnant du cube ouvre le Block suivant et peut obtenir un
double coup V2.
</span>
</label>
<label class="option-card @(Form.Mode == MatchEngine.ModeTime ? "is-selected" : string.Empty)">
<input type="radio" name="mode" checked="@(Form.Mode == MatchEngine.ModeTime)" @onchange="() => SetMode(MatchEngine.ModeTime)" />
<strong>ChessCubing Time</strong>
<span>
Meme structure de Blocks, avec chronos cumules et alternance
bloc - / bloc +.
</span>
</label>
</div>
</fieldset>
<fieldset class="field span-2">
<legend>Cadence du match</legend>
<div class="option-grid preset-grid">
<label class="option-card @(Form.Preset == MatchEngine.PresetFast ? "is-selected" : string.Empty)">
<input type="radio" name="preset" checked="@(Form.Preset == MatchEngine.PresetFast)" @onchange="() => SetPreset(MatchEngine.PresetFast)" />
<strong>FAST</strong>
<span>6 coups par joueur</span>
</label>
<label class="option-card @(Form.Preset == MatchEngine.PresetFreeze ? "is-selected" : string.Empty)">
<input type="radio" name="preset" checked="@(Form.Preset == MatchEngine.PresetFreeze)" @onchange="() => SetPreset(MatchEngine.PresetFreeze)" />
<strong>FREEZE</strong>
<span>8 coups par joueur</span>
</label>
<label class="option-card @(Form.Preset == MatchEngine.PresetMasters ? "is-selected" : string.Empty)">
<input type="radio" name="preset" checked="@(Form.Preset == MatchEngine.PresetMasters)" @onchange="() => SetPreset(MatchEngine.PresetMasters)" />
<strong>MASTERS</strong>
<span>10 coups par joueur</span>
</label>
</div>
</fieldset>
<fieldset class="field span-2">
<legend>Temps personnalises</legend>
<div class="timing-grid">
<label class="field">
<span>Temps du Block (minutes)</span>
<input @bind="Form.BlockMinutes" name="blockMinutes" type="number" min="1" max="180" step="1" />
</label>
@if (!UsesMoveLimit)
{
<label class="field" id="timeInitialField">
<span>Temps de chaque joueur (minutes)</span>
<input @bind="Form.TimeInitialMinutes" name="timeInitialMinutes" type="number" min="1" max="180" step="1" />
</label>
}
@if (UsesMoveLimit)
{
<label class="field" id="moveSecondsField">
<span>Temps coup (secondes)</span>
<input @bind="Form.MoveSeconds" name="moveSeconds" type="number" min="5" max="300" step="1" />
</label>
}
</div>
</fieldset>
<label class="field">
<span>Joueur blanc</span>
<input @bind="Form.WhiteName" @bind:event="oninput" name="whiteName" type="text" maxlength="40" placeholder="Blanc" />
</label>
<label class="field">
<span>Joueur noir</span>
<input @bind="Form.BlackName" @bind:event="oninput" name="blackName" type="text" maxlength="40" placeholder="Noir" />
</label>
@if (Form.CompetitionMode)
{
<label class="field" id="arbiterField">
<span>Arbitre</span>
<input @bind="Form.ArbiterName" @bind:event="oninput" name="arbiterName" type="text" maxlength="40" placeholder="Arbitre principal" />
</label>
<label class="field" id="eventField">
<span>Club / evenement</span>
<input @bind="Form.EventName" @bind:event="oninput" name="eventName" type="text" maxlength="60" placeholder="Club, tournoi, demonstration" />
</label>
<label class="field span-2" id="notesField">
<span>Notes</span>
<textarea @bind="Form.Notes" @bind:event="oninput" name="notes" rows="3" placeholder="Tirage au sort effectue, 8 cubes verifies, variante prete..."></textarea>
</label>
}
<div id="setupSummary" class="setup-summary span-2">
<strong>@CurrentMode.Label</strong>
<span>@CurrentPreset.Description</span>
<span>@TimingText</span>
<span>@TimeImpact</span>
<span>@QuotaText</span>
</div>
<div class="setup-actions span-2">
<button class="button primary" type="submit">Ouvrir la page chrono</button>
<button class="button secondary" id="loadDemoButton" type="button" @onclick="LoadDemo">Charger une demo</button>
<a class="button ghost" href="reglement.html">Consulter le reglement</a>
</div>
</form>
</section>
<aside class="panel side-panel">
<div class="section-heading">
<div>
<p class="eyebrow">Match en memoire</p>
<h2>Reprise rapide</h2>
</div>
</div>
@if (!_ready || CurrentMatch is null)
{
<div id="resumeCard" class="resume-card empty">
<p>Aucun match en cours pour l'instant.</p>
</div>
}
else
{
<div id="resumeCard" class="resume-card">
<strong>@ResumeMatchLabel(CurrentMatch)</strong>
<p>@CurrentMatchMode(CurrentMatch)</p>
<p>@($"{CurrentMatch.Config.WhiteName} vs {CurrentMatch.Config.BlackName}")</p>
<p>@ResumePhaseLabel(CurrentMatch)</p>
<div class="resume-actions">
<button class="button primary" id="resumeMatchButton" type="button" @onclick="ResumeMatch">
@(string.IsNullOrEmpty(CurrentMatch.Result) ? "Reprendre la phase" : "Voir le match")
</button>
<button class="button ghost" id="clearMatchButton" type="button" @onclick="ClearMatchAsync">Effacer le match</button>
</div>
</div>
}
<div class="rules-stack">
<article class="rule-card">
<span class="micro-label">Page chrono</span>
<strong>Gros boutons uniquement</strong>
<p>
Chaque joueur dispose d'une grande zone tactile pour signaler la
fin de son coup, puis l'app ouvre automatiquement la phase cube
quand le Block d'echecs est termine.
</p>
</article>
<article class="rule-card">
<span class="micro-label">Page cube</span>
<strong>Une page dediee</strong>
<p>
Les deux joueurs lancent et arretent leur propre chrono cube sur
un ecran separe, toujours en face-a-face sur mobile.
</p>
</article>
<article class="rule-card">
<span class="micro-label">Sources</span>
<strong>Reglements integres</strong>
<p>
<a href="reglement.html">Page reglement du site</a>
<br />
<a href="ChessCubing_Twice_Reglement_Officiel_V2-1.pdf" target="_blank">Reglement ChessCubing Twice</a>
<br />
<a href="ChessCubing_Time_Reglement_Officiel_V1-1.pdf" target="_blank">Reglement ChessCubing Time</a>
</p>
</article>
</div>
</aside>
</main>
<div class="setup-refresh-footer">
<button class="refresh-link-button" id="refreshAppButton" type="button" @onclick="ForceRefreshAsync">
Rafraichir l'app
</button>
</div>
</div>
@code {
private SetupFormModel Form { get; set; } = new();
private bool _ready;
private MatchState? CurrentMatch => Store.Current;
private string SetupBodyClass => UsesMoveLimit ? string.Empty : "time-setup-mode";
private bool UsesMoveLimit => MatchEngine.UsesMoveLimit(Form.Mode);
private MatchPresetInfo CurrentPreset =>
MatchEngine.Presets.TryGetValue(Form.Preset, out var preset)
? preset
: MatchEngine.Presets[MatchEngine.PresetFast];
private MatchModeInfo CurrentMode =>
MatchEngine.Modes.TryGetValue(Form.Mode, out var mode)
? mode
: MatchEngine.Modes[MatchEngine.ModeTwice];
private string TimingText =>
UsesMoveLimit
? $"Temps configures : Block {MatchEngine.FormatClock(Form.BlockMinutes * 60_000L)}, coup {MatchEngine.FormatClock(Form.MoveSeconds * 1_000L)}."
: $"Temps configures : Block {MatchEngine.FormatClock(Form.BlockMinutes * 60_000L)}, temps de chaque joueur {MatchEngine.FormatClock(Form.TimeInitialMinutes * 60_000L)}.";
private string TimeImpact =>
Form.Mode == MatchEngine.ModeTime
? $"Chronos cumules de {MatchEngine.FormatClock(Form.TimeInitialMinutes * 60_000L)} par joueur, ajustes apres chaque phase cube avec plafond de 120 s pris en compte. Aucun temps par coup en mode Time."
: "Le gagnant du cube commence le Block suivant, avec double coup V2 possible.";
private string QuotaText =>
UsesMoveLimit
? $"Quota actif : {CurrentPreset.Quota} coups par joueur."
: $"Quota actif : {CurrentPreset.Quota} coups par joueur et par Block.";
protected override async Task OnAfterRenderAsync(bool firstRender)
{
if (!firstRender)
{
return;
}
await Store.EnsureLoadedAsync();
_ready = true;
StateHasChanged();
}
private async Task HandleSubmit()
{
var match = MatchEngine.CreateMatch(Form.ToMatchConfig());
Store.SetCurrent(match);
await Store.SaveAsync();
Navigation.NavigateTo("/chrono.html");
}
private void LoadDemo()
=> Form = SetupFormModel.CreateDemo();
private void SetMode(string mode)
=> Form.Mode = mode;
private void SetPreset(string preset)
=> Form.Preset = preset;
private void ResumeMatch()
{
if (CurrentMatch is null)
{
return;
}
Navigation.NavigateTo(MatchEngine.RouteForMatch(CurrentMatch));
}
private async Task ClearMatchAsync()
{
await Store.ClearAsync();
StateHasChanged();
}
private Task ForceRefreshAsync()
=> Browser.ForceRefreshAsync("/application.html").AsTask();
private static string ResumeMatchLabel(MatchState match)
=> string.IsNullOrWhiteSpace(match.Config.MatchLabel) ? "Rencontre ChessCubing" : match.Config.MatchLabel;
private static string CurrentMatchMode(MatchState match)
=> MatchEngine.Modes.TryGetValue(match.Config.Mode, out var mode) ? mode.Label : "ChessCubing Twice";
private static string ResumePhaseLabel(MatchState match)
{
if (!string.IsNullOrEmpty(match.Result))
{
return MatchEngine.ResultText(match);
}
return match.Phase == MatchEngine.PhaseCube ? "Page cube prete" : "Page chrono prete";
}
}

View File

@@ -0,0 +1,604 @@
@page "/chrono"
@page "/chrono.html"
@implements IAsyncDisposable
@inject MatchStore Store
@inject NavigationManager Navigation
<PageTitle>ChessCubing Arena | Phase Chrono</PageTitle>
<PageBody Page="chrono" BodyClass="@ChronoBodyClass" />
@{
var summary = Match is null ? null : BuildSummary();
var blackZone = Match is null ? null : BuildZone(MatchEngine.ColorBlack);
var whiteZone = Match is null ? null : BuildZone(MatchEngine.ColorWhite);
}
@if (!_ready)
{
<main class="phase-shell chrono-stage">
<div class="panel" style="padding: 1.5rem;">Chargement de la phase chrono...</div>
</main>
}
else if (Match is not null && summary is not null && blackZone is not null && whiteZone is not null)
{
<main class="phase-shell chrono-stage">
<header class="phase-header">
<a class="brand-link brand-link-logo" href="application.html" aria-label="Retour a l'application">
<img class="brand-link-icon" src="logo.png" alt="Icone ChessCubing" />
<img class="brand-link-mark" src="transparent.png" alt="Logo ChessCubing" />
</a>
<div class="phase-title">
<p class="eyebrow">Phase chrono</p>
<h1 id="chronoTitle">@summary.Title</h1>
<p id="chronoSubtitle" class="phase-subtitle">@summary.Subtitle</p>
</div>
<button class="button ghost small utility-button" id="openArbiterButton" type="button" @onclick="OpenArbiterModal">
Arbitre
</button>
</header>
<section class="status-strip">
<article class="status-card">
<span id="blockTimerLabel">Temps Block</span>
<strong id="blockTimer">@summary.BlockTimer</strong>
</article>
<article class="status-card" id="moveTimerCard" hidden="@summary.HideMoveTimer">
<span>Temps coup</span>
<strong id="moveTimer">@summary.MoveTimer</strong>
</article>
<article class="status-card wide">
<span id="chronoCenterLabel">@summary.CenterLabel</span>
<strong id="chronoCenterValue">@summary.CenterValue</strong>
</article>
</section>
<section class="faceoff-board">
<article class="player-zone opponent-zone @(blackZone.ActiveZone ? "active-zone" : string.Empty) @(blackZone.HasPlayerClock ? "has-player-clock" : string.Empty)" id="blackZone">
<div class="zone-inner mirrored-mobile">
<div class="zone-head">
<div>
<span class="seat-tag dark-seat">Noir</span>
<h2 id="blackNameChrono">@blackZone.Name</h2>
</div>
<div class="zone-stats">
<strong id="blackMovesChrono">@blackZone.Moves</strong>
<span class="player-clock @(blackZone.NegativeClock ? "negative-clock" : string.Empty) @(blackZone.ActiveClock ? "active-clock" : string.Empty)" id="blackClockChrono">@blackZone.Clock</span>
</div>
</div>
<button class="zone-button dark-button @(blackZone.ActiveTurn ? "active-turn" : string.Empty)" id="blackMoveButton" type="button" disabled="@blackZone.Disabled" @onclick="() => HandleChronoTapAsync(MatchEngine.ColorBlack)">
@blackZone.ButtonText
</button>
<p class="zone-foot" id="blackHintChrono">@blackZone.Hint</p>
</div>
</article>
<article class="phase-spine">
<div class="spine-card">
<p class="micro-label" id="spineLabel">@summary.SpineLabel</p>
<strong id="spineHeadline">@summary.SpineHeadline</strong>
<p id="spineText">@summary.SpineText</p>
</div>
<button class="button primary spine-button" id="primaryChronoButton" type="button" @onclick="HandlePrimaryActionAsync">
@summary.PrimaryButtonText
</button>
</article>
<article class="player-zone @(whiteZone.ActiveZone ? "active-zone" : string.Empty) @(whiteZone.HasPlayerClock ? "has-player-clock" : string.Empty)" id="whiteZone">
<div class="zone-inner">
<div class="zone-head">
<div>
<span class="seat-tag light-seat">Blanc</span>
<h2 id="whiteNameChrono">@whiteZone.Name</h2>
</div>
<div class="zone-stats">
<strong id="whiteMovesChrono">@whiteZone.Moves</strong>
<span class="player-clock @(whiteZone.NegativeClock ? "negative-clock" : string.Empty) @(whiteZone.ActiveClock ? "active-clock" : string.Empty)" id="whiteClockChrono">@whiteZone.Clock</span>
</div>
</div>
<button class="zone-button light-button @(whiteZone.ActiveTurn ? "active-turn" : string.Empty)" id="whiteMoveButton" type="button" disabled="@whiteZone.Disabled" @onclick="() => HandleChronoTapAsync(MatchEngine.ColorWhite)">
@whiteZone.ButtonText
</button>
<p class="zone-foot" id="whiteHintChrono">@whiteZone.Hint</p>
</div>
</article>
</section>
</main>
<section class="modal @(ShowArbiterModal ? string.Empty : "hidden")" id="arbiterModal" aria-hidden="@BoolString(!ShowArbiterModal)">
<div class="modal-backdrop" @onclick="CloseArbiterModal"></div>
<div class="modal-card">
<div class="modal-head">
<div>
<p class="eyebrow">Outils arbitre</p>
<h2>Controles avances</h2>
</div>
<button class="button ghost small" id="closeArbiterButton" type="button" @onclick="CloseArbiterModal">Fermer</button>
</div>
<p class="section-copy" id="arbiterStatus">@summary.ArbiterStatus</p>
<div class="modal-actions">
<button class="button secondary" id="arbiterPauseButton" type="button" @onclick="TogglePauseAsync">
Pause / reprise
</button>
<button class="button secondary" id="arbiterCloseBlockButton" type="button" @onclick="CloseBlockAsync">
Passer au cube
</button>
<button class="button secondary" id="arbiterTimeoutButton" type="button" hidden="@summary.HideMoveTimer" disabled="@summary.HideMoveTimer" @onclick="TimeoutMoveAsync">
@summary.TimeoutButtonText
</button>
<button class="button secondary" id="arbiterSwitchTurnButton" type="button" @onclick="SwitchTurnAsync">
Corriger le trait
</button>
<button class="button ghost" id="arbiterWhiteWinButton" type="button" @onclick="() => SetResultAsync(MatchEngine.ColorWhite)">
Blanc gagne
</button>
<button class="button ghost" id="arbiterBlackWinButton" type="button" @onclick="() => SetResultAsync(MatchEngine.ColorBlack)">
Noir gagne
</button>
<button class="button ghost danger" id="arbiterStopButton" type="button" @onclick='() => SetResultAsync("stopped")'>
Abandon / arret
</button>
<button class="button ghost" id="arbiterResetButton" type="button" @onclick="ResetMatchAsync">
Reinitialiser le match
</button>
</div>
</div>
</section>
}
@code {
private CancellationTokenSource? _tickerCancellation;
private bool _ready;
private bool ShowArbiterModal;
private MatchState? Match => Store.Current;
private string ChronoBodyClass =>
Match is not null && MatchEngine.IsTimeMode(Match)
? "phase-body time-mode"
: "phase-body";
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)
{
await Store.SaveAsync();
Navigation.NavigateTo("/cube.html", replace: true);
return;
}
_tickerCancellation = new CancellationTokenSource();
_ = RunTickerAsync(_tickerCancellation.Token);
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 (MatchEngine.SyncRunningState(match))
{
Store.MarkDirty();
}
await Store.FlushIfDueAsync();
if (string.IsNullOrEmpty(match.Result) && match.Phase == MatchEngine.PhaseCube)
{
await Store.SaveAsync();
await InvokeAsync(() => Navigation.NavigateTo("/cube.html", replace: true));
return;
}
await InvokeAsync(StateHasChanged);
}
}
catch (OperationCanceledException)
{
}
}
private void OpenArbiterModal()
=> ShowArbiterModal = true;
private void CloseArbiterModal()
=> ShowArbiterModal = false;
private async Task HandleChronoTapAsync(string color)
{
var match = Match;
if (match is null || !string.IsNullOrEmpty(match.Result) || match.Phase != MatchEngine.PhaseBlock)
{
return;
}
MatchEngine.SyncRunningState(match);
if (!match.Running || match.CurrentTurn != color)
{
return;
}
if (match.DoubleCoup.Step == 1)
{
MatchEngine.RegisterFreeDoubleMove(match);
}
else
{
MatchEngine.RegisterCountedMove(match, match.DoubleCoup.Step == 2 ? "double" : "standard");
}
await PersistAndRouteAsync();
}
private async Task HandlePrimaryActionAsync()
{
var match = Match;
if (match is null)
{
return;
}
MatchEngine.SyncRunningState(match);
if (!string.IsNullOrEmpty(match.Result))
{
Navigation.NavigateTo("/application.html");
return;
}
if (match.Phase != MatchEngine.PhaseBlock)
{
await Store.SaveAsync();
Navigation.NavigateTo("/cube.html", replace: true);
return;
}
if (match.Running)
{
MatchEngine.PauseBlock(match);
}
else
{
MatchEngine.StartBlock(match);
}
await PersistAndRouteAsync();
}
private async Task TogglePauseAsync()
{
var match = Match;
if (match is null || !string.IsNullOrEmpty(match.Result) || match.Phase != MatchEngine.PhaseBlock)
{
return;
}
MatchEngine.SyncRunningState(match);
if (match.Running)
{
MatchEngine.PauseBlock(match);
}
else
{
MatchEngine.StartBlock(match);
}
await PersistAndRouteAsync();
}
private async Task CloseBlockAsync()
{
var match = Match;
if (match is null || !string.IsNullOrEmpty(match.Result) || match.Phase != MatchEngine.PhaseBlock)
{
return;
}
MatchEngine.SyncRunningState(match);
MatchEngine.RequestBlockClosure(match, $"Cloture manuelle {MatchEngine.GetBlockGenitivePhrase(match)} demandee par l'arbitre.");
await PersistAndRouteAsync();
}
private async Task TimeoutMoveAsync()
{
var match = Match;
if (match is null || !string.IsNullOrEmpty(match.Result) || match.Phase != MatchEngine.PhaseBlock || !MatchEngine.UsesMoveLimit(match))
{
return;
}
MatchEngine.SyncRunningState(match);
MatchEngine.RegisterMoveTimeout(match, false);
await PersistAndRouteAsync();
}
private async Task SwitchTurnAsync()
{
var match = Match;
if (match is null || !string.IsNullOrEmpty(match.Result) || match.Phase != MatchEngine.PhaseBlock)
{
return;
}
MatchEngine.SyncRunningState(match);
match.CurrentTurn = MatchEngine.OpponentOf(match.CurrentTurn);
if (MatchEngine.UsesMoveLimit(match))
{
match.MoveRemainingMs = MatchEngine.GetMoveLimitMs(match);
}
MatchEngine.LogEvent(match, "Trait corrige manuellement par l'arbitre.");
await PersistAndRouteAsync();
}
private async Task SetResultAsync(string result)
{
var match = Match;
if (match is null)
{
return;
}
MatchEngine.SyncRunningState(match);
MatchEngine.SetResult(match, result);
await PersistAndRouteAsync();
}
private async Task ResetMatchAsync()
{
await Store.ClearAsync();
Navigation.NavigateTo("/application.html", replace: true);
}
private async Task PersistAndRouteAsync()
{
Store.MarkDirty();
await Store.SaveAsync();
if (Match is not null && string.IsNullOrEmpty(Match.Result) && Match.Phase == MatchEngine.PhaseCube)
{
Navigation.NavigateTo("/cube.html", replace: true);
return;
}
StateHasChanged();
}
private ChronoSummaryView BuildSummary()
{
var match = Match!;
var blockHeading = MatchEngine.FormatBlockHeading(match, match.BlockNumber);
var subtitle = $"{blockHeading} - {MatchEngine.Modes[match.Config.Mode].Label} - {MatchEngine.RenderModeContext(match)}";
var hideMoveTimer = MatchEngine.IsTimeMode(match);
var timeoutButtonText = $"Depassement {MatchEngine.FormatClock(MatchEngine.GetMoveLimitMs(match))}";
if (!string.IsNullOrEmpty(match.Result))
{
return new ChronoSummaryView(
match.Config.MatchLabel,
subtitle,
MatchEngine.FormatClock(match.BlockRemainingMs),
hideMoveTimer ? "--:--" : MatchEngine.FormatClock(match.MoveRemainingMs),
"Resultat",
MatchEngine.ResultText(match),
"Termine",
MatchEngine.ResultText(match),
"Retournez a la configuration pour lancer une nouvelle rencontre.",
"Retour a l'accueil",
"Le match est termine. Vous pouvez revenir a l'accueil ou reinitialiser.",
hideMoveTimer,
timeoutButtonText);
}
if (match.Running)
{
return new ChronoSummaryView(
match.Config.MatchLabel,
subtitle,
MatchEngine.FormatClock(match.BlockRemainingMs),
hideMoveTimer ? "--:--" : MatchEngine.FormatClock(match.MoveRemainingMs),
"Trait",
MatchEngine.PlayerName(match, match.CurrentTurn),
"Chrono en cours",
$"{blockHeading} actif",
"Chaque joueur tape sur sa grande zone quand son coup est termine. La page cube s'ouvrira automatiquement a la fin de la phase chess.",
"Pause arbitre",
$"{blockHeading} en cours. Joueur au trait : {MatchEngine.PlayerName(match, match.CurrentTurn)}.",
hideMoveTimer,
timeoutButtonText);
}
return new ChronoSummaryView(
match.Config.MatchLabel,
subtitle,
MatchEngine.FormatClock(match.BlockRemainingMs),
hideMoveTimer ? "--:--" : MatchEngine.FormatClock(match.MoveRemainingMs),
"Trait",
MatchEngine.PlayerName(match, match.CurrentTurn),
MatchEngine.IsTimeMode(match) ? "Etat du Block" : "Pret",
blockHeading,
"Demarrez le Block, puis laissez uniquement les deux grandes zones aux joueurs. La page cube prendra automatiquement le relais.",
"Demarrer le Block",
$"{blockHeading} pret. {MatchEngine.PlayerName(match, match.CurrentTurn)} commencera.",
hideMoveTimer,
timeoutButtonText);
}
private ChronoZoneView BuildZone(string color)
{
var match = Match!;
var active = match.CurrentTurn == color;
var clockValue = match.Clocks is not null
? color == MatchEngine.ColorWhite ? match.Clocks.White : match.Clocks.Black
: 0;
var clockText = match.Clocks is not null
? MatchEngine.FormatSignedClock(clockValue)
: $"Dernier cube {MatchEngine.RenderLastCube(match, color)}";
if (!string.IsNullOrEmpty(match.Result))
{
return new ChronoZoneView(
MatchEngine.PlayerName(match, color),
$"{(color == MatchEngine.ColorWhite ? match.Moves.White : match.Moves.Black)} / {match.Quota}",
clockText,
MatchEngine.ResultText(match),
"Le match est termine.",
true,
false,
false,
match.Clocks is not null,
match.Clocks is not null && clockValue < 0,
false);
}
if (!match.Running)
{
return new ChronoZoneView(
MatchEngine.PlayerName(match, color),
$"{(color == MatchEngine.ColorWhite ? match.Moves.White : match.Moves.Black)} / {match.Quota}",
clockText,
"Block en pause",
active
? $"{MatchEngine.GetBlockPhrase(match)} n'a pas encore demarre ou a ete mis en pause."
: $"{MatchEngine.PlayerName(match, match.CurrentTurn)} reprendra au demarrage.",
true,
false,
active,
match.Clocks is not null,
match.Clocks is not null && clockValue < 0,
false);
}
if (!active)
{
return new ChronoZoneView(
MatchEngine.PlayerName(match, color),
$"{(color == MatchEngine.ColorWhite ? match.Moves.White : match.Moves.Black)} / {match.Quota}",
clockText,
"Attends",
$"{MatchEngine.PlayerName(match, match.CurrentTurn)} est en train de jouer.",
true,
false,
false,
match.Clocks is not null,
match.Clocks is not null && clockValue < 0,
false);
}
if (match.DoubleCoup.Step == 1)
{
return new ChronoZoneView(
MatchEngine.PlayerName(match, color),
$"{(color == MatchEngine.ColorWhite ? match.Moves.White : match.Moves.Black)} / {match.Quota}",
clockText,
"1er coup gratuit",
"Ce coup ne compte pas et ne doit pas donner echec.",
false,
true,
true,
match.Clocks is not null,
match.Clocks is not null && clockValue < 0,
match.Clocks is not null);
}
if (match.DoubleCoup.Step == 2)
{
return new ChronoZoneView(
MatchEngine.PlayerName(match, color),
$"{(color == MatchEngine.ColorWhite ? match.Moves.White : match.Moves.Black)} / {match.Quota}",
clockText,
"2e coup du double",
"Ce coup compte dans le quota et l'echec redevient autorise.",
false,
true,
true,
match.Clocks is not null,
match.Clocks is not null && clockValue < 0,
match.Clocks is not null);
}
return new ChronoZoneView(
MatchEngine.PlayerName(match, color),
$"{(color == MatchEngine.ColorWhite ? match.Moves.White : match.Moves.Black)} / {match.Quota}",
clockText,
"J'ai fini mon coup",
"Tape des que ton coup est joue sur l'echiquier.",
false,
true,
true,
match.Clocks is not null,
match.Clocks is not null && clockValue < 0,
match.Clocks is not null);
}
private static string BoolString(bool value)
=> value ? "true" : "false";
private sealed record ChronoSummaryView(
string Title,
string Subtitle,
string BlockTimer,
string MoveTimer,
string CenterLabel,
string CenterValue,
string SpineLabel,
string SpineHeadline,
string SpineText,
string PrimaryButtonText,
string ArbiterStatus,
bool HideMoveTimer,
string TimeoutButtonText);
private sealed record ChronoZoneView(
string Name,
string Moves,
string Clock,
string ButtonText,
string Hint,
bool Disabled,
bool ActiveTurn,
bool ActiveZone,
bool HasPlayerClock,
bool NegativeClock,
bool ActiveClock);
}

View File

@@ -0,0 +1,821 @@
@page "/cube"
@page "/cube.html"
@implements IAsyncDisposable
@inject BrowserBridge Browser
@inject MatchStore Store
@inject NavigationManager Navigation
<PageTitle>ChessCubing Arena | Phase Cube</PageTitle>
<PageBody Page="cube" BodyClass="phase-body" />
@{
var summary = Match is null ? null : BuildSummary();
var blackZone = Match is null ? null : BuildZone(MatchEngine.ColorBlack);
var whiteZone = Match is null ? null : BuildZone(MatchEngine.ColorWhite);
var resultView = Match is null ? null : BuildResultView();
}
@if (!_ready)
{
<main class="phase-shell cube-shell">
<div class="panel" style="padding: 1.5rem;">Chargement de la phase cube...</div>
</main>
}
else if (Match is not null && summary is not null && blackZone is not null && whiteZone is not null)
{
<main class="phase-shell cube-shell">
<header class="phase-header">
<a class="brand-link brand-link-logo" href="application.html" aria-label="Retour a l'application">
<img class="brand-link-icon" src="logo.png" alt="Icone ChessCubing" />
<img class="brand-link-mark" src="transparent.png" alt="Logo ChessCubing" />
</a>
<div class="phase-title">
<p class="eyebrow">Phase cube</p>
<h1 id="cubeTitle">@summary.Title</h1>
<p id="cubeSubtitle" class="phase-subtitle">@summary.Subtitle</p>
</div>
<button class="button ghost small utility-button" id="openCubeHelpButton" type="button" @onclick="OpenHelpModal">
Arbitre
</button>
</header>
<section class="status-strip">
<article class="status-card">
<span id="cubeBlockLabelText">Block</span>
<strong id="cubeBlockLabel">@Match.BlockNumber</strong>
</article>
<article class="status-card">
<span>Temps max</span>
<strong id="cubeElapsed">@MatchEngine.RenderCubeElapsed(Match)</strong>
</article>
<article class="status-card wide">
<span id="cubeCenterLabel">@summary.CenterLabel</span>
<strong id="cubeCenterValue">@summary.CenterValue</strong>
</article>
</section>
<section class="faceoff-board">
<article class="player-zone opponent-zone" id="blackCubeZone">
<div class="zone-inner mirrored-mobile">
<div class="zone-head">
<div>
<span class="seat-tag dark-seat">Noir</span>
<h2 id="blackNameCube">@blackZone.Name</h2>
</div>
<div class="zone-stats">
<strong id="blackCubeResult">@blackZone.ResultText</strong>
<span id="blackCubeCap">@blackZone.MetaText</span>
</div>
</div>
<button class="zone-button dark-button @(blackZone.HoldArmed && !blackZone.HoldReady ? "cube-hold-arming" : string.Empty) @(blackZone.HoldReady ? "cube-hold-ready" : string.Empty)" id="blackCubeButton" type="button" disabled="@blackZone.Disabled" style="@blackZone.ProgressStyle" @onpointerdown="() => HandleCubePointerDownAsync(MatchEngine.ColorBlack)" @onpointerup="() => HandleCubePointerUpAsync(MatchEngine.ColorBlack)" @onpointercancel="() => CancelCubeHold(MatchEngine.ColorBlack)" @onpointerleave="() => CancelCubeHold(MatchEngine.ColorBlack)">
@blackZone.ButtonText
</button>
<p class="zone-foot" id="blackHintCube">@blackZone.Hint</p>
</div>
</article>
<article class="phase-spine">
<div class="spine-card">
<p class="micro-label" id="cubeSpineLabel">@summary.SpineLabel</p>
<strong id="cubeSpineHeadline">@summary.SpineHeadline</strong>
<p id="cubeSpineText">@summary.SpineText</p>
</div>
<button class="button primary spine-button" id="primaryCubeButton" type="button" disabled="@summary.PrimaryDisabled" @onclick="HandlePrimaryActionAsync">
@summary.PrimaryButtonText
</button>
</article>
<article class="player-zone" id="whiteCubeZone">
<div class="zone-inner">
<div class="zone-head">
<div>
<span class="seat-tag light-seat">Blanc</span>
<h2 id="whiteNameCube">@whiteZone.Name</h2>
</div>
<div class="zone-stats">
<strong id="whiteCubeResult">@whiteZone.ResultText</strong>
<span id="whiteCubeCap">@whiteZone.MetaText</span>
</div>
</div>
<button class="zone-button light-button @(whiteZone.HoldArmed && !whiteZone.HoldReady ? "cube-hold-arming" : string.Empty) @(whiteZone.HoldReady ? "cube-hold-ready" : string.Empty)" id="whiteCubeButton" type="button" disabled="@whiteZone.Disabled" style="@whiteZone.ProgressStyle" @onpointerdown="() => HandleCubePointerDownAsync(MatchEngine.ColorWhite)" @onpointerup="() => HandleCubePointerUpAsync(MatchEngine.ColorWhite)" @onpointercancel="() => CancelCubeHold(MatchEngine.ColorWhite)" @onpointerleave="() => CancelCubeHold(MatchEngine.ColorWhite)">
@whiteZone.ButtonText
</button>
<p class="zone-foot" id="whiteHintCube">@whiteZone.Hint</p>
</div>
</article>
</section>
</main>
<section class="modal @(ShowHelpModal ? string.Empty : "hidden")" id="cubeHelpModal" aria-hidden="@BoolString(!ShowHelpModal)">
<div class="modal-backdrop" @onclick="CloseHelpModal"></div>
<div class="modal-card">
<div class="modal-head">
<div>
<p class="eyebrow">Outils arbitre</p>
<h2>Phase cube</h2>
</div>
<button class="button ghost small" id="closeCubeHelpButton" type="button" @onclick="CloseHelpModal">
Fermer
</button>
</div>
<p class="section-copy" id="cubeHelpStatus">@summary.HelpStatus</p>
<div class="modal-actions">
<button class="button secondary" id="replayCubeButton" type="button" @onclick="ReplayCubeAsync">
Rejouer la phase cube
</button>
<button class="button ghost danger" id="cubeResetButton" type="button" @onclick="ResetMatchAsync">
Reinitialiser le match
</button>
</div>
</div>
</section>
<section class="modal @(ShowResultModal && resultView is not null ? string.Empty : "hidden")" id="cubeResultModal" aria-hidden="@BoolString(!(ShowResultModal && resultView is not null))">
<div class="modal-backdrop" @onclick="CloseResultModal"></div>
@if (resultView is not null)
{
<div class="modal-card result-modal-card">
<div class="modal-head">
<div>
<p class="eyebrow">Fin de phase cube</p>
<h2 id="cubeResultModalTitle">@resultView.Title</h2>
</div>
<button class="button ghost small" id="closeCubeResultButton" type="button" @onclick="CloseResultModal">
Fermer
</button>
</div>
<p class="section-copy" id="cubeResultSummary">@resultView.Summary</p>
<div class="cube-result-overview">
<article class="result-pill-card">
<span>Vainqueur cube</span>
<strong id="cubeResultWinner">@resultView.Winner</strong>
</article>
<article class="result-pill-card">
<span>Suite</span>
<strong id="cubeResultOutcome">@resultView.Outcome</strong>
</article>
</div>
<div class="cube-result-player-grid">
<article class="cube-result-player-card">
<span class="seat-tag light-seat">Blanc</span>
<strong id="cubeResultWhiteName">@resultView.WhiteName</strong>
<span id="cubeResultWhiteTime">@resultView.WhiteTime</span>
<span id="cubeResultWhiteDetail">@resultView.WhiteDetail</span>
<span id="cubeResultWhiteClock">@resultView.WhiteClock</span>
</article>
<article class="cube-result-player-card">
<span class="seat-tag dark-seat">Noir</span>
<strong id="cubeResultBlackName">@resultView.BlackName</strong>
<span id="cubeResultBlackTime">@resultView.BlackTime</span>
<span id="cubeResultBlackDetail">@resultView.BlackDetail</span>
<span id="cubeResultBlackClock">@resultView.BlackClock</span>
</article>
</div>
<div class="modal-actions">
<button class="button primary" id="cubeResultActionButton" type="button" @onclick="ApplyResultAsync">
@resultView.ActionLabel
</button>
<button class="button ghost" id="cubeResultDismissButton" type="button" @onclick="CloseResultModal">
Revenir a la phase cube
</button>
</div>
</div>
}
</section>
}
@code {
private readonly Dictionary<string, CubeHoldState> _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);
}

View File

@@ -0,0 +1,238 @@
@page "/"
@page "/index.html"
<PageTitle>ChessCubing Arena | Accueil</PageTitle>
<PageBody BodyClass="home-body" />
<div class="ambient ambient-left"></div>
<div class="ambient ambient-right"></div>
<div class="rules-shell">
<header class="hero hero-home">
<div class="hero-copy">
<a class="logo-lockup" href="index.html" aria-label="Accueil ChessCubing">
<img class="hero-logo-icon" src="logo.png" alt="Icone ChessCubing" />
<img class="hero-logo" src="transparent.png" alt="Logo ChessCubing" />
</a>
<p class="eyebrow">Association ChessCubing</p>
<h1>Les echecs rencontrent le Rubik's Cube</h1>
<p class="lead">
ChessCubing propose un jeu hybride simple a comprendre, intense a
vivre et tres plaisant a regarder. On joue une partie d'echecs, on
passe par une phase cube obligatoire, puis la partie repart avec un
nouveau rythme.
</p>
<div class="hero-actions">
<a class="button primary" href="application.html">Ouvrir l'application</a>
<a class="button secondary" href="reglement.html">Lire le reglement</a>
<a class="button ghost" href="/ethan/">Ouvrir l'appli d'Ethan</a>
</div>
</div>
<aside class="hero-preview">
<div class="preview-card">
<p class="micro-label">Le principe</p>
<ol class="phase-list">
<li>Deux joueurs disputent une partie d'echecs courte et nerveuse.</li>
<li>Une phase cube coupe le rythme et relance la tension.</li>
<li>Le resultat du cube influence immediatement la suite du match.</li>
</ol>
</div>
<div class="preview-banner">
<span class="mini-chip">Decouverte rapide</span>
<strong>On comprend en quelques minutes</strong>
<p>
Le format donne envie de jouer meme quand on decouvre seulement
l'un des deux univers. On peut venir pour les echecs, pour le
cube, ou simplement pour l'energie du duel.
</p>
</div>
</aside>
</header>
<main class="rules-grid">
<section class="panel panel-wide">
<div class="section-heading">
<div>
<p class="eyebrow">L'association</p>
<h2>Une passerelle entre deux passions</h2>
</div>
<p class="section-copy">
L'association ChessCubing veut creer des moments de rencontre, de
decouverte et de jeu partage autour d'un format hybride.
</p>
</div>
<div class="home-story-grid">
<article class="stage-card">
<span class="micro-label">Partager</span>
<strong>Faire decouvrir un format original</strong>
<p>
L'idee est simple : reunir des profils differents autour d'un
jeu lisible, vivant et immediatement intriguant.
</p>
</article>
<article class="stage-card">
<span class="micro-label">Rassembler</span>
<strong>Creer un terrain commun</strong>
<p>
Joueurs d'echecs, cubers, curieux, familles ou clubs peuvent se
retrouver dans une experience facile a lancer et a commenter.
</p>
</article>
<article class="stage-card">
<span class="micro-label">Animer</span>
<strong>Donner envie de revenir jouer</strong>
<p>
Le melange de reflexion, de vitesse et de rebondissements donne
au format un cote spectaculaire qui marche tres bien en
initiation comme en evenement.
</p>
</article>
</div>
</section>
<section class="panel panel-half">
<div class="section-heading">
<div>
<p class="eyebrow">Le jeu en simple</p>
<h2>Comment se passe une partie</h2>
</div>
</div>
<div class="home-mini-grid">
<article class="mini-panel">
<span class="micro-label">1</span>
<strong>On joue une partie d'echecs</strong>
<p>
La partie avance par sequences courtes, ce qui garde un tres bon
rythme et rend l'action facile a suivre.
</p>
</article>
<article class="mini-panel">
<span class="micro-label">2</span>
<strong>On resout un cube</strong>
<p>
Les deux joueurs enchainent avec une phase cube obligatoire qui
sert de relance, de pression et de bascule.
</p>
</article>
<article class="mini-panel">
<span class="micro-label">3</span>
<strong>Le match repart</strong>
<p>
Selon le mode choisi, la phase cube donne l'initiative ou agit
sur les chronos. Le duel ne retombe jamais.
</p>
</article>
</div>
</section>
<section class="panel panel-half">
<div class="section-heading">
<div>
<p class="eyebrow">Pourquoi ca plait</p>
<h2>Un format qui donne envie d'essayer</h2>
</div>
</div>
<div class="home-mini-grid two-columns">
<article class="mini-panel">
<strong>Tres lisible</strong>
<p>On comprend vite quand la pression monte et pourquoi le cube change tout.</p>
</article>
<article class="mini-panel">
<strong>Toujours relance</strong>
<p>Chaque phase cube remet du suspense dans la partie et ouvre de nouvelles options.</p>
</article>
<article class="mini-panel">
<strong>Convivial</strong>
<p>Le format marche bien en initiation, en animation de club ou en demonstration publique.</p>
</article>
<article class="mini-panel">
<strong>Memorable</strong>
<p>On ressort d'une partie avec des coups, des temps et des retournements dont on se souvient.</p>
</article>
</div>
</section>
<section class="panel panel-wide">
<div class="section-heading">
<div>
<p class="eyebrow">Deux formats</p>
<h2>Deux manieres de vivre le duel</h2>
</div>
<p class="section-copy">
Le reglement officiel propose deux lectures du meme concept, l'une
plus orientee initiative, l'autre plus orientee gestion du temps.
</p>
</div>
<div class="rules-compare">
<article class="format-card twice-card">
<div class="format-head">
<span class="mini-chip">ChessCubing Twice</span>
<h3>Le cube donne l'elan</h3>
<p>
Le joueur le plus rapide sur la phase cube prend le depart de
la partie suivante et peut meme obtenir un double coup dans
certaines situations.
</p>
</div>
<div class="format-badges">
<span>Mode nerveux</span>
<span>Initiative forte</span>
<span>Effet immediat</span>
</div>
</article>
<article class="format-card time-card">
<div class="format-head">
<span class="mini-chip">ChessCubing Time</span>
<h3>Le cube agit sur les chronos</h3>
<p>
Ici, on garde le trait mais la performance sur le cube retire
ou ajoute du temps selon l'alternance des blocs.
</p>
</div>
<div class="format-badges">
<span>Gestion de temps</span>
<span>Blocs - et +</span>
<span>Suspense permanent</span>
</div>
</article>
</div>
</section>
<section class="panel panel-wide cta-panel">
<div class="section-heading">
<div>
<p class="eyebrow">Envie de tester</p>
<h2>Choisis ton entree</h2>
</div>
<p class="section-copy">
Tu peux decouvrir le jeu tranquillement avec la page reglement ou
aller directement vers l'application officielle de match.
</p>
</div>
<div class="source-grid">
<a class="source-card" href="application.html">
<span class="micro-label">Application</span>
<strong>Ouvrir la partie arbitrage</strong>
<p>Configurer une rencontre, lancer le chrono et enchainer avec la phase cube.</p>
</a>
<a class="source-card" href="reglement.html">
<span class="micro-label">Reglement</span>
<strong>Comprendre les formats officiels</strong>
<p>Retrouver une presentation claire du Twice, du Time et des regles de match.</p>
</a>
<a class="source-card" href="ChessCubing_Twice_Reglement_Officiel_V2-1.pdf" target="_blank">
<span class="micro-label">PDF officiel</span>
<strong>Lire le texte source</strong>
<p>Consulter directement le document officiel si tu veux tous les details.</p>
</a>
</div>
</section>
</main>
</div>

View File

@@ -0,0 +1,39 @@
@page "/not-found"
<PageTitle>ChessCubing Arena | Page introuvable</PageTitle>
<PageBody BodyClass="home-body" />
<div class="ambient ambient-left"></div>
<div class="ambient ambient-right"></div>
<div class="rules-shell">
<section class="panel panel-wide cta-panel" style="margin-top: 2rem;">
<div class="section-heading">
<div>
<p class="eyebrow">Navigation</p>
<h1>Page introuvable</h1>
</div>
<p class="section-copy">
Le lien demande n'existe pas ou n'est plus expose par l'application Blazor.
</p>
</div>
<div class="source-grid">
<a class="source-card" href="index.html">
<span class="micro-label">Accueil</span>
<strong>Retourner au site</strong>
<p>Revenir a la page d'accueil de ChessCubing Arena.</p>
</a>
<a class="source-card" href="application.html">
<span class="micro-label">Application</span>
<strong>Ouvrir l'arbitrage</strong>
<p>Configurer un match et basculer vers les phases chrono et cube.</p>
</a>
<a class="source-card" href="reglement.html">
<span class="micro-label">Reglement</span>
<strong>Lire les formats officiels</strong>
<p>Retrouver la synthese des reglements Twice et Time.</p>
</a>
</div>
</section>
</div>

View File

@@ -0,0 +1,344 @@
@page "/reglement"
@page "/reglement.html"
<PageTitle>ChessCubing Arena | Reglement officiel</PageTitle>
<PageBody BodyClass="rules-body" />
<div class="ambient ambient-left"></div>
<div class="ambient ambient-right"></div>
<div class="rules-shell">
<header class="hero hero-rules">
<div class="hero-copy">
<a class="logo-lockup" href="index.html" aria-label="Accueil ChessCubing">
<img class="hero-logo-icon" src="logo.png" alt="Icone ChessCubing" />
<img class="hero-logo" src="transparent.png" alt="Logo ChessCubing" />
</a>
<p class="eyebrow">Referentiel officiel</p>
<h1>Reglement ChessCubing</h1>
<p class="lead">
Cette page reprend les regles officielles en vigueur du
ChessCubing Twice et du ChessCubing Time pour offrir une lecture
rapide, claire et directement exploitable en club, en demonstration
ou en arbitrage.
</p>
<div class="hero-pills">
<span>Twice V2</span>
<span>Time V1</span>
<span>Entree en vigueur le 4 fevrier 2026</span>
<span>Fin uniquement par mat ou abandon</span>
</div>
<div class="hero-actions">
<a class="button primary" href="application.html">Ouvrir l'application</a>
<a class="button secondary" href="index.html">Retour a l'accueil</a>
</div>
</div>
<aside class="hero-preview">
<div class="preview-card">
<p class="micro-label">Reperes rapides</p>
<div class="rule-metrics">
<article class="metric-chip">
<span>Partie</span>
<strong>180 s</strong>
</article>
<article class="metric-chip">
<span>Cube</span>
<strong>Obligatoire</strong>
</article>
<article class="metric-chip">
<span>Quotas</span>
<strong>6 / 8 / 10</strong>
</article>
<article class="metric-chip">
<span>Chrono cube</span>
<strong>Cap 120 s en Time</strong>
</article>
</div>
</div>
<div class="preview-banner">
<span class="mini-chip">Deux formats</span>
<strong>Meme structure, effets differents</strong>
<p>
Le mode Twice donne l'initiative au gagnant du cube et peut
declencher un double coup. Le mode Time ajuste les chronos sur
des blocs - et des blocs +, sans priorite ni double coup.
</p>
</div>
</aside>
</header>
<main class="rules-grid">
<section class="panel panel-wide">
<div class="section-heading">
<div>
<p class="eyebrow">Vue d'ensemble</p>
<h2>Le match en 4 temps</h2>
</div>
<p class="section-copy">
Les deux reglements partagent la meme colonne vertebrale : un
match en parties successives, interrompues par une phase cube
obligatoire.
</p>
</div>
<div class="rules-stage-grid">
<article class="stage-card">
<span class="micro-label">1. Avant le match</span>
<strong>Installation et tirage</strong>
<p>
L'arbitre controle le materiel, les cubes, les caches, les
melanges, puis le tirage au sort qui attribue Blancs ou Noirs.
</p>
</article>
<article class="stage-card">
<span class="micro-label">2. Partie d'echecs</span>
<strong>180 secondes de jeu</strong>
<p>
Chaque partie comporte une phase d'echecs limitee par une duree
fixe et par un quota de coups selon le format FAST, FREEZE ou
MASTERS.
</p>
</article>
<article class="stage-card">
<span class="micro-label">3. Phase cube</span>
<strong>Un cube identique par joueur</strong>
<p>
L'application designe le numero du cube, les deux joueurs
resolvent le meme melange, et le resultat influe ensuite selon
le mode choisi.
</p>
</article>
<article class="stage-card">
<span class="micro-label">4. Fin de partie</span>
<strong>Jamais au temps</strong>
<p>
La partie se termine uniquement par echec et mat ou abandon,
jamais par simple chute au temps ou depassement d'une partie.
</p>
</article>
</div>
</section>
<section class="panel panel-half">
<div class="section-heading">
<div>
<p class="eyebrow">Materiel commun</p>
<h2>Base officielle</h2>
</div>
</div>
<ul class="rule-list">
<li>Un echiquier et un jeu de pieces reglementaires.</li>
<li>Huit Rubik's Cubes 3x3, soit quatre par joueur.</li>
<li>Des caches opaques numerotes de 1 a 4.</li>
<li>Des melanges strictement identiques pour chaque numero.</li>
<li>L'application officielle ChessCubing.</li>
<li>Un arbitre pour piloter le match et les transitions.</li>
</ul>
</section>
<section class="panel panel-half">
<div class="section-heading">
<div>
<p class="eyebrow">Arbitrage</p>
<h2>Check-list terrain</h2>
</div>
</div>
<ul class="rule-list">
<li>Verifier la presence des huit cubes et des caches numerotes.</li>
<li>Confirmer des melanges identiques sous chaque numero.</li>
<li>Preparer l'echiquier et la variante dans l'application.</li>
<li>Controler le tirage au sort avant la premiere partie.</li>
<li>Declencher chaque phase cube au bon moment.</li>
<li>Surveiller le respect du plafond de 120 s en mode Time.</li>
</ul>
</section>
<section class="panel panel-wide" id="formats">
<div class="section-heading">
<div>
<p class="eyebrow">Formats officiels</p>
<h2>Twice et Time, cote a cote</h2>
</div>
<p class="section-copy">
Les deux formats partagent les parties et la phase cube, mais leur
logique d'avantage differe completement.
</p>
</div>
<div class="rules-compare">
<article class="format-card twice-card">
<div class="format-head">
<span class="mini-chip">Version V2</span>
<h3>ChessCubing Twice</h3>
<p>
Le gagnant du cube obtient l'initiative sur la partie suivante,
avec une regle de double coup encadree.
</p>
</div>
<div class="format-badges">
<span>Partie : 180 s</span>
<span>Temps par coup : 20 s max</span>
<span>FAST / FREEZE / MASTERS : 6 / 8 / 10</span>
</div>
<div class="format-section">
<h4>Debut et fin des parties</h4>
<ul class="rule-list compact">
<li>Les Blancs commencent la partie 1.</li>
<li>Aucun double coup n'est possible a la partie 1.</li>
<li>Une partie s'arrete a 180 s ou quand les deux quotas sont atteints.</li>
<li>Il est interdit de finir une partie avec un roi en echec.</li>
<li>Si le dernier coup donne echec, les coups necessaires pour parer sont joues hors quota.</li>
</ul>
</div>
<div class="format-section">
<h4>Phase cube</h4>
<ul class="rule-list compact">
<li>Le numero du cube est designe par l'application.</li>
<li>Les deux joueurs recoivent un melange identique.</li>
<li>Le joueur le plus rapide gagne la phase cube.</li>
<li>En cas d'egalite parfaite, la phase cube est rejouee.</li>
<li>Le gagnant du cube commence la partie suivante.</li>
</ul>
</div>
<div class="callout">
<span class="micro-label">Double coup V2</span>
<strong>Condition stricte</strong>
<p>
Le gagnant du cube ne doit pas avoir joue le dernier coup de
la partie precedente. Le premier coup est gratuit, non compte,
peut capturer mais ne peut pas donner echec. Le second compte
comme premier coup de la partie, peut donner echec, mais ne peut
capturer qu'un pion ou une piece mineure.
</p>
</div>
<div class="format-section">
<h4>Temps par coup et fin de partie</h4>
<ul class="rule-list compact">
<li>Chaque coup doit etre joue en 20 secondes maximum.</li>
<li>En depassement, le coup est perdu et compte dans le quota.</li>
<li>Sur le premier coup d'un double coup, le depassement annule l'avantage.</li>
<li>Sur le second coup d'un double coup, le coup est perdu et comptabilise.</li>
<li>La partie se termine uniquement par mat ou abandon.</li>
</ul>
</div>
</article>
<article class="format-card time-card">
<div class="format-head">
<span class="mini-chip">Version V1</span>
<h3>ChessCubing Time</h3>
<p>
Ici, la phase cube n'offre pas l'initiative mais modifie les
chronos selon une alternance bloc - puis bloc +.
</p>
</div>
<div class="format-badges">
<span>Temps initial : 10 min / joueur</span>
<span>Block : 180 s</span>
<span>Cap cube pris en compte : 120 s</span>
</div>
<div class="format-section">
<h4>Structure temporelle</h4>
<ul class="rule-list compact">
<li>La structure des Blocks est identique a celle du Twice.</li>
<li>Les quotas de coups restent les memes : 6, 8 ou 10.</li>
<li>Chaque Block est suivi d'une phase cube obligatoire.</li>
<li>Le trait est conserve apres la phase cube.</li>
<li>Aucun systeme de priorite ou de double coup n'existe.</li>
</ul>
</div>
<div class="split-callouts">
<article class="split-card minus-card">
<span class="micro-label">Block impair</span>
<strong>Bloc -</strong>
<p>
Le temps de resolution du cube est retire du chrono du
joueur concerne, avec un plafond de 120 secondes.
</p>
</article>
<article class="split-card plus-card">
<span class="micro-label">Block pair</span>
<strong>Bloc +</strong>
<p>
Le temps de resolution du cube est ajoute au chrono adverse,
lui aussi plafonne a 120 secondes.
</p>
</article>
</div>
<div class="format-section">
<h4>Exemples officiels</h4>
<ul class="rule-list compact">
<li>Bloc - : 35 s retire 35 s a son propre chrono.</li>
<li>Bloc - : 110 s retire 110 s a son propre chrono.</li>
<li>Bloc + : 25 s ajoute 25 s au chrono adverse.</li>
<li>Bloc + : 150 s ajoute 120 s au chrono adverse.</li>
</ul>
</div>
<div class="callout">
<span class="micro-label">Reprise du jeu</span>
<strong>Pas de coup pendant le cube</strong>
<p>
Aucun coup ne peut etre joue pendant la phase cube. Des que
les deux resolutions sont terminees, les chronos sont ajustes
et la partie reprend immediatement.
</p>
</div>
<div class="format-section">
<h4>Fin de partie et vigilance</h4>
<ul class="rule-list compact">
<li>La partie s'arrete uniquement par mat ou abandon volontaire.</li>
<li>L'arbitre surveille le chronometrage exact et le plafond de 120 s.</li>
<li>L'absence de priorite et de double coup fait partie des points cles du mode.</li>
</ul>
</div>
</article>
</div>
</section>
<section class="panel panel-wide">
<div class="section-heading">
<div>
<p class="eyebrow">Sources</p>
<h2>Documents officiels</h2>
</div>
<p class="section-copy">
Cette synthese reprend les versions officielles actuellement
embarquees dans l'application.
</p>
</div>
<div class="source-grid">
<a class="source-card" href="ChessCubing_Twice_Reglement_Officiel_V2-1.pdf" target="_blank">
<span class="micro-label">PDF officiel</span>
<strong>ChessCubing Twice V2</strong>
<p>Version entree en vigueur le 4 fevrier 2026.</p>
</a>
<a class="source-card" href="ChessCubing_Time_Reglement_Officiel_V1-1.pdf" target="_blank">
<span class="micro-label">PDF officiel</span>
<strong>ChessCubing Time V1</strong>
<p>Version entree en vigueur le 4 fevrier 2026.</p>
</a>
<a class="source-card" href="application.html">
<span class="micro-label">Application</span>
<strong>Retour a ChessCubing Arena</strong>
<p>Revenir a la configuration du match et a l'arbitrage mobile.</p>
</a>
</div>
</section>
</main>
</div>

View File

@@ -0,0 +1,14 @@
using ChessCubing.App;
using ChessCubing.App.Services;
using Microsoft.AspNetCore.Components.Web;
using Microsoft.AspNetCore.Components.WebAssembly.Hosting;
var builder = WebAssemblyHostBuilder.CreateDefault(args);
builder.RootComponents.Add<App>("#app");
builder.RootComponents.Add<HeadOutlet>("head::after");
builder.Services.AddScoped(_ => new HttpClient { BaseAddress = new Uri(builder.HostEnvironment.BaseAddress) });
builder.Services.AddScoped<BrowserBridge>();
builder.Services.AddScoped<MatchStore>();
await builder.Build().RunAsync();

View File

@@ -0,0 +1,15 @@
{
"$schema": "https://json.schemastore.org/launchsettings.json",
"profiles": {
"http": {
"commandName": "Project",
"dotnetRunMessages": true,
"launchBrowser": true,
"inspectUri": "{wsProtocol}://{url.hostname}:{url.port}/_framework/debug/ws-proxy?browser={browserInspectUri}",
"applicationUrl": "http://localhost:5263",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
}
}
}
}

View File

@@ -0,0 +1,30 @@
using Microsoft.JSInterop;
namespace ChessCubing.App.Services;
public sealed class BrowserBridge(IJSRuntime jsRuntime)
{
public ValueTask StartViewportAsync()
=> jsRuntime.InvokeVoidAsync("chesscubingViewport.start");
public ValueTask SetBodyStateAsync(string? page, string? bodyClass)
=> jsRuntime.InvokeVoidAsync("chesscubingPage.setBodyState", page, bodyClass ?? string.Empty);
public ValueTask<string?> ReadMatchJsonAsync()
=> jsRuntime.InvokeAsync<string?>("chesscubingStorage.getMatchState", MatchStore.StorageKey, MatchStore.WindowNameKey);
public ValueTask WriteMatchJsonAsync(string json)
=> jsRuntime.InvokeVoidAsync("chesscubingStorage.setMatchState", MatchStore.StorageKey, MatchStore.WindowNameKey, json);
public ValueTask ClearMatchAsync()
=> jsRuntime.InvokeVoidAsync("chesscubingStorage.clearMatchState", MatchStore.StorageKey);
public ValueTask<bool> PlayCubePhaseAlertAsync()
=> jsRuntime.InvokeAsync<bool>("chesscubingAudio.playCubePhaseAlert");
public ValueTask PrimeAudioAsync()
=> jsRuntime.InvokeVoidAsync("chesscubingAudio.prime");
public ValueTask ForceRefreshAsync(string path)
=> jsRuntime.InvokeVoidAsync("chesscubingBrowser.forceRefresh", path);
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,106 @@
using System.Text.Json;
using System.Text.Json.Serialization;
using ChessCubing.App.Models;
namespace ChessCubing.App.Services;
public sealed class MatchStore(BrowserBridge browser)
{
public const string StorageKey = "chesscubing-arena-state-v2";
public const string WindowNameKey = "chesscubing-arena-state-v2:";
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web)
{
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
};
private bool _dirty;
private long _lastPersistedAt;
public MatchState? Current { get; private set; }
public bool IsLoaded { get; private set; }
public async Task EnsureLoadedAsync()
{
if (IsLoaded)
{
return;
}
try
{
var raw = await browser.ReadMatchJsonAsync();
if (!string.IsNullOrWhiteSpace(raw))
{
var parsed = JsonSerializer.Deserialize<MatchState>(raw, JsonOptions);
if (parsed is not null && MatchEngine.IsSupportedSchemaVersion(parsed.SchemaVersion))
{
MatchEngine.NormalizeRecoveredMatch(parsed);
Current = parsed;
}
}
}
catch
{
Current = null;
}
IsLoaded = true;
_lastPersistedAt = MatchEngine.NowUnixMs();
}
public void SetCurrent(MatchState? match)
{
Current = match;
MarkDirty();
}
public void MarkDirty()
=> _dirty = true;
public async Task SaveAsync()
{
if (!IsLoaded)
{
return;
}
if (Current is null)
{
await browser.ClearMatchAsync();
_dirty = false;
_lastPersistedAt = MatchEngine.NowUnixMs();
return;
}
var json = JsonSerializer.Serialize(Current, JsonOptions);
await browser.WriteMatchJsonAsync(json);
_dirty = false;
_lastPersistedAt = MatchEngine.NowUnixMs();
}
public async Task FlushIfDueAsync(long minimumIntervalMs = 1_000)
{
if (!_dirty)
{
return;
}
if (MatchEngine.NowUnixMs() - _lastPersistedAt < minimumIntervalMs)
{
return;
}
await SaveAsync();
}
public async Task ClearAsync()
{
Current = null;
IsLoaded = true;
_dirty = false;
_lastPersistedAt = MatchEngine.NowUnixMs();
await browser.ClearMatchAsync();
}
}

View File

@@ -0,0 +1,11 @@
@using System.Globalization
@using System.Net.Http
@using ChessCubing.App
@using ChessCubing.App.Components
@using ChessCubing.App.Models
@using ChessCubing.App.Services
@using Microsoft.AspNetCore.Components
@using Microsoft.AspNetCore.Components.Forms
@using Microsoft.AspNetCore.Components.Routing
@using Microsoft.AspNetCore.Components.Web
@using Microsoft.JSInterop

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 KiB

View File

@@ -0,0 +1,45 @@
<!DOCTYPE html>
<html lang="fr">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
<meta name="theme-color" content="#140700" />
<meta name="application-name" content="ChessCubing Arena" />
<meta name="mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
<meta name="apple-mobile-web-app-title" content="ChessCubing" />
<meta name="description" content="Application officielle ChessCubing Arena en Blazor .NET 10." />
<title>ChessCubing Arena</title>
<base href="/" />
<link rel="preload" id="webassembly" />
<link rel="icon" type="image/png" href="favicon.png" />
<link rel="shortcut icon" href="favicon.png" />
<link rel="apple-touch-icon" href="logo.png" />
<link rel="manifest" href="site.webmanifest" />
<link rel="stylesheet" href="styles.css" />
<link href="ChessCubing.App.styles.css" rel="stylesheet" />
<script src="js/chesscubing-interop.js"></script>
<script type="importmap"></script>
</head>
<body>
<div id="app">
<div style="min-height: 100dvh; display: grid; place-items: center; padding: 2rem; color: #f5f7fb;">
<div style="text-align: center;">
<strong style="display: block; margin-bottom: 0.75rem;">ChessCubing Arena</strong>
<span>Chargement de l'application Blazor...</span>
</div>
</div>
</div>
<div id="blazor-error-ui">
Une erreur inattendue est survenue.
<a href="." class="reload">Recharger</a>
<span class="dismiss">x</span>
</div>
<script src="_framework/blazor.webassembly#[.{fingerprint}].js"></script>
</body>
</html>

View File

@@ -0,0 +1,200 @@
(() => {
const assetTokenStorageKey = "chesscubing-arena-asset-token";
let viewportStarted = false;
let audioContext = null;
function syncViewportHeight() {
const visibleHeight = window.visualViewport?.height ?? window.innerHeight;
const viewportTopOffset = window.visualViewport?.offsetTop ?? 0;
const viewportHeight = Math.max(
visibleHeight + viewportTopOffset,
window.innerHeight,
document.documentElement.clientHeight,
);
document.documentElement.style.setProperty("--app-visible-height", `${Math.round(visibleHeight)}px`);
document.documentElement.style.setProperty("--app-viewport-top", `${Math.round(viewportTopOffset)}px`);
document.documentElement.style.setProperty("--app-viewport-height", `${Math.round(viewportHeight)}px`);
}
function startViewport() {
if (viewportStarted) {
return;
}
viewportStarted = true;
syncViewportHeight();
window.addEventListener("load", syncViewportHeight);
window.addEventListener("resize", syncViewportHeight);
window.addEventListener("scroll", syncViewportHeight, { passive: true });
window.addEventListener("pageshow", syncViewportHeight);
window.addEventListener("orientationchange", syncViewportHeight);
window.visualViewport?.addEventListener("resize", syncViewportHeight);
window.visualViewport?.addEventListener("scroll", syncViewportHeight);
window.setTimeout(syncViewportHeight, 0);
window.setTimeout(syncViewportHeight, 150);
window.setTimeout(syncViewportHeight, 400);
window.requestAnimationFrame(() => window.requestAnimationFrame(syncViewportHeight));
}
function getAudioContext() {
const AudioContextClass = window.AudioContext || window.webkitAudioContext;
if (!AudioContextClass) {
return null;
}
if (!audioContext) {
try {
audioContext = new AudioContextClass();
} catch {
return null;
}
}
return audioContext;
}
function primeAudio() {
const context = getAudioContext();
if (!context || context.state === "running") {
return;
}
context.resume().catch(() => undefined);
}
function playCubePhaseAlert() {
primeAudio();
const context = getAudioContext();
if (!context || context.state !== "running") {
return false;
}
const pattern = [
{ frequency: 740, offset: 0, duration: 0.12, gain: 0.035 },
{ frequency: 988, offset: 0.18, duration: 0.12, gain: 0.04 },
{ frequency: 1318, offset: 0.36, duration: 0.2, gain: 0.05 },
];
const startAt = context.currentTime + 0.02;
pattern.forEach(({ frequency, offset, duration, gain }) => {
const oscillator = context.createOscillator();
const envelope = context.createGain();
const toneStartAt = startAt + offset;
oscillator.type = "triangle";
oscillator.frequency.setValueAtTime(frequency, toneStartAt);
envelope.gain.setValueAtTime(0.0001, toneStartAt);
envelope.gain.exponentialRampToValueAtTime(gain, toneStartAt + 0.02);
envelope.gain.exponentialRampToValueAtTime(0.0001, toneStartAt + duration);
oscillator.connect(envelope);
envelope.connect(context.destination);
oscillator.start(toneStartAt);
oscillator.stop(toneStartAt + duration + 0.03);
});
return true;
}
window.chesscubingPage = {
setBodyState(page, bodyClass) {
if (page) {
document.body.dataset.page = page;
} else {
delete document.body.dataset.page;
}
document.body.className = bodyClass || "";
},
};
window.chesscubingViewport = {
start: startViewport,
};
window.chesscubingStorage = {
getMatchState(storageKey, windowNameKey) {
try {
const raw = window.localStorage.getItem(storageKey);
if (raw) {
return raw;
}
} catch {
}
try {
if (!window.name || !window.name.startsWith(windowNameKey)) {
return null;
}
return window.name.slice(windowNameKey.length);
} catch {
return null;
}
},
setMatchState(storageKey, windowNameKey, value) {
try {
window.name = `${windowNameKey}${value}`;
} catch {
}
try {
window.localStorage.setItem(storageKey, value);
} catch {
}
},
clearMatchState(storageKey) {
try {
window.localStorage.removeItem(storageKey);
} catch {
}
try {
window.name = "";
} catch {
}
},
};
window.chesscubingAudio = {
prime: primeAudio,
playCubePhaseAlert,
};
window.chesscubingBrowser = {
async forceRefresh(path) {
const refreshToken = `${Date.now()}`;
try {
window.sessionStorage.setItem(assetTokenStorageKey, refreshToken);
} catch {
}
if ("caches" in window) {
try {
const cacheKeys = await window.caches.keys();
await Promise.all(cacheKeys.map((cacheKey) => window.caches.delete(cacheKey)));
} catch {
}
}
if ("serviceWorker" in navigator) {
try {
const registrations = await navigator.serviceWorker.getRegistrations();
await Promise.all(registrations.map((registration) => registration.update().catch(() => undefined)));
await Promise.all(registrations.map((registration) => registration.unregister().catch(() => undefined)));
} catch {
}
}
const targetUrl = new URL(path, window.location.href);
targetUrl.searchParams.set("refresh", refreshToken);
window.location.replace(targetUrl.toString());
},
};
startViewport();
})();