diff --git a/JoshHeaps.Net/Controllers/ChessController.cs b/JoshHeaps.Net/Controllers/ChessController.cs index 6e63375..df9190f 100644 --- a/JoshHeaps.Net/Controllers/ChessController.cs +++ b/JoshHeaps.Net/Controllers/ChessController.cs @@ -176,33 +176,7 @@ public class ChessController( if (!_games.TryGetValue(gameId, out var gameState)) return NotFound("Game not found"); - var response = new - { - gameState.GameId, - CurrentPlayer = gameState.CurrentPlayer.ToString(), - gameState.IsCheck, - gameState.IsCheckmate, - gameState.IsStalemate, - gameState.IsThreefoldRepetition, - EnPassantTarget = gameState.EnPassantTarget?.ToString() ?? null, - gameState.WhiteCanCastleKingside, - gameState.WhiteCanCastleQueenside, - gameState.BlackCanCastleKingside, - gameState.BlackCanCastleQueenside, - Pieces = gameState.Pieces - .Where(p => p.Position.Row >= 0) - .Select(p => new { - p.Id, - p.Type, - p.Color, - p.Position.Row, - p.Position.Col, - p.HasMoved - }), - gameState.MoveHistory - }; - - return Ok(response); + return Ok(gameState.ToDto()); } /// @@ -210,7 +184,7 @@ public class ChessController( /// The test passes a JSON body with a MoveDto. /// [HttpPost("move")] - public ActionResult MakeMove([FromBody] MoveDto moveDto) + public async Task MakeMove([FromBody] MoveDto moveDto) { if (!_games.TryGetValue(moveDto.GameId, out var gameState)) return NotFound("Game not found"); @@ -245,10 +219,18 @@ public class ChessController( else ScheduleRemoveGame(gameState.GameId, _multiplayerGameTimeout); + var state = gameState.ToDto(); + + // Broadcast the move (with the full resulting state) to everyone watching this + // game. The mover also receives this echo but drops it via the version guard, + // since it already rendered the same state from this response. + await chessHub.Clients.Group(gameState.GameId.ToString()) + .SendAsync("ReceiveMoveUpdate", gameState.GameId.ToString(), moveDto, result, state); + if (!isGameOver && gameState.IsVsComputer && gameState.Computer is not null) queue.Queue(() => orchestrator.PlayAsync(gameState, gameState.Computer!)); - return Ok(result); + return Ok(new { result, state }); } /// diff --git a/JoshHeaps.Net/Hubs/ChessHub.cs b/JoshHeaps.Net/Hubs/ChessHub.cs index 0cb59a1..1305721 100644 --- a/JoshHeaps.Net/Hubs/ChessHub.cs +++ b/JoshHeaps.Net/Hubs/ChessHub.cs @@ -1,5 +1,4 @@ -using JoshHeaps.Net.Models; -using Microsoft.AspNetCore.SignalR; +using Microsoft.AspNetCore.SignalR; namespace JoshHeaps.Net.Hubs; @@ -11,11 +10,6 @@ public class ChessHub : Hub await Groups.AddToGroupAsync(Context.ConnectionId, gameId); } - public async Task MoveMade(string gameId, MoveDto moveDto, MoveResultDto moveResult) - { - await Clients.OthersInGroup(gameId).SendAsync("ReceiveMoveUpdate", gameId, moveDto, moveResult); - } - public async Task LeaveWebsocketGroup(string gameId) { Console.WriteLine($"❌ Leaving group {gameId}"); diff --git a/JoshHeaps.Net/Models/GameStateDto.cs b/JoshHeaps.Net/Models/GameStateDto.cs new file mode 100644 index 0000000..8279acd --- /dev/null +++ b/JoshHeaps.Net/Models/GameStateDto.cs @@ -0,0 +1,61 @@ +using System.Linq; + +namespace JoshHeaps.Net.Models; + +/// +/// A single piece as sent to the client. Captured pieces keep their original +/// type and color; their position is meaningless and omitted. +/// +public record ChessPieceDto(string Id, PieceType Type, PieceColor Color, int Row, int Col, bool HasMoved); + +/// +/// The full board state pushed to clients. The same shape is returned by the +/// state endpoint, the move endpoint, and every SignalR move broadcast, so the +/// client always renders from one authoritative payload instead of re-fetching. +/// +public record GameStateDto( + Guid GameId, + string CurrentPlayer, + bool IsCheck, + bool IsCheckmate, + bool IsStalemate, + bool IsThreefoldRepetition, + string? EnPassantTarget, + bool WhiteCanCastleKingside, + bool WhiteCanCastleQueenside, + bool BlackCanCastleKingside, + bool BlackCanCastleQueenside, + IReadOnlyList Pieces, + IReadOnlyList CapturedPieces, + IReadOnlyList MoveHistory, + // Moves in standard algebraic notation, for the move-list panel. + IReadOnlyList SanHistory, + // Monotonic ply counter the client uses to drop stale or echoed updates. + int Version); + +public static class GameStateMapper +{ + public static GameStateDto ToDto(this GameState gameState) => new( + gameState.GameId, + gameState.CurrentPlayer.ToString(), + gameState.IsCheck, + gameState.IsCheckmate, + gameState.IsStalemate, + gameState.IsThreefoldRepetition, + gameState.EnPassantTarget?.ToString(), + gameState.WhiteCanCastleKingside, + gameState.WhiteCanCastleQueenside, + gameState.BlackCanCastleKingside, + gameState.BlackCanCastleQueenside, + gameState.Pieces + .Where(p => p.Position.Row >= 0) + .Select(p => new ChessPieceDto(p.Id, p.Type, p.Color, p.Position.Row, p.Position.Col, p.HasMoved)) + .ToList(), + gameState.Pieces + .Where(p => p.Position.Row < 0) + .Select(p => new ChessPieceDto(p.Id, p.Type, p.Color, p.Position.Row, p.Position.Col, p.HasMoved)) + .ToList(), + gameState.MoveHistory, + gameState.SanHistory, + gameState.MoveHistory.Count); +} diff --git a/JoshHeaps.Net/Pages/Chess.cshtml b/JoshHeaps.Net/Pages/Chess.cshtml index 314b112..d87778c 100644 --- a/JoshHeaps.Net/Pages/Chess.cshtml +++ b/JoshHeaps.Net/Pages/Chess.cshtml @@ -6,7 +6,14 @@ }
-
+
+
+ + Opponent +
+ +
+
@for (int i = 0; i < 64; i++) @@ -14,18 +21,41 @@
}
-
-
-

Chess

-

Click a button to start a game :)

-
+
+ + You +
+ +
+ -
- - - - Watch other games → + + + + +
+ Start a game to play. +
@@ -61,17 +91,20 @@ @section Scripts { - - - - - - - - + + + + + + + + } @section Styles { + + + } \ No newline at end of file diff --git a/JoshHeaps.Net/Pages/_Layout.cshtml b/JoshHeaps.Net/Pages/_Layout.cshtml index d0a8f1a..a122e08 100644 --- a/JoshHeaps.Net/Pages/_Layout.cshtml +++ b/JoshHeaps.Net/Pages/_Layout.cshtml @@ -1,6 +1,6 @@ @{ Layout = null; - ViewData["cssVersion"] = "1.0.5"; // <--- change this once to bust cache + ViewData["cssVersion"] = "1.0.7"; // <--- change this once to bust cache } diff --git a/JoshHeaps.Net/Services/Implementations/ComputerMoveOrchestrator.cs b/JoshHeaps.Net/Services/Implementations/ComputerMoveOrchestrator.cs index 21c15b1..244b6f6 100644 --- a/JoshHeaps.Net/Services/Implementations/ComputerMoveOrchestrator.cs +++ b/JoshHeaps.Net/Services/Implementations/ComputerMoveOrchestrator.cs @@ -26,7 +26,7 @@ public sealed class ComputerMoveOrchestrator( var result = chessService.MakeMove(state, move); await chessHub.Clients.Group(state.GameId.ToString()) - .SendAsync("ReceiveMoveUpdate", state.GameId.ToString(), move, result); + .SendAsync("ReceiveMoveUpdate", state.GameId.ToString(), move, result, state.ToDto()); return (move, result); } diff --git a/JoshHeaps.Net/wwwroot/css/chess/game.css b/JoshHeaps.Net/wwwroot/css/chess/game.css index 7ca7512..88640c4 100644 --- a/JoshHeaps.Net/wwwroot/css/chess/game.css +++ b/JoshHeaps.Net/wwwroot/css/chess/game.css @@ -1,12 +1,4 @@ -#boardContainer { - position: relative; - width: fit-content; - margin: 2vw auto; -} - -#chessBoard { - width: 60vw; - height: 60vw; +#chessBoard { display: grid; grid-template-columns: repeat(8, 1fr); grid-template-rows: repeat(8, 1fr); diff --git a/JoshHeaps.Net/wwwroot/css/chess/site.css b/JoshHeaps.Net/wwwroot/css/chess/site.css index bdf63a9..6e20e1f 100644 --- a/JoshHeaps.Net/wwwroot/css/chess/site.css +++ b/JoshHeaps.Net/wwwroot/css/chess/site.css @@ -1,159 +1,429 @@ -html, body { - background-color: #2b2c30; - color: #d6d6d6; - cursor: default; +:root { + --bg: #2b2c30; + --panel: #21232a; + --panel-row: rgba(255, 255, 255, 0.035); + --bar: #1e2026; + --accent: #8cd5ed; + --accent-ink: #10222a; + --text: #d6d6d6; + --muted: #8b8f99; + --line: #34373f; + --bar-h: clamp(34px, 5.5vh, 50px); + --radius: 12px; + --font: 'Outfit', system-ui, -apple-system, Segoe UI, sans-serif; +} + +html, body { height: 100%; margin: 0; + background: var(--bg); + color: var(--text); + font-family: var(--font); + cursor: default; + /* The chess page is a fixed, single-screen app: never scroll. */ + overflow: hidden; } -#startGameBtn { - background-color: #8cd5ed; - color: #262626; - border-radius: 30px; - border: 0px; - cursor: pointer; - order: 1; - padding: 2vh; - margin: 2vh; +main { + height: 100%; } -#startCPUGame { - background-color: #8cd5ed; - color: #262626; - border-radius: 30px; - border: 0px; - cursor: pointer; - order: 1; - padding: 2vh; - margin: 2vh; -} - -#copyPgnBtn { - background-color: #8cd5ed; - color: #262626; - border-radius: 30px; - border: 0px; - cursor: pointer; - order: 1; - padding: 2vh; - margin: 2vh; -} - -#watchLink { - color: #8cd5ed; - text-decoration: none; - margin: 2vh; - order: 2; -} - -#watchLink:hover { - text-decoration: underline; -} +/* ---- Layout ------------------------------------------------------------ */ +/* Portrait / narrow: board on top, panel stacked below. */ #chessContainer { + height: 100vh; + height: 100dvh; + box-sizing: border-box; display: flex; flex-direction: column; align-items: center; - justify-content: space-evenly; - height: 100vh; - box-sizing: border-box; - padding: 5vw; + gap: clamp(8px, 1.6vh, 16px); + padding: clamp(10px, 2vh, 20px); + overflow: hidden; +} + +#boardArea { + display: flex; + flex-direction: column; + gap: 6px; + flex: 0 0 auto; + width: min(94vw, 56vh); } #chessBoard { - aspect-ratio: 1 / 1; - width: 90vw; /* use the smaller of width or height */ + width: 100%; height: auto; - max-height: 90vw; + aspect-ratio: 1 / 1; + border-radius: 6px; + overflow: hidden; + box-shadow: 0 8px 30px rgba(0, 0, 0, 0.45); } -#boardContainer { - align-content: center; - flex-grow: 1; -} - -.sideContent { +#gamePanel { + width: min(94vw, 56vh); + flex: 1 1 auto; + min-height: 0; display: flex; flex-direction: column; - align-content: center; - justify-content: center; - text-align: center; + background: var(--panel); + border-radius: var(--radius); + overflow: hidden; + box-shadow: 0 6px 24px rgba(0, 0, 0, 0.35); } -#textContainer { - flex-direction: column; - order: -1; - flex-shrink: 1; - font-size: large; - margin: 5vw; -} - -#buttonContainer { - flex-grow: 3; -} - -/* Put UI to left/right when screen is short */ +/* Landscape / wide: board left, game panel right (chess.com style). */ @media (min-aspect-ratio: 1/1) { #chessContainer { - display: grid; - grid-template-columns: auto, auto; - grid-template-rows: auto, auto; - align-items: center; flex-direction: row; + align-items: center; justify-content: center; - align-items: center; - height: 100vh; - padding: 0; + gap: clamp(16px, 3vw, 48px); + padding: clamp(12px, 3vh, 28px); } - .sideContent { - display: flex; - flex-direction: column; - flex-grow: 1; - flex-shrink: 0; - align-items: center; - text-align: center; - padding: min(5vw, 5vh); - } - - #boardContainer { - grid-row: 1 / span 2; - grid-column: 1; - } - - #textContainer { - grid-row: 1; - grid-column: 2; - text-align: center; - font-size: x-large; - } - - #buttonContainer { - grid-row: 2; - grid-column: 2; - text-align: left; - align-self: start; - } - - #startGameBtn { - padding: 1vw; - font-size: large; - } - - #startCPUGame { - padding: 1vw; - margin: 1vw; - font-size: large; + #boardArea { + width: auto; + height: 100%; + justify-content: center; + flex: 0 0 auto; } #chessBoard { - aspect-ratio: 1 / 1; - flex-shrink: 1; - width: min(90vh, 90vw); - max-height: 90vh; + width: min(72vh, 54vw); + height: min(72vh, 54vw); } - #difficultyButtonContainer { - grid-template-columns: repeat(10, 1fr); + .playerBar { + width: min(72vh, 54vw); } -} \ No newline at end of file + + #gamePanel { + width: clamp(300px, 26vw, 380px); + height: min(86vh, 100%); + align-self: center; + } +} + +/* ---- Player bars ------------------------------------------------------- */ + +.playerBar { + display: flex; + align-items: center; + gap: 10px; + width: 100%; + box-sizing: border-box; + height: var(--bar-h); + padding: 0 12px; + background: var(--bar); + border-radius: 8px; +} + +.playerDot { + width: 14px; + height: 14px; + border-radius: 50%; + flex: 0 0 auto; +} + +.playerDot.white { + background: #ededed; + box-shadow: 0 0 0 1px rgba(0, 0, 0, 0.4) inset; +} + +.playerDot.black { + background: #2c2c2c; + box-shadow: 0 0 0 1px #565656 inset; +} + +.playerName { + font-weight: 600; + white-space: nowrap; +} + +.capturedTray { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 1px; + flex: 1; + min-width: 0; + overflow: hidden; +} + +.capturedPiece { + height: clamp(15px, calc(var(--bar-h) * 0.58), 26px); + width: auto; + margin-right: -5px; +} + +.advantage { + margin-left: auto; + font-weight: 700; + color: var(--accent); + white-space: nowrap; +} + +/* ---- Game panel -------------------------------------------------------- */ + +.panelHeader { + display: flex; + align-items: center; + gap: 10px; + padding: 14px 16px; + border-bottom: 1px solid var(--line); + flex: 0 0 auto; +} + +.panelLogo { + font-size: 1.5rem; + color: var(--accent); + line-height: 1; +} + +.panelHeader h1 { + margin: 0; + font-size: 1.25rem; + font-weight: 700; + letter-spacing: 0.01em; +} + +#moveList { + flex: 1 1 auto; + min-height: 0; + overflow-y: auto; + padding: 6px 0; +} + +.movePlaceholder { + margin: 0; + padding: 18px 16px; + color: var(--muted); + font-size: 0.95rem; +} + +.moveRow { + display: grid; + grid-template-columns: 2.4em 1fr 1fr; + align-items: center; + gap: 6px; + padding: 4px 14px; + font-size: 0.98rem; +} + +.moveRow:nth-child(odd) { + background: var(--panel-row); +} + +.moveNum { + color: var(--muted); + font-variant-numeric: tabular-nums; +} + +.moveSan { + padding: 2px 7px; + border-radius: 5px; +} + +.moveSan.latest { + background: var(--accent); + color: var(--accent-ink); + font-weight: 600; +} + +#statusLine { + flex: 0 0 auto; + padding: 12px 16px; + border-top: 1px solid var(--line); + color: var(--muted); + font-weight: 500; +} + +#statusLine.alert { + color: var(--accent); + font-weight: 700; +} + +#panelButtons { + flex: 0 0 auto; + display: flex; + flex-direction: column; + gap: 10px; + padding: 14px 16px 16px; + border-top: 1px solid var(--line); +} + +.btn { + font-family: inherit; + font-weight: 600; + font-size: clamp(0.92rem, 1vw, 1.05rem); + border: 0; + border-radius: 8px; + padding: clamp(10px, 1.4vh, 14px) 16px; + cursor: pointer; + transition: transform 0.08s ease, filter 0.15s ease, background 0.15s ease; +} + +.btn:active { + transform: translateY(1px); +} + +.btn-primary { + background: var(--accent); + color: var(--accent-ink); +} + +.btn-primary:hover { + filter: brightness(1.08); +} + +.btn-secondary { + background: #363a44; + color: var(--text); +} + +.btn-secondary:hover { + background: #424752; +} + +.btn-ghost { + background: transparent; + color: var(--accent); + border: 1px solid var(--line); +} + +.btn-ghost:hover { + background: #2a2e37; +} + +#watchLink { + color: var(--muted); + text-decoration: none; + text-align: center; + font-size: 0.9rem; + margin-top: 2px; +} + +#watchLink:hover { + color: var(--accent); +} + +/* ---- Mobile chrome (hidden on desktop) --------------------------------- */ + +.iconBtn { + background: transparent; + border: 0; + color: var(--text); + font-size: 1.7rem; + line-height: 1; + cursor: pointer; + padding: 0 4px; +} + +.iconBtn:hover { + color: var(--accent); +} + +#menuClose { + margin-left: auto; +} + +/* Default (tablet/desktop): no mobile bar, menu button, or sheet chrome. */ +#mobileBar, +#menuBackdrop, +#menuClose { + display: none; +} + +/* ---- Phone layout ------------------------------------------------------ */ +/* The full panel doesn't fit alongside a usable board on a phone, so we show + the board + a slim status/menu bar, and tuck the move list and secondary + actions into a slide-up sheet. The page itself still never scrolls. */ +@media (max-width: 640px) { + #chessContainer { + flex-direction: column; + justify-content: flex-start; + gap: 8px; + padding: 8px; + } + + #boardArea { + width: min(94vw, 60vh); + flex: 0 0 auto; + } + + .playerBar { + width: 100%; + } + + /* Right rail becomes a hidden bottom sheet, revealed by the Menu button. */ + #gamePanel { + display: none; + position: fixed; + left: 0; + right: 0; + bottom: 0; + top: auto; + width: 100%; + height: auto; + max-height: 85dvh; + border-radius: 16px 16px 0 0; + z-index: 60; + box-shadow: 0 -8px 30px rgba(0, 0, 0, 0.5); + } + + body.menu-open #gamePanel { + display: flex; + } + + /* The slim bar already shows status, so hide the sheet's inline copy. */ + #gamePanel #statusLine { + display: none; + } + + #menuClose { + display: block; + } + + #menuBackdrop { + position: fixed; + inset: 0; + background: rgba(0, 0, 0, 0.55); + z-index: 55; + } + + body.menu-open #menuBackdrop { + display: block; + } + + #mobileBar { + display: flex; + align-items: center; + gap: 10px; + width: min(94vw, 60vh); + flex: 0 0 auto; + box-sizing: border-box; + background: var(--panel); + border-radius: 10px; + padding: 8px 10px; + } + + #mobileStatus { + flex: 1; + min-width: 0; + color: var(--muted); + font-weight: 600; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + } + + #mobileStatus.alert { + color: var(--accent); + } + + #mobileBar #menuToggle { + flex: 0 0 auto; + padding: 10px 20px; + } +} diff --git a/JoshHeaps.Net/wwwroot/js/ChessScripts/ChessAPI.js b/JoshHeaps.Net/wwwroot/js/ChessScripts/ChessAPI.js index e935454..19f872d 100644 --- a/JoshHeaps.Net/wwwroot/js/ChessScripts/ChessAPI.js +++ b/JoshHeaps.Net/wwwroot/js/ChessScripts/ChessAPI.js @@ -46,13 +46,15 @@ const ChessAPI = { throw new Error(message || "Invalid move or not your turn."); } - const result = await response.json(); + // The move endpoint returns both the move result and the full resulting + // board state, so the caller can render without a follow-up fetch. + const data = await response.json(); - if (!result.success) { - throw new Error(result.message || "Invalid move or not your turn."); + if (!data.result.success) { + throw new Error(data.result.message || "Invalid move or not your turn."); } - return result; + return data; }, async handleMove(targetRow, targetCol) { @@ -85,16 +87,14 @@ const ChessAPI = { GameState.setPreviousMove([GameState.selectedPiece.row, GameState.selectedPiece.col], [targetRow, targetCol]); try { - const moveResult = await this.makeMove(moveDto); + const { result, state } = await this.makeMove(moveDto); - const updatedGame = await this.getGameState(GameState.currentGameId); - ChessBoard.renderPieces(updatedGame.pieces); + ChessBoard.renderState(state); GameState.clearSelection(); ChessInteractions.clearHighlights(); - await ChessSignalR.notifyMoveMade(moveDto, moveResult); - this.alertGameStatusChange(moveResult); + this.alertGameStatusChange(result); } catch (error) { alert("❌ " + error.message); diff --git a/JoshHeaps.Net/wwwroot/js/ChessScripts/ChessBoard.js b/JoshHeaps.Net/wwwroot/js/ChessScripts/ChessBoard.js index 0c5a59e..aa019ed 100644 --- a/JoshHeaps.Net/wwwroot/js/ChessScripts/ChessBoard.js +++ b/JoshHeaps.Net/wwwroot/js/ChessScripts/ChessBoard.js @@ -1,4 +1,91 @@ +// Material value per piece type, indexed by PieceType (0=Pawn .. 5=King). +const PIECE_VALUES = [1, 5, 3, 3, 9, 0]; + const ChessBoard = { + // Render from a full game-state payload (the shape returned by the move/state + // endpoints and pushed over SignalR). Ignores state older than what's already + // shown, so a slow initial fetch can't clobber a move that arrived first. + renderState(state) { + if (!state || !GameState.shouldApply(state.version)) return; + this.renderPieces(state.pieces); + this.renderCapturedTrays(state.pieces, state.capturedPieces); + this.renderMoveList(state.sanHistory); + this.renderStatus(state); + GameState.setVersion(state.version); + }, + + // Two-column numbered move list (white move, black move), latest highlighted. + renderMoveList(sanHistory) { + const list = document.getElementById("moveList"); + if (!list) return; + + const san = sanHistory ?? []; + + if (san.length === 0) { + list.innerHTML = '

Moves will appear here once a game begins.

'; + return; + } + + list.innerHTML = ""; + + for (let i = 0; i < san.length; i += 2) { + const row = document.createElement("div"); + row.className = "moveRow"; + + const num = document.createElement("span"); + num.className = "moveNum"; + num.textContent = `${i / 2 + 1}.`; + row.appendChild(num); + + row.appendChild(this.moveCell(san[i], i === san.length - 1)); + + if (i + 1 < san.length) + row.appendChild(this.moveCell(san[i + 1], i + 1 === san.length - 1)); + + list.appendChild(row); + } + + list.scrollTop = list.scrollHeight; + }, + + moveCell(san, isLatest) { + const cell = document.createElement("span"); + cell.className = isLatest ? "moveSan latest" : "moveSan"; + cell.textContent = san; + return cell; + }, + + renderStatus(state) { + let text; + let alert = false; + + if (state.isCheckmate) { + const winner = state.currentPlayer === "White" ? "Black" : "White"; + text = `Checkmate — ${winner} wins`; + alert = true; + } else if (state.isStalemate) { + text = "Draw — stalemate"; + alert = true; + } else if (state.isThreefoldRepetition) { + text = "Draw — threefold repetition"; + alert = true; + } else { + text = `${state.currentPlayer} to move`; + if (state.isCheck) { + text += " — check"; + alert = true; + } + } + + // Mirror to both the desktop panel status and the mobile bar status. + ["statusLine", "mobileStatus"].forEach(id => { + const el = document.getElementById(id); + if (!el) return; + el.textContent = text; + el.classList.toggle("alert", alert); + }); + }, + renderPieces(pieces) { this.clearAllSquares(); this.renderCoordinateLabels(); @@ -7,6 +94,48 @@ const ChessBoard = { this.highlightPreviousMove(); }, + // Show each side's captured pieces and the leading side's material advantage, + // arranged so the current player's tray sits below the board. + renderCapturedTrays(activePieces, capturedPieces) { + const captured = capturedPieces ?? []; + + // A captured piece's color is the side that lost it, so White's haul is the + // captured Black pieces, and vice versa. + const whiteCaptured = captured.filter(p => p.color === 1); + const blackCaptured = captured.filter(p => p.color === 0); + + // Net material from pieces still on the board, so promotions count correctly. + const advantage = (activePieces ?? []).reduce( + (sum, p) => sum + (p.color === 0 ? PIECE_VALUES[p.type] : -PIECE_VALUES[p.type]), 0); + + const white = { captured: whiteCaptured, advantage: Math.max(advantage, 0) }; + const black = { captured: blackCaptured, advantage: Math.max(-advantage, 0) }; + + const isWhite = GameState.currentPlayerIsWhite !== false; + this.fillCapturedTray("bottom", isWhite ? white : black); + this.fillCapturedTray("top", isWhite ? black : white); + }, + + fillCapturedTray(position, side) { + const tray = document.getElementById(`captured-${position}`); + const badge = document.getElementById(`advantage-${position}`); + if (!tray || !badge) return; + + tray.innerHTML = ""; + [...side.captured] + .sort((a, b) => PIECE_VALUES[a.type] - PIECE_VALUES[b.type]) + .forEach(piece => { + const img = document.createElement("img"); + img.src = ChessUtils.getPieceImageUrl(piece); + img.alt = piece.type; + img.className = "capturedPiece"; + img.draggable = false; + tray.appendChild(img); + }); + + badge.textContent = side.advantage > 0 ? `+${side.advantage}` : ""; + }, + clearAllSquares() { for (let i = 0; i < 64; i++) { const square = document.getElementById(`square-${i}`); diff --git a/JoshHeaps.Net/wwwroot/js/ChessScripts/ChessSignalR.js b/JoshHeaps.Net/wwwroot/js/ChessScripts/ChessSignalR.js index de0ef5b..fa8200d 100644 --- a/JoshHeaps.Net/wwwroot/js/ChessScripts/ChessSignalR.js +++ b/JoshHeaps.Net/wwwroot/js/ChessScripts/ChessSignalR.js @@ -11,8 +11,8 @@ const ChessSignalR = { console.error("❌ SignalR connection closed:", err?.message); }); - this.connection.on("ReceiveMoveUpdate", async (gameId, moveDto, moveResultDto) => { - await this.handleMoveUpdate(gameId, moveDto, moveResultDto); + this.connection.on("ReceiveMoveUpdate", async (gameId, moveDto, moveResultDto, state) => { + await this.handleMoveUpdate(gameId, moveDto, moveResultDto, state); }); this.connection.on("ReceiveGameOver", async (gameId, winner, reason) => { @@ -28,12 +28,14 @@ const ChessSignalR = { } }, - async handleMoveUpdate(gameId, moveDto, moveResultDto) { + async handleMoveUpdate(gameId, moveDto, moveResultDto, state) { if (gameId !== GameState.currentGameId) return; - const gameState = await ChessAPI.getGameState(gameId); + // Drop the echo of our own move and any out-of-order delivery. + if (!GameState.shouldApply(state?.version)) return; + GameState.setPreviousMove([moveDto.sourceRow, moveDto.sourceCol], [moveDto.targetRow, moveDto.targetCol]); - ChessBoard.renderPieces(gameState.pieces); + ChessBoard.renderState(state); ChessAPI.alertGameStatusChange(moveResultDto); }, @@ -50,12 +52,6 @@ const ChessSignalR = { await this.leaveGame(); }, - async notifyMoveMade(moveDto, moveResult) { - if (this.connection) { - await this.connection.invoke("MoveMade", GameState.currentGameId, moveDto, moveResult); - } - }, - async leaveGame() { if (this.connection) { await this.connection.invoke("LeaveWebsocketGroup", GameState.currentGameId); diff --git a/JoshHeaps.Net/wwwroot/js/ChessScripts/GameState.js b/JoshHeaps.Net/wwwroot/js/ChessScripts/GameState.js index 84416f9..1a0de6c 100644 --- a/JoshHeaps.Net/wwwroot/js/ChessScripts/GameState.js +++ b/JoshHeaps.Net/wwwroot/js/ChessScripts/GameState.js @@ -6,6 +6,10 @@ const GameState = { legalMoves: [], previousMoveStart: null, previousMoveEnd: null, + // Highest ply (move count) already rendered. Lets us ignore stale or + // already-applied updates that arrive out of order, including the echo + // of our own move. + lastVersion: -1, reset() { this.currentGameId = null; @@ -15,12 +19,22 @@ const GameState = { this.legalMoves = []; this.previousMoveStart = null; this.previousMoveEnd = null; + this.lastVersion = -1; + }, + + shouldApply(version) { + return typeof version !== "number" || version > this.lastVersion; + }, + + setVersion(version) { + if (typeof version === "number") this.lastVersion = version; }, setGameInfo(gameId, playerId, isWhite) { this.currentGameId = gameId; this.currentPlayerId = playerId; this.currentPlayerIsWhite = isWhite; + this.lastVersion = -1; }, setSelectedPiece(piece) { diff --git a/JoshHeaps.Net/wwwroot/js/ChessScripts/Spectate.js b/JoshHeaps.Net/wwwroot/js/ChessScripts/Spectate.js index 96debc7..08f3450 100644 --- a/JoshHeaps.Net/wwwroot/js/ChessScripts/Spectate.js +++ b/JoshHeaps.Net/wwwroot/js/ChessScripts/Spectate.js @@ -11,8 +11,8 @@ const Spectate = { .configureLogging(signalR.LogLevel.Warning) .build(); - this.connection.on("ReceiveMoveUpdate", (gameId, moveDto) => - this.handleMoveUpdate(gameId, moveDto)); + this.connection.on("ReceiveMoveUpdate", (gameId, moveDto, _moveResult, state) => + this.handleMoveUpdate(gameId, moveDto, state)); this.connection.on("ReceiveGameOver", (gameId) => this.removeGame(gameId)); @@ -117,7 +117,10 @@ const Spectate = { if (!response.ok) return; - const state = await response.json(); + this.renderFromState(gameId, await response.json()); + }, + + renderFromState(gameId, state) { const result = this.resultTextFromState(state); const stored = this.games.get(gameId); @@ -128,10 +131,13 @@ const Spectate = { this.setResult(gameId, result); }, - async handleMoveUpdate(gameId, moveDto) { + async handleMoveUpdate(gameId, moveDto, state) { if (!this.games.has(gameId)) return; - await this.renderGame(gameId); + // Render from the pushed state; fall back to a fetch only if it's missing. + if (state) this.renderFromState(gameId, state); + else await this.renderGame(gameId); + this.highlightMove(gameId, moveDto); }, diff --git a/JoshHeaps.Net/wwwroot/js/ChessScripts/chessMain.js b/JoshHeaps.Net/wwwroot/js/ChessScripts/chessMain.js index 6cdac8b..826816f 100644 --- a/JoshHeaps.Net/wwwroot/js/ChessScripts/chessMain.js +++ b/JoshHeaps.Net/wwwroot/js/ChessScripts/chessMain.js @@ -30,6 +30,18 @@ function resetCopyPgn() { window.copyPgn = copyPgn; window.showCopyPgn = showCopyPgn; +// Mobile slide-up menu (move list + secondary actions). +function toggleMenu() { + document.body.classList.toggle("menu-open"); +} + +function closeMenu() { + document.body.classList.remove("menu-open"); +} + +window.toggleMenu = toggleMenu; +window.closeMenu = closeMenu; + async function forfeitCurrentGame() { const gameId = ChessUtils.getCookie("chessGameId"); const playerId = ChessUtils.getCookie("chessPlayerId"); @@ -44,6 +56,7 @@ async function forfeitCurrentGame() { } async function startNewGame() { + closeMenu(); await ChessSignalR.stopConnection(); await forfeitCurrentGame(); resetCopyPgn(); @@ -63,7 +76,7 @@ async function startNewGame() { await ChessSignalR.setupConnection(); const gameState = await ChessAPI.getGameState(gameData.gameId); - ChessBoard.renderPieces(gameState.pieces); + ChessBoard.renderState(gameState); } catch (error) { console.error("Failed to start new game:", error); @@ -71,6 +84,7 @@ async function startNewGame() { } async function startCPUGame() { + closeMenu(); await ChessSignalR.stopConnection(); await forfeitCurrentGame(); resetCopyPgn(); @@ -91,7 +105,7 @@ async function startCPUGame() { await ChessSignalR.setupConnection(); const gameState = await ChessAPI.getGameState(gameData.gameId); - ChessBoard.renderPieces(gameState.pieces); + ChessBoard.renderState(gameState); } catch (error) { console.error("Failed to start CPU game:", error); @@ -111,7 +125,7 @@ async function resumeSavedGame() { GameState.setGameInfo(savedGameId, savedPlayerId, savedPlayerIsWhite === "true"); await ChessSignalR.setupConnection(); - ChessBoard.renderPieces(gameState.pieces); + ChessBoard.renderState(gameState); } catch (err) { console.warn("Saved game not found or expired.", err);