This repository has been archived on 2026-03-15. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
cau-praktikum/app/static/js/play/app.js
T

784 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})`);
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 = `<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");
}
}