Replace custom socket implementation with SignalR.
Replace MSAL and custom cookie auth with Microsoft.Identity.EntityFramework Also some UI redesign to accommodate different login experience.
This commit is contained in:
6
Shogi.UI/Pages/Play/GameBoard/EmptyGameBoard.razor
Normal file
6
Shogi.UI/Pages/Play/GameBoard/EmptyGameBoard.razor
Normal file
@@ -0,0 +1,6 @@
|
||||
@using Contracts.Types;
|
||||
|
||||
<GameBoardPresentation Perspective="WhichPlayer.Player1" />
|
||||
|
||||
@code {
|
||||
}
|
||||
76
Shogi.UI/Pages/Play/GameBoard/GameBoard.razor
Normal file
76
Shogi.UI/Pages/Play/GameBoard/GameBoard.razor
Normal file
@@ -0,0 +1,76 @@
|
||||
@using Shogi.Contracts.Api
|
||||
@using Shogi.Contracts.Types
|
||||
@using System.Text.RegularExpressions
|
||||
@using System.Security.Claims
|
||||
|
||||
@implements IDisposable
|
||||
@inject ShogiApi ShogiApi
|
||||
@inject PromotePrompt PromotePrompt
|
||||
@inject GameHubNode hubNode
|
||||
@inject NavigationManager navigator
|
||||
|
||||
@if (session == null)
|
||||
{
|
||||
<EmptyGameBoard />
|
||||
}
|
||||
else if (isSpectating)
|
||||
{
|
||||
<SpectatorGameBoard Session="session" />
|
||||
}
|
||||
else
|
||||
{
|
||||
<SeatedGameBoard Perspective="perspective" Session="session" />
|
||||
}
|
||||
|
||||
|
||||
@code {
|
||||
[CascadingParameter]
|
||||
private Task<AuthenticationState> authenticationState { get; set; }
|
||||
|
||||
[Parameter]
|
||||
[EditorRequired]
|
||||
public string SessionId { get; set; } = string.Empty;
|
||||
|
||||
Session? session;
|
||||
private WhichPlayer perspective;
|
||||
private bool isSpectating;
|
||||
private List<IDisposable> disposables = new List<IDisposable>(2);
|
||||
|
||||
protected override void OnInitialized()
|
||||
{
|
||||
navigator.RegisterLocationChangingHandler((a) => new ValueTask(hubNode.Unsubscribe(SessionId)));
|
||||
disposables.Add(hubNode.OnSessionJoined(async () => await FetchSession()));
|
||||
disposables.Add(hubNode.OnPieceMoved(async () => await FetchSession()));
|
||||
}
|
||||
|
||||
protected override async Task OnParametersSetAsync()
|
||||
{
|
||||
await hubNode.Subscribe(SessionId);
|
||||
await FetchSession();
|
||||
}
|
||||
|
||||
async Task FetchSession()
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(SessionId))
|
||||
{
|
||||
this.session = await ShogiApi.GetSession(SessionId);
|
||||
if (this.session != null)
|
||||
{
|
||||
var state = await authenticationState;
|
||||
var accountId = state.User.Claims.First(c => c.Type == ClaimTypes.Name).Value;
|
||||
this.perspective = accountId == session.Player1 ? WhichPlayer.Player1 : WhichPlayer.Player2;
|
||||
this.isSpectating = !(accountId == this.session.Player1 || accountId == this.session.Player2);
|
||||
}
|
||||
StateHasChanged();
|
||||
}
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
foreach (var d in disposables)
|
||||
{
|
||||
d.Dispose();
|
||||
}
|
||||
disposables.Clear();
|
||||
}
|
||||
}
|
||||
183
Shogi.UI/Pages/Play/GameBoard/GameBoardPresentation.razor
Normal file
183
Shogi.UI/Pages/Play/GameBoard/GameBoardPresentation.razor
Normal file
@@ -0,0 +1,183 @@
|
||||
@using Shogi.Contracts.Types;
|
||||
@using System.Text.Json;
|
||||
@inject PromotePrompt PromotePrompt;
|
||||
|
||||
<article class="game-board">
|
||||
@if (IsSpectating)
|
||||
{
|
||||
<aside class="icons">
|
||||
<div title="You are spectating.">
|
||||
<span>Camera icon</span>
|
||||
</div>
|
||||
</aside>
|
||||
}
|
||||
<!-- Game board -->
|
||||
<section class="board" data-perspective="@Perspective">
|
||||
@for (var rank = 1; rank < 10; rank++)
|
||||
{
|
||||
foreach (var file in Files)
|
||||
{
|
||||
var position = $"{file}{rank}";
|
||||
var piece = Session?.BoardState.Board[position];
|
||||
var isSelected = piece != null && SelectedPosition == position;
|
||||
<div class="tile" @onclick="OnClickTileInternal(piece, position)"
|
||||
data-position="@(position)"
|
||||
data-selected="@(isSelected)"
|
||||
style="grid-area: @position">
|
||||
@if (piece != null)
|
||||
{
|
||||
<GamePiece Piece="piece" Perspective="Perspective" />
|
||||
}
|
||||
</div>
|
||||
}
|
||||
}
|
||||
<div class="ruler vertical" style="grid-area: rank">
|
||||
<span>9</span>
|
||||
<span>8</span>
|
||||
<span>7</span>
|
||||
<span>6</span>
|
||||
<span>5</span>
|
||||
<span>4</span>
|
||||
<span>3</span>
|
||||
<span>2</span>
|
||||
<span>1</span>
|
||||
</div>
|
||||
<div class="ruler" style="grid-area: file">
|
||||
<span>A</span>
|
||||
<span>B</span>
|
||||
<span>C</span>
|
||||
<span>D</span>
|
||||
<span>E</span>
|
||||
<span>F</span>
|
||||
<span>G</span>
|
||||
<span>H</span>
|
||||
<span>I</span>
|
||||
</div>
|
||||
|
||||
<!-- Promote prompt -->
|
||||
<div class="promote-prompt" data-visible="@PromotePrompt.IsVisible">
|
||||
<p>Do you wish to promote?</p>
|
||||
<div>
|
||||
<button type="button">Yes</button>
|
||||
<button type="button">No</button>
|
||||
<button type="button">Cancel</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Side board -->
|
||||
@if (Session != null)
|
||||
{
|
||||
<aside class="side-board PrimaryTheme ThemeVariant--Contrast">
|
||||
<div class="player-area">
|
||||
<div class="hand">
|
||||
@if (opponentHand.Any())
|
||||
{
|
||||
@foreach (var piece in opponentHand)
|
||||
{
|
||||
<div class="tile">
|
||||
<GamePiece Piece="piece" Perspective="Perspective" />
|
||||
</div>
|
||||
}
|
||||
}
|
||||
</div>
|
||||
<p class="text-center">Opponent Hand</p>
|
||||
</div>
|
||||
|
||||
<div class="place-self-center">
|
||||
<PlayerName Name="@opponentName" IsTurn="!IsMyTurn" />
|
||||
<hr />
|
||||
<PlayerName Name="@userName" IsTurn="IsMyTurn" />
|
||||
</div>
|
||||
|
||||
<div class="player-area">
|
||||
@if (this.OnClickJoinGame != null && string.IsNullOrEmpty(Session.Player2) && !string.IsNullOrEmpty(Session.Player1))
|
||||
{
|
||||
<div class="place-self-center">
|
||||
<button @onclick="OnClickJoinGameInternal">Join Game</button>
|
||||
</div>
|
||||
}
|
||||
else
|
||||
{
|
||||
<p class="text-center">Hand</p>
|
||||
<div class="hand">
|
||||
@if (userHand.Any())
|
||||
{
|
||||
@foreach (var piece in userHand)
|
||||
{
|
||||
<div @onclick="OnClickHandInternal(piece)"
|
||||
class="tile"
|
||||
data-selected="@(piece.WhichPiece == SelectedPieceFromHand)">
|
||||
<GamePiece Piece="piece" Perspective="Perspective" />
|
||||
</div>
|
||||
}
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
</aside>
|
||||
}
|
||||
</article>
|
||||
|
||||
@code {
|
||||
static readonly string[] Files = new[] { "A", "B", "C", "D", "E", "F", "G", "H", "I" };
|
||||
|
||||
/// <summary>
|
||||
/// When true, an icon is displayed indicating that the user is spectating.
|
||||
/// </summary>
|
||||
[Parameter] public bool IsSpectating { get; set; } = false;
|
||||
[Parameter] public WhichPlayer Perspective { get; set; }
|
||||
[Parameter] public Session? Session { get; set; }
|
||||
[Parameter] public string? SelectedPosition { get; set; }
|
||||
[Parameter] public WhichPiece? SelectedPieceFromHand { get; set; }
|
||||
// TODO: Exchange these OnClick actions for events like "SelectionChangedEvent" and "MoveFromBoardEvent" and "MoveFromHandEvent".
|
||||
[Parameter] public Func<Piece?, string, Task>? OnClickTile { get; set; }
|
||||
[Parameter] public Func<Piece, Task>? OnClickHand { get; set; }
|
||||
[Parameter] public Func<Task>? OnClickJoinGame { get; set; }
|
||||
[Parameter] public bool IsMyTurn { get; set; }
|
||||
|
||||
private IReadOnlyCollection<Piece> opponentHand;
|
||||
private IReadOnlyCollection<Piece> userHand;
|
||||
private string? userName;
|
||||
private string? opponentName;
|
||||
|
||||
public GameBoardPresentation()
|
||||
{
|
||||
opponentHand = Array.Empty<Piece>();
|
||||
userHand = Array.Empty<Piece>();
|
||||
userName = string.Empty;
|
||||
opponentName = string.Empty;
|
||||
}
|
||||
|
||||
protected override void OnParametersSet()
|
||||
{
|
||||
base.OnParametersSet();
|
||||
if (Session == null)
|
||||
{
|
||||
opponentHand = Array.Empty<Piece>();
|
||||
userHand = Array.Empty<Piece>();
|
||||
userName = string.Empty;
|
||||
opponentName = string.Empty;
|
||||
}
|
||||
else
|
||||
{
|
||||
opponentHand = Perspective == WhichPlayer.Player1
|
||||
? this.Session.BoardState.Player2Hand
|
||||
: this.Session.BoardState.Player1Hand;
|
||||
userHand = Perspective == WhichPlayer.Player1
|
||||
? this.Session.BoardState.Player1Hand
|
||||
: this.Session.BoardState.Player2Hand;
|
||||
userName = Perspective == WhichPlayer.Player1
|
||||
? this.Session.Player1
|
||||
: this.Session.Player2 ?? "Empty Seat";
|
||||
opponentName = Perspective == WhichPlayer.Player1
|
||||
? this.Session.Player2 ?? "Empty Seat"
|
||||
: this.Session.Player1;
|
||||
}
|
||||
}
|
||||
|
||||
private Action OnClickTileInternal(Piece? piece, string position) => () => OnClickTile?.Invoke(piece, position);
|
||||
private Action OnClickHandInternal(Piece piece) => () => OnClickHand?.Invoke(piece);
|
||||
private void OnClickJoinGameInternal() => OnClickJoinGame?.Invoke();
|
||||
}
|
||||
128
Shogi.UI/Pages/Play/GameBoard/GameboardPresentation.razor.css
Normal file
128
Shogi.UI/Pages/Play/GameBoard/GameboardPresentation.razor.css
Normal file
@@ -0,0 +1,128 @@
|
||||
.game-board {
|
||||
--ratio: 0.9;
|
||||
display: grid;
|
||||
grid-template-areas: "board side-board icons";
|
||||
grid-template-columns: auto minmax(10rem, 30rem) 1fr;
|
||||
background-color: #444;
|
||||
position: relative; /* For absolute positioned promote prompt. */
|
||||
}
|
||||
|
||||
.board {
|
||||
grid-area: board;
|
||||
}
|
||||
|
||||
.side-board {
|
||||
grid-area: side-board;
|
||||
}
|
||||
|
||||
.icons {
|
||||
grid-area: icons;
|
||||
}
|
||||
|
||||
.board {
|
||||
position: relative;
|
||||
display: grid;
|
||||
grid-template-areas:
|
||||
"rank A9 B9 C9 D9 E9 F9 G9 H9 I9"
|
||||
"rank A8 B8 C8 D8 E8 F8 G8 H8 I8"
|
||||
"rank A7 B7 C7 D7 E7 F7 G7 H7 I7"
|
||||
"rank A6 B6 C6 D6 E6 F6 G6 H6 I6"
|
||||
"rank A5 B5 C5 D5 E5 F5 G5 H5 I5"
|
||||
"rank A4 B4 C4 D4 E4 F4 G4 H4 I4"
|
||||
"rank A3 B3 C3 D3 E3 F3 G3 H3 I3"
|
||||
"rank A2 B2 C2 D2 E2 F2 G2 H2 I2"
|
||||
"rank A1 B1 C1 D1 E1 F1 G1 H1 I1"
|
||||
". file file file file file file file file file";
|
||||
grid-template-columns: auto repeat(9, 1fr);
|
||||
grid-template-rows: repeat(9, 1fr) auto;
|
||||
gap: 3px;
|
||||
padding: 1rem;
|
||||
background-color: #444444;
|
||||
height: calc(100vmin - 2rem);
|
||||
aspect-ratio: var(--ratio);
|
||||
}
|
||||
|
||||
.board[data-perspective="Player2"] {
|
||||
grid-template-areas:
|
||||
"file file file file file file file file file ."
|
||||
"I1 H1 G1 F1 E1 D1 C1 B1 A1 rank"
|
||||
"I2 H2 G2 F2 E2 D2 C2 B2 A2 rank"
|
||||
"I3 H3 G3 F3 E3 D3 C3 B3 A3 rank"
|
||||
"I4 H4 G4 F4 E4 D4 C4 B4 A4 rank"
|
||||
"I5 H5 G5 F5 E5 D5 C5 B5 A5 rank"
|
||||
"I6 H6 G6 F6 E6 D6 C6 B6 A6 rank"
|
||||
"I7 H7 G7 F7 E7 D7 C7 B7 A7 rank"
|
||||
"I8 H8 G8 F8 E8 D8 C8 B8 A8 rank"
|
||||
"I9 H9 G9 F9 E9 D9 C9 B9 A9 rank";
|
||||
grid-template-columns: repeat(9, minmax(0, 1fr)) auto;
|
||||
grid-template-rows: auto repeat(9, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
|
||||
.board .tile {
|
||||
display: grid;
|
||||
place-content: center;
|
||||
aspect-ratio: var(--ratio);
|
||||
background-color: beige;
|
||||
transition: filter linear 0.25s;
|
||||
}
|
||||
|
||||
.board .tile[data-selected] {
|
||||
filter: invert(0.8);
|
||||
}
|
||||
|
||||
.ruler {
|
||||
color: beige;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
justify-content: space-around;
|
||||
}
|
||||
|
||||
.ruler.vertical {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.board[data-perspective="Player2"] .ruler {
|
||||
flex-direction: row-reverse;
|
||||
}
|
||||
|
||||
.board[data-perspective="Player2"] .ruler.vertical {
|
||||
flex-direction: column-reverse;
|
||||
}
|
||||
|
||||
.side-board {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
place-content: space-between;
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.side-board .player-area {
|
||||
display: grid;
|
||||
place-items: stretch;
|
||||
}
|
||||
|
||||
.side-board .hand {
|
||||
display: grid;
|
||||
border: 1px solid #ccc;
|
||||
grid-template-columns: repeat(auto-fill, 3rem);
|
||||
grid-template-rows: 3rem;
|
||||
place-items: center start;
|
||||
padding: 0.5rem;
|
||||
}
|
||||
|
||||
.promote-prompt {
|
||||
display: none;
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
border: 2px solid #444;
|
||||
padding: 1rem;
|
||||
box-shadow: 1px 1px 1px #444;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.promote-prompt[data-visible="true"] {
|
||||
display: block;
|
||||
}
|
||||
21
Shogi.UI/Pages/Play/GameBoard/PlayerName.razor
Normal file
21
Shogi.UI/Pages/Play/GameBoard/PlayerName.razor
Normal file
@@ -0,0 +1,21 @@
|
||||
<p style="margin: 0">
|
||||
@if (IsTurn)
|
||||
{
|
||||
<span>* </span>
|
||||
}
|
||||
|
||||
@if (string.IsNullOrEmpty(Name))
|
||||
{
|
||||
<span>Empty Seat</span>
|
||||
}
|
||||
else
|
||||
{
|
||||
<span>@Name</span>
|
||||
}
|
||||
|
||||
</p>
|
||||
|
||||
@code {
|
||||
[Parameter][EditorRequired] public bool IsTurn { get; set; }
|
||||
[Parameter][EditorRequired] public string Name { get; set; } = string.Empty;
|
||||
}
|
||||
128
Shogi.UI/Pages/Play/GameBoard/SeatedGameBoard.razor
Normal file
128
Shogi.UI/Pages/Play/GameBoard/SeatedGameBoard.razor
Normal file
@@ -0,0 +1,128 @@
|
||||
@using Shogi.Contracts.Api;
|
||||
@using Shogi.Contracts.Types;
|
||||
@using System.Text.RegularExpressions;
|
||||
@using System.Net;
|
||||
@inject PromotePrompt PromotePrompt;
|
||||
@inject ShogiApi ShogiApi;
|
||||
|
||||
<GameBoardPresentation Session="Session"
|
||||
Perspective="Perspective"
|
||||
OnClickHand="OnClickHand"
|
||||
OnClickTile="OnClickTile"
|
||||
SelectedPosition="@selectedBoardPosition"
|
||||
SelectedPieceFromHand="@selectedPieceFromHand"
|
||||
IsMyTurn="IsMyTurn" />
|
||||
|
||||
@code {
|
||||
[Parameter, EditorRequired]
|
||||
public WhichPlayer Perspective { get; set; }
|
||||
[Parameter, EditorRequired]
|
||||
public Session Session { get; set; } = default!;
|
||||
private bool IsMyTurn => Session?.BoardState.WhoseTurn == Perspective;
|
||||
private string? selectedBoardPosition;
|
||||
private WhichPiece? selectedPieceFromHand;
|
||||
|
||||
protected override void OnParametersSet()
|
||||
{
|
||||
base.OnParametersSet();
|
||||
selectedBoardPosition = null;
|
||||
selectedPieceFromHand = null;
|
||||
if (Session == null)
|
||||
{
|
||||
throw new ArgumentException($"{nameof(Session)} cannot be null.", nameof(Session));
|
||||
}
|
||||
}
|
||||
|
||||
bool ShouldPromptForPromotion(string position)
|
||||
{
|
||||
if (Perspective == WhichPlayer.Player1 && Regex.IsMatch(position, ".[7-9]"))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
if (Perspective == WhichPlayer.Player2 && Regex.IsMatch(position, ".[1-3]"))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
async Task OnClickTile(Piece? pieceAtPosition, string position)
|
||||
{
|
||||
if (!IsMyTurn) return;
|
||||
|
||||
|
||||
if (selectedBoardPosition == position)
|
||||
{
|
||||
// Deselect the selected position.
|
||||
selectedBoardPosition = null;
|
||||
StateHasChanged();
|
||||
return;
|
||||
}
|
||||
|
||||
if (selectedBoardPosition == null && pieceAtPosition?.Owner == Perspective)
|
||||
{
|
||||
// Select an owned piece.
|
||||
selectedBoardPosition = position;
|
||||
// Prevent selecting pieces from the hand and board at the same time.
|
||||
selectedPieceFromHand = null;
|
||||
StateHasChanged();
|
||||
return;
|
||||
}
|
||||
|
||||
if (selectedPieceFromHand is not null)
|
||||
{
|
||||
if (pieceAtPosition is null)
|
||||
{
|
||||
// Placing a piece from the hand to an empty space.
|
||||
var success = await ShogiApi.Move(
|
||||
Session.SessionId.ToString(),
|
||||
new MovePieceCommand(selectedPieceFromHand.Value, position));
|
||||
if (!success)
|
||||
{
|
||||
selectedPieceFromHand = null;
|
||||
}
|
||||
}
|
||||
StateHasChanged();
|
||||
return;
|
||||
}
|
||||
|
||||
if (selectedBoardPosition != null)
|
||||
{
|
||||
if (pieceAtPosition == null || pieceAtPosition?.Owner != Perspective)
|
||||
{
|
||||
// Moving to an empty space or capturing an opponent's piece.
|
||||
if (ShouldPromptForPromotion(position) || ShouldPromptForPromotion(selectedBoardPosition))
|
||||
{
|
||||
PromotePrompt.Show(
|
||||
Session.SessionId.ToString(),
|
||||
new MovePieceCommand(selectedBoardPosition, position, false));
|
||||
}
|
||||
else
|
||||
{
|
||||
var success = await ShogiApi.Move(Session.SessionId.ToString(), new MovePieceCommand(selectedBoardPosition, position, false));
|
||||
if (!success)
|
||||
{
|
||||
selectedBoardPosition = null;
|
||||
}
|
||||
}
|
||||
StateHasChanged();
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async Task OnClickHand(Piece piece)
|
||||
{
|
||||
if (!IsMyTurn) return;
|
||||
|
||||
// Prevent selecting from both the hand and the board.
|
||||
selectedBoardPosition = null;
|
||||
|
||||
selectedPieceFromHand = piece.WhichPiece == selectedPieceFromHand
|
||||
// Deselecting the already-selected piece
|
||||
? selectedPieceFromHand = null
|
||||
: selectedPieceFromHand = piece.WhichPiece;
|
||||
|
||||
StateHasChanged();
|
||||
}
|
||||
}
|
||||
29
Shogi.UI/Pages/Play/GameBoard/SpectatorGameBoard.razor
Normal file
29
Shogi.UI/Pages/Play/GameBoard/SpectatorGameBoard.razor
Normal file
@@ -0,0 +1,29 @@
|
||||
@using Contracts.Types
|
||||
@using System.Net
|
||||
@inject ShogiApi ShogiApi
|
||||
|
||||
<GameBoardPresentation IsSpectating="true"
|
||||
Perspective="WhichPlayer.Player2"
|
||||
Session="Session"
|
||||
OnClickJoinGame="OnClickJoinGame" />
|
||||
|
||||
@code {
|
||||
[Parameter]
|
||||
[EditorRequired]
|
||||
public Session Session { get; set; } = default!;
|
||||
|
||||
protected override void OnParametersSet()
|
||||
{
|
||||
base.OnParametersSet();
|
||||
if (Session == null)
|
||||
{
|
||||
throw new ArgumentException($"{nameof(Session)} cannot be null.", nameof(Session));
|
||||
}
|
||||
}
|
||||
|
||||
async Task OnClickJoinGame()
|
||||
{
|
||||
var response = await ShogiApi.PatchJoinGame(Session.SessionId.ToString());
|
||||
response.EnsureSuccessStatusCode();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user