791 lines
22 KiB
JavaScript
791 lines
22 KiB
JavaScript
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 = `<td>${moveNo}.</td><td>${white}</td><td>${black}</td>`;
|
|
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) =>
|
|
`<img class="captured-piece" src="${PIECE_IMAGE_PATHS[piece]}" alt="${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");
|
|
}
|
|
}
|