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})`); const actions = [{ label: "OK" }]; if (data.game_id) { actions.unshift({ label: "View saved game", className: "btn-primary", onClick: () => { window.location.href = `/games/${data.game_id}`; }, }); } this.showModal("Game over", `${data.result.toUpperCase()} - ${data.reason}`, actions); } 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"); } }