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 = `