From 36727cdb24ec5fcdc36168799fbf5eacd659376d Mon Sep 17 00:00:00 2001 From: tototomate123 Date: Fri, 27 Feb 2026 12:27:02 +0100 Subject: [PATCH] add live game UI with board rendering, timers, and SAN move list --- app/static/js/main.js | 16 - app/static/js/play.js | 9 + app/static/js/play/app.js | 783 ++++++++++++++++++++++++++++++++ app/static/js/play/constants.js | 29 ++ app/static/js/play/utils.js | 32 ++ 5 files changed, 853 insertions(+), 16 deletions(-) delete mode 100644 app/static/js/main.js create mode 100644 app/static/js/play.js create mode 100644 app/static/js/play/app.js create mode 100644 app/static/js/play/constants.js create mode 100644 app/static/js/play/utils.js diff --git a/app/static/js/main.js b/app/static/js/main.js deleted file mode 100644 index 2e09dfd..0000000 --- a/app/static/js/main.js +++ /dev/null @@ -1,16 +0,0 @@ -import { createWSClient } from "./ws.js"; -import { - handleP2Connected, - handleGameStarted, - handleCodeGameCreated, -} from "./ws_handlers.js"; - -function main() { - createWSClient({ - onGameStarted: handleGameStarted, - onP2Connected: handleP2Connected, - onGameCreated: handleCodeGameCreated, - }); -} - -main(); diff --git a/app/static/js/play.js b/app/static/js/play.js new file mode 100644 index 0000000..52b9af7 --- /dev/null +++ b/app/static/js/play.js @@ -0,0 +1,9 @@ +import { PlayApp } from "./play/app.js"; + +function main() { + const app = new PlayApp(); + window.playApp = app; + app.installSocket(); +} + +main(); diff --git a/app/static/js/play/app.js b/app/static/js/play/app.js new file mode 100644 index 0000000..e20f3b2 --- /dev/null +++ b/app/static/js/play/app.js @@ -0,0 +1,783 @@ +import { createWSClient } from "../ws.js"; +import { + handleP2Connected, + handleGameStarted, + handleCodeGameCreated, + handleCodeGameJoined, + handleCodeGameJoinFailed, + handleMoveAccepted, + handleOpponentMove, + handleMoveRejected, + handleGameOver, + handleDrawOffered, + handleClockTick, +} from "../ws_handlers.js"; +import { PIECE_IMAGE_PATHS, START_COUNTS } from "./constants.js"; +import { formatClock, parseFenBoard } from "./utils.js"; + +const PIECE_VALUES = { p: 1, n: 3, b: 3, r: 5, q: 9 }; + +export class PlayApp { + constructor() { + this.socket = null; + + this.setupPanel = document.getElementById("setup-panel"); + this.waitingPanel = document.getElementById("waiting-panel"); + this.gameStage = document.getElementById("game-stage"); + + this.canvas = document.getElementById("chess-canvas"); + this.ctx = this.canvas.getContext("2d"); + + this.createBtn = document.getElementById("create-code-btn"); + this.joinBtn = document.getElementById("join-code-btn"); + this.joinInput = document.getElementById("join-code-input"); + this.copyCodeBtn = document.getElementById("copy-code-btn"); + this.readyBtn = document.getElementById("ready-btn"); + this.offerDrawBtn = document.getElementById("offer-draw-btn"); + this.resignBtn = document.getElementById("resign-btn"); + + this.codeEl = document.getElementById("code-value"); + this.opponentEl = document.getElementById("opponent-value"); + this.lobbyStatusEl = document.getElementById("lobby-status"); + this.statusEl = document.getElementById("play-status"); + this.colorEl = document.getElementById("color-value"); + this.turnEl = document.getElementById("turn-value"); + + this.myTimeEl = document.getElementById("my-time-value"); + this.opponentTimeEl = document.getElementById("opponent-time-value"); + this.myCapturedStripEl = document.getElementById("my-captured-strip"); + this.oppCapturedStripEl = document.getElementById( + "opponent-captured-strip", + ); + this.myMaterialStripEl = document.getElementById("my-material-strip"); + this.oppMaterialStripEl = document.getElementById( + "opponent-material-strip", + ); + this.myNameStripEl = document.getElementById("my-name-strip"); + this.oppNameStripEl = document.getElementById("opponent-name-strip"); + + this.moveListBodyEl = document.getElementById("move-list-body"); + + this.modal = document.getElementById("play-modal"); + this.modalTitleEl = document.getElementById("play-modal-title"); + this.modalTextEl = document.getElementById("play-modal-text"); + this.modalActionsEl = document.getElementById("play-modal-actions"); + + this.state = "setup"; + this.timeMode = "10+0"; + this.playAsPref = "r"; + this.gameCode = null; + this.myColor = null; + this.turn = "w"; + this.fen = ""; + this.board = {}; + this.whiteTimeMs = 0; + this.blackTimeMs = 0; + this.selectedSquare = null; + this.ready = false; + this.animation = null; + this.drag = null; + this.pieceImages = {}; + this.moveHistory = []; + this.myName = ( + document.querySelector(".profile-pill")?.textContent || "You" + ).trim(); + this.oppName = "Opponent"; + + this.displayBoardSize = 640; + this.squareSize = 80; + this.dpr = Math.max(1, window.devicePixelRatio || 1); + + this.loadPieceImages(); + this.installControls(); + this.installBoardInteractions(); + this.installResizeHandling(); + this.setState("setup"); + this.resizeCanvasForDisplay(); + } + + loadPieceImages() { + for (const [piece, src] of Object.entries(PIECE_IMAGE_PATHS)) { + const img = new Image(); + img.onload = () => this.render(); + img.src = src; + this.pieceImages[piece] = img; + } + } + + installResizeHandling() { + window.addEventListener("resize", () => this.resizeCanvasForDisplay()); + } + + resizeCanvasForDisplay() { + const nextDpr = Math.max(1, window.devicePixelRatio || 1); + const cssSize = Math.max(320, Math.floor(this.canvas.clientWidth || 640)); + + this.displayBoardSize = cssSize; + this.squareSize = cssSize / 8; + this.dpr = nextDpr; + + // piece images stay sharp on zoom screens + this.canvas.width = Math.floor(cssSize * nextDpr); + this.canvas.height = Math.floor(cssSize * nextDpr); + this.ctx.setTransform(nextDpr, 0, 0, nextDpr, 0, 0); + + this.render(); + } + + installSocket() { + this.socket = createWSClient({ + onGameStarted: handleGameStarted, + onP2Connected: handleP2Connected, + onGameCreated: handleCodeGameCreated, + onGameJoined: handleCodeGameJoined, + onGameJoinFailed: handleCodeGameJoinFailed, + onMoveAccept: handleMoveAccepted, + onUserMove: handleOpponentMove, + onMoveReject: handleMoveRejected, + onGameOver: handleGameOver, + onDrawOffered: handleDrawOffered, + onClockTick: handleClockTick, + }); + } + + installControls() { + const setChip = (containerId, initialValue, key, attr) => { + const container = document.getElementById(containerId); + this[key] = initialValue; + container.addEventListener("click", (event) => { + const chip = event.target.closest(".chip"); + if (!chip) { + return; + } + for (const btn of container.querySelectorAll(".chip")) { + btn.classList.remove("active"); + } + chip.classList.add("active"); + this[key] = chip.getAttribute(attr); + }); + }; + + setChip("time-mode-row", "10+0", "timeMode", "data-time-mode"); + setChip("play-as-row", "r", "playAsPref", "data-play-as"); + + this.createBtn.addEventListener("click", () => { + this.socket.emit("create_code_game", { + play_as: this.playAsPref, + time_mode: this.timeMode, + }); + this.setLobbyStatus("Creating game code..."); + }); + + this.joinBtn.addEventListener("click", () => { + const code = this.joinInput.value.trim().toUpperCase(); + if (!code) { + this.showModal("Join game", "Enter a game code first.", [ + { label: "OK" }, + ]); + return; + } + this.gameCode = code; + this.socket.emit("join_code_game", { code }); + this.setLobbyStatus("Joining game..."); + }); + + this.copyCodeBtn.addEventListener("click", async () => { + const code = this.gameCode || this.codeEl.textContent; + if (!code || code === "------") { + return; + } + try { + await navigator.clipboard.writeText(code); + this.showModal( + "Code copied", + `Game code ${code} copied to clipboard.`, + [{ label: "Nice" }], + ); + } catch { + this.showModal("Copy failed", "Could not copy code from browser.", [ + { label: "OK" }, + ]); + } + }); + + this.readyBtn.addEventListener("click", () => { + this.ready = !this.ready; + this.readyBtn.textContent = this.ready ? "Unready" : "Ready"; + this.socket.emit("user_ready", { ready: this.ready }); + this.setLobbyStatus( + this.ready + ? "You are ready. Waiting for opponent..." + : "You are not ready.", + ); + }); + + this.offerDrawBtn.addEventListener("click", () => { + this.showModal("Offer draw", "Send a draw offer to your opponent?", [ + { + label: "Offer draw", + className: "btn-primary", + onClick: () => { + this.socket.emit("request_draw", {}); + this.setStatus("Draw offer sent."); + }, + }, + { label: "Cancel" }, + ]); + }); + + this.resignBtn.addEventListener("click", () => { + this.showModal("Resign", "Are you sure you want to resign this game?", [ + { + label: "Resign", + className: "btn-primary", + onClick: () => this.socket.emit("request_resign", {}), + }, + { label: "Cancel" }, + ]); + }); + + this.modal.addEventListener("click", (event) => { + if (event.target === this.modal) { + this.hideModal(); + } + }); + } + + installBoardInteractions() { + this.canvas.addEventListener("mousedown", (event) => + this.onPointerDown(event), + ); + this.canvas.addEventListener("mousemove", (event) => + this.onPointerMove(event), + ); + this.canvas.addEventListener("mouseup", (event) => this.onPointerUp(event)); + this.canvas.addEventListener("mouseleave", () => this.onPointerCancel()); + } + + onPointerDown(event) { + if (!this.isGameActive() || !this.isMyTurn()) { + return; + } + + const point = this.getCanvasPoint(event); + const square = this.pixelToSquare(point.x, point.y); + if (!square) { + return; + } + + const piece = this.board[square]; + if (!piece || !this.isMyPiece(piece)) { + return; + } + + this.selectedSquare = square; + this.drag = { + from: square, + piece, + startX: point.x, + startY: point.y, + x: point.x, + y: point.y, + moved: false, + }; + this.render(); + } + + onPointerMove(event) { + if (!this.drag) { + return; + } + + const point = this.getCanvasPoint(event); + this.drag.x = point.x; + this.drag.y = point.y; + + if (!this.drag.moved) { + const dx = point.x - this.drag.startX; + const dy = point.y - this.drag.startY; + if (Math.sqrt(dx * dx + dy * dy) > 6) { + this.drag.moved = true; + } + } + + this.render(); + } + + onPointerUp(event) { + if (!this.drag) { + return; + } + + const point = this.getCanvasPoint(event); + const targetSquare = this.pixelToSquare(point.x, point.y); + const sourceSquare = this.drag.from; + const wasDrag = this.drag.moved; + + this.drag = null; + + if (!targetSquare) { + this.selectedSquare = null; + this.render(); + return; + } + + if (targetSquare === sourceSquare) { + if (wasDrag) { + this.selectedSquare = null; + } + this.render(); + return; + } + + this.tryMove(sourceSquare, targetSquare); + } + + onPointerCancel() { + if (!this.drag) { + return; + } + this.drag = null; + this.selectedSquare = null; + this.render(); + } + + tryMove(fromSquare, toSquare) { + if (!this.isGameActive()) { + return; + } + if (!this.isMyTurn()) { + this.setStatus("Wait for your turn."); + return; + } + + this.selectedSquare = null; + this.socket.emit("move_request", { + from_square: fromSquare, + to_square: toSquare, + }); + } + + onCodeCreated(data) { + this.gameCode = data.code; + this.codeEl.textContent = data.code; + this.opponentEl.textContent = "Waiting..."; + this.oppName = "Opponent"; + this.updatePlayerNames(); + this.ready = false; + this.readyBtn.textContent = "Ready"; + this.setLobbyStatus("Code created. Waiting for opponent to join..."); + this.setState("waiting"); + } + + onGameJoined(data) { + this.codeEl.textContent = + this.gameCode || this.joinInput.value.trim().toUpperCase() || "------"; + if (data.p1_name) { + this.opponentEl.textContent = data.p1_name; + this.oppName = data.p1_name; + this.updatePlayerNames(); + } + this.ready = false; + this.readyBtn.textContent = "Ready"; + this.setLobbyStatus( + "Joined lobby. Waiting for both players to be ready...", + ); + this.setState("waiting"); + } + + onGameJoinFailed(data) { + this.setState("setup"); + this.showModal("Join failed", data.reason || "Unknown error", [ + { label: "OK" }, + ]); + } + + onP2Connected(data) { + if (data.p2_name) { + this.opponentEl.textContent = data.p2_name; + this.oppName = data.p2_name; + this.updatePlayerNames(); + this.setLobbyStatus( + data.ready ? "Opponent is ready." : "Opponent joined. Click Ready.", + ); + } else { + this.opponentEl.textContent = "Waiting..."; + this.oppName = "Opponent"; + this.updatePlayerNames(); + this.setLobbyStatus("Opponent disconnected. Waiting for rejoin..."); + } + } + + onGameStarted(data) { + this.myColor = data.play_as; + this.colorEl.textContent = this.myColor === "w" ? "White" : "Black"; + this.turn = data.turn || "w"; + this.turnEl.textContent = this.turn === "w" ? "White" : "Black"; + this.fen = data.fen; + this.board = parseFenBoard(this.fen); + this.applyClockPayload(data); + + if (data.opponent) { + this.oppName = data.opponent; + this.updatePlayerNames(); + } + + this.ready = false; + this.readyBtn.textContent = "Ready"; + this.selectedSquare = null; + this.drag = null; + this.moveHistory = []; + this.renderMoveList(); + + this.updateCapturedDisplay(); + this.setStatus("Game started."); + this.setState("game"); + this.render(); + } + + onMoveApplied(data) { + const movingPiece = this.board[data.from_square] || null; + + this.fen = data.fen; + this.board = parseFenBoard(this.fen); + this.turn = data.turn || this.turn; + this.turnEl.textContent = this.turn === "w" ? "White" : "Black"; + this.applyClockPayload(data); + this.updateCapturedDisplay(); + + if (data.san) { + this.moveHistory.push(data.san); + this.renderMoveList(); + } + + if (movingPiece) { + this.animateMove(data.from_square, data.to_square, movingPiece); + } else { + this.render(); + } + } + + onMoveRejected(data) { + this.setStatus(`Move rejected: ${data.reason || "invalid move"}`); + } + + onGameOver(data) { + this.setStatus(`Game over: ${data.result} (${data.reason})`); + this.showModal( + "Game over", + `${data.result.toUpperCase()} - ${data.reason}`, + [{ label: "OK" }], + ); + } + + onDrawOffered(data) { + const from = data.from || "Opponent"; + this.showModal("Draw offer", `${from} offered a draw.`, [ + { + label: "Accept draw", + className: "btn-primary", + onClick: () => this.socket.emit("request_draw", { accepted: true }), + }, + { + label: "Decline", + onClick: () => this.socket.emit("request_draw", { accepted: false }), + }, + ]); + } + + onClockTick(data) { + if (!this.isGameActive()) { + return; + } + this.applyClockPayload(data); + } + + applyClockPayload(data) { + if (typeof data.white_time_left_ms === "number") { + this.whiteTimeMs = Math.max(0, Math.floor(data.white_time_left_ms)); + } + if (typeof data.black_time_left_ms === "number") { + this.blackTimeMs = Math.max(0, Math.floor(data.black_time_left_ms)); + } + this.updateClockDisplay(); + } + + updateClockDisplay() { + const myMs = this.myColor === "w" ? this.whiteTimeMs : this.blackTimeMs; + const oppMs = this.myColor === "w" ? this.blackTimeMs : this.whiteTimeMs; + this.myTimeEl.textContent = formatClock(myMs); + this.opponentTimeEl.textContent = formatClock(oppMs); + } + + updatePlayerNames() { + this.myNameStripEl.textContent = this.myName + " (You)"; + this.oppNameStripEl.textContent = this.oppName; + } + + renderMoveList() { + this.moveListBodyEl.innerHTML = ""; + for (let i = 0; i < this.moveHistory.length; i += 2) { + const row = document.createElement("tr"); + const moveNo = Math.floor(i / 2) + 1; + const white = this.moveHistory[i] || ""; + const black = this.moveHistory[i + 1] || ""; + row.innerHTML = `${moveNo}.${white}${black}`; + this.moveListBodyEl.appendChild(row); + } + } + + setState(nextState) { + this.state = nextState; + this.setupPanel.classList.toggle("hidden", nextState !== "setup"); + this.waitingPanel.classList.toggle("hidden", nextState !== "waiting"); + this.gameStage.classList.toggle("hidden", nextState !== "game"); + } + + isGameActive() { + return this.state === "game"; + } + + isMyPiece(piece) { + if (!this.myColor) { + return false; + } + const isWhite = piece === piece.toUpperCase(); + return ( + (this.myColor === "w" && isWhite) || (this.myColor === "b" && !isWhite) + ); + } + + isMyTurn() { + return this.myColor && this.turn === this.myColor; + } + + getCanvasPoint(event) { + const rect = this.canvas.getBoundingClientRect(); + // convert mouse px into board CSS px space + const scaleX = this.displayBoardSize / rect.width; + const scaleY = this.displayBoardSize / rect.height; + return { + x: (event.clientX - rect.left) * scaleX, + y: (event.clientY - rect.top) * scaleY, + }; + } + + pixelToSquare(x, y) { + const fileIndex = Math.floor(x / this.squareSize); + const rankIndex = Math.floor(y / this.squareSize); + if (fileIndex < 0 || fileIndex > 7 || rankIndex < 0 || rankIndex > 7) { + return null; + } + + const orientWhite = this.myColor !== "b"; + const file = orientWhite ? fileIndex : 7 - fileIndex; + const rank = orientWhite ? 7 - rankIndex : rankIndex; + return `${"abcdefgh"[file]}${rank + 1}`; + } + + squareToPixel(square) { + const file = square.charCodeAt(0) - 97; + const rank = Number(square[1]) - 1; + + const orientWhite = this.myColor !== "b"; + const col = orientWhite ? file : 7 - file; + const row = orientWhite ? 7 - rank : rank; + + return { x: col * this.squareSize, y: row * this.squareSize }; + } + + animateMove(from, to, piece) { + this.animation = { + from, + to, + piece, + start: performance.now(), + duration: 140, + }; + this.render(); + } + + updateCapturedDisplay() { + const counts = {}; + for (const key of Object.keys(START_COUNTS)) { + counts[key] = 0; + } + for (const piece of Object.values(this.board)) { + counts[piece] = (counts[piece] || 0) + 1; + } + + const capturedWhite = []; + const capturedBlack = []; + + for (const piece of ["P", "N", "B", "R", "Q"]) { + const missing = START_COUNTS[piece] - counts[piece]; + for (let i = 0; i < missing; i += 1) { + capturedWhite.push(piece); + } + } + for (const piece of ["p", "n", "b", "r", "q"]) { + const missing = START_COUNTS[piece] - counts[piece]; + for (let i = 0; i < missing; i += 1) { + capturedBlack.push(piece); + } + } + + const myCaptured = this.myColor === "w" ? capturedBlack : capturedWhite; + const oppCaptured = this.myColor === "w" ? capturedWhite : capturedBlack; + + this.renderCapturedPieces(this.myCapturedStripEl, myCaptured); + this.renderCapturedPieces(this.oppCapturedStripEl, oppCaptured); + + const myMaterial = this.capturedMaterialValue(myCaptured); + const oppMaterial = this.capturedMaterialValue(oppCaptured); + const diff = myMaterial - oppMaterial; + + this.myMaterialStripEl.textContent = + diff > 0 ? `+${diff}` : diff < 0 ? `${diff}` : "="; + this.oppMaterialStripEl.textContent = + diff < 0 ? `+${-diff}` : diff > 0 ? `${-diff}` : "="; + } + + capturedMaterialValue(capturedPieces) { + let score = 0; + for (const piece of capturedPieces) { + const p = piece.toLowerCase(); + score += PIECE_VALUES[p] || 0; + } + return score; + } + + renderCapturedPieces(container, pieces) { + if (!pieces.length) { + container.textContent = "-"; + return; + } + + container.innerHTML = pieces + .map( + (piece) => + `${piece}`, + ) + .join(""); + } + + drawBoard() { + this.ctx.clearRect(0, 0, this.displayBoardSize, this.displayBoardSize); + for (let row = 0; row < 8; row += 1) { + for (let col = 0; col < 8; col += 1) { + const light = (row + col) % 2 === 0; + this.ctx.fillStyle = light ? "#f6e7cf" : "#b18155"; + this.ctx.fillRect( + col * this.squareSize, + row * this.squareSize, + this.squareSize, + this.squareSize, + ); + } + } + } + + drawSelection() { + if (!this.selectedSquare) { + return; + } + const { x, y } = this.squareToPixel(this.selectedSquare); + this.ctx.strokeStyle = "#2563eb"; + this.ctx.lineWidth = 3; + this.ctx.strokeRect(x + 3, y + 3, this.squareSize - 6, this.squareSize - 6); + } + + drawPiece(piece, centerX, centerY) { + const img = this.pieceImages[piece]; + if (!img || !img.complete || img.naturalWidth <= 0) { + return; + } + const size = this.squareSize * 0.86; + this.ctx.drawImage(img, centerX - size / 2, centerY - size / 2, size, size); + } + + drawPieces() { + for (const [square, piece] of Object.entries(this.board)) { + if (this.animation && square === this.animation.to) { + continue; + } + if (this.drag && square === this.drag.from) { + continue; + } + + const { x, y } = this.squareToPixel(square); + this.drawPiece(piece, x + this.squareSize / 2, y + this.squareSize / 2); + } + + if (this.animation) { + const now = performance.now(); + const progress = Math.min( + 1, + (now - this.animation.start) / this.animation.duration, + ); + const from = this.squareToPixel(this.animation.from); + const to = this.squareToPixel(this.animation.to); + const x = from.x + (to.x - from.x) * progress; + const y = from.y + (to.y - from.y) * progress; + this.drawPiece( + this.animation.piece, + x + this.squareSize / 2, + y + this.squareSize / 2, + ); + if (progress >= 1) { + this.animation = null; + } + } + + if (this.drag) { + this.ctx.globalAlpha = 0.9; + this.drawPiece(this.drag.piece, this.drag.x, this.drag.y); + this.ctx.globalAlpha = 1; + } + } + + render() { + this.ctx.setTransform(this.dpr, 0, 0, this.dpr, 0, 0); + this.drawBoard(); + this.drawSelection(); + this.drawPieces(); + + if (this.animation || this.drag) { + requestAnimationFrame(() => this.render()); + } + } + + setStatus(text) { + this.statusEl.textContent = text; + } + + setLobbyStatus(text) { + this.lobbyStatusEl.textContent = text; + } + + showModal(title, text, actions = []) { + this.modalTitleEl.textContent = title; + this.modalTextEl.textContent = text; + this.modalActionsEl.innerHTML = ""; + + const safeActions = actions.length ? actions : [{ label: "OK" }]; + for (const action of safeActions) { + const btn = document.createElement("button"); + btn.type = "button"; + btn.className = `btn ${action.className || "btn-secondary"}`; + btn.textContent = action.label; + btn.addEventListener("click", () => { + this.hideModal(); + action.onClick?.(); + }); + this.modalActionsEl.appendChild(btn); + } + + this.modal.classList.remove("hidden"); + } + + hideModal() { + this.modal.classList.add("hidden"); + } +} diff --git a/app/static/js/play/constants.js b/app/static/js/play/constants.js new file mode 100644 index 0000000..e638c8a --- /dev/null +++ b/app/static/js/play/constants.js @@ -0,0 +1,29 @@ +export const PIECE_IMAGE_PATHS = { + P: "/static/board_pieces/wp.png", + N: "/static/board_pieces/wn.png", + B: "/static/board_pieces/wb.png", + R: "/static/board_pieces/wr.png", + Q: "/static/board_pieces/wq.png", + K: "/static/board_pieces/wk.png", + p: "/static/board_pieces/bp.png", + n: "/static/board_pieces/bn.png", + b: "/static/board_pieces/bb.png", + r: "/static/board_pieces/br.png", + q: "/static/board_pieces/bq.png", + k: "/static/board_pieces/bk.png", +}; + +export const START_COUNTS = { + P: 8, + N: 2, + B: 2, + R: 2, + Q: 1, + K: 1, + p: 8, + n: 2, + b: 2, + r: 2, + q: 1, + k: 1, +}; diff --git a/app/static/js/play/utils.js b/app/static/js/play/utils.js new file mode 100644 index 0000000..9a6c50d --- /dev/null +++ b/app/static/js/play/utils.js @@ -0,0 +1,32 @@ +export function formatClock(ms) { + if (!Number.isFinite(ms) || ms < 0) { + return "--:--"; + } + + const totalSeconds = Math.floor(ms / 1000); + const minutes = Math.floor(totalSeconds / 60); + const seconds = totalSeconds % 60; + return `${String(minutes).padStart(2, "0")}:${String(seconds).padStart(2, "0")}`; +} + +export function parseFenBoard(fen) { + const boardPart = (fen || "").split(" ")[0] || "8/8/8/8/8/8/8/8"; + const rows = boardPart.split("/"); + const board = {}; + + for (let r = 0; r < 8; r += 1) { + let file = 0; + for (const fenChar of rows[r]) { + const num = Number(fenChar); + if (!Number.isNaN(num)) { + file += num; + continue; + } + const square = `${"abcdefgh"[file]}${8 - r}`; + board[square] = fenChar; + file += 1; + } + } + + return board; +}