2118 lines
42 KiB
JavaScript
2118 lines
42 KiB
JavaScript
import {
|
|
ChessEngine,
|
|
ChessRules,
|
|
} from "/shared/chess-engine.js";
|
|
|
|
import {
|
|
chooseComputerMove,
|
|
} from "/js/ai.js";
|
|
|
|
|
|
/* =========================================================
|
|
PIECES
|
|
========================================================= */
|
|
|
|
const PIECE_GLYPHS = {
|
|
w: {
|
|
K: "♔",
|
|
Q: "♕",
|
|
R: "♖",
|
|
B: "♗",
|
|
N: "♘",
|
|
P: "♙",
|
|
},
|
|
|
|
b: {
|
|
K: "♚",
|
|
Q: "♛",
|
|
R: "♜",
|
|
B: "♝",
|
|
N: "♞",
|
|
P: "♟",
|
|
},
|
|
};
|
|
|
|
|
|
/* =========================================================
|
|
STORAGE
|
|
========================================================= */
|
|
|
|
const STORAGE_KEYS = {
|
|
name: "chess-party:name",
|
|
partyIds: "chess-party:party-ids",
|
|
};
|
|
|
|
|
|
/* =========================================================
|
|
TIME CONTROLS
|
|
========================================================= */
|
|
|
|
const TIME_CONTROLS = {
|
|
none: 0,
|
|
|
|
"1+0": 60,
|
|
"3+0": 180,
|
|
"3+2": 180,
|
|
"5+0": 300,
|
|
"5+3": 300,
|
|
"10+0": 600,
|
|
"15+10": 900,
|
|
"30+0": 1800,
|
|
"30+20": 1800,
|
|
};
|
|
|
|
|
|
/* =========================================================
|
|
ELEMENTS
|
|
========================================================= */
|
|
|
|
const elements = {
|
|
modeBadge: document.querySelector("#modeBadge"),
|
|
connectionBadge: document.querySelector("#connectionBadge"),
|
|
|
|
board: document.querySelector("#board"),
|
|
|
|
statusMessage: document.querySelector("#statusMessage"),
|
|
turnBadge: document.querySelector("#turnBadge"),
|
|
|
|
modeAiButton: document.querySelector("#modeAiButton"),
|
|
modePartyButton: document.querySelector("#modePartyButton"),
|
|
|
|
playerNameInput: document.querySelector("#playerNameInput"),
|
|
|
|
aiControls: document.querySelector("#aiControls"),
|
|
partyControls: document.querySelector("#partyControls"),
|
|
|
|
aiLevelSelect: document.querySelector("#aiLevelSelect"),
|
|
playerColorSelect: document.querySelector("#playerColorSelect"),
|
|
|
|
aiTimeControl: document.querySelector("#aiClockSelect"),
|
|
partyTimeControl: document.querySelector("#partyClockSelect"),
|
|
|
|
startAiButton: document.querySelector("#startAiButton"),
|
|
|
|
createPartyButton: document.querySelector("#createPartyButton"),
|
|
partyCodeInput: document.querySelector("#partyCodeInput"),
|
|
joinPartyButton: document.querySelector("#joinPartyButton"),
|
|
leavePartyButton: document.querySelector("#leavePartyButton"),
|
|
|
|
partySummary: document.querySelector("#partySummary"),
|
|
copyInviteButton: document.querySelector("#copyInviteButton"),
|
|
|
|
flipBoardButton: document.querySelector("#flipBoardButton"),
|
|
|
|
whitePlayerLabel: document.querySelector("#whitePlayerLabel"),
|
|
blackPlayerLabel: document.querySelector("#blackPlayerLabel"),
|
|
|
|
rosterWhite: document.querySelector("#rosterWhite"),
|
|
rosterBlack: document.querySelector("#rosterBlack"),
|
|
|
|
spectatorList: document.querySelector("#spectatorList"),
|
|
spectatorCount: document.querySelector("#spectatorCount"),
|
|
|
|
whiteClock: document.querySelector("#whiteClock"),
|
|
blackClock: document.querySelector("#blackClock"),
|
|
|
|
promotionDialog: document.querySelector("#promotionDialog"),
|
|
promotionOptions: document.querySelector("#promotionOptions"),
|
|
|
|
toastHost: document.querySelector("#toastHost"),
|
|
};
|
|
|
|
|
|
/* =========================================================
|
|
STATE
|
|
========================================================= */
|
|
|
|
const appState = {
|
|
mode: "ai",
|
|
|
|
engine: new ChessEngine(),
|
|
|
|
orientation: "w",
|
|
selectedSquare: null,
|
|
|
|
aiLevel: "intermediate",
|
|
playerColor: "w",
|
|
aiTimeControl: "10+0",
|
|
|
|
isAiThinking: false,
|
|
aiMoveTimer: null,
|
|
aiClockTimer: null,
|
|
|
|
pendingPromotionMoves: null,
|
|
|
|
playerName: "Guest",
|
|
|
|
party: null,
|
|
partySnapshot: null,
|
|
partyStream: null,
|
|
|
|
gameOver: false,
|
|
gameOverMessage: null,
|
|
|
|
localClocks: {
|
|
white: 0,
|
|
black: 0,
|
|
running: false,
|
|
turn: "w",
|
|
lastTick: 0,
|
|
},
|
|
};
|
|
|
|
|
|
/* =========================================================
|
|
HELPERS
|
|
========================================================= */
|
|
|
|
function currentStatus() {
|
|
return (
|
|
appState.engine.state.status ||
|
|
ChessEngine.evaluateStatus(
|
|
appState.engine.state
|
|
)
|
|
);
|
|
}
|
|
|
|
|
|
function currentHumanColor() {
|
|
if (appState.mode === "ai") {
|
|
return appState.playerColor;
|
|
}
|
|
|
|
if (!appState.party) {
|
|
return null;
|
|
}
|
|
|
|
if (appState.party.role === "white") {
|
|
return "w";
|
|
}
|
|
|
|
if (appState.party.role === "black") {
|
|
return "b";
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
|
|
function isHumanTurn() {
|
|
const color = currentHumanColor();
|
|
const status = currentStatus();
|
|
|
|
return Boolean(
|
|
color &&
|
|
status.phase === "playing" &&
|
|
appState.engine.state.turn === color &&
|
|
!appState.isAiThinking &&
|
|
!appState.gameOver
|
|
);
|
|
}
|
|
|
|
|
|
function isFinished() {
|
|
const status = currentStatus();
|
|
|
|
return Boolean(
|
|
appState.gameOver ||
|
|
status.phase === "checkmate" ||
|
|
status.phase === "draw"
|
|
);
|
|
}
|
|
|
|
|
|
function getLegalMovesForSelected() {
|
|
if (!appState.selectedSquare) {
|
|
return [];
|
|
}
|
|
|
|
return appState.engine.getMovesFrom(
|
|
appState.selectedSquare
|
|
);
|
|
}
|
|
|
|
|
|
function clearSelection() {
|
|
appState.selectedSquare = null;
|
|
appState.pendingPromotionMoves = null;
|
|
}
|
|
|
|
|
|
function escapeHtml(value) {
|
|
return String(value)
|
|
.replaceAll("&", "&")
|
|
.replaceAll("<", "<")
|
|
.replaceAll(">", ">")
|
|
.replaceAll('"', """)
|
|
.replaceAll("'", "'");
|
|
}
|
|
|
|
|
|
function formatClock(seconds) {
|
|
const total = Math.max(
|
|
0,
|
|
Math.ceil(Number(seconds) || 0)
|
|
);
|
|
|
|
const minutes = Math.floor(total / 60);
|
|
const secs = total % 60;
|
|
|
|
if (minutes >= 60) {
|
|
const hours = Math.floor(minutes / 60);
|
|
const mins = minutes % 60;
|
|
|
|
return (
|
|
`${String(hours).padStart(2, "0")}:` +
|
|
`${String(mins).padStart(2, "0")}:` +
|
|
`${String(secs).padStart(2, "0")}`
|
|
);
|
|
}
|
|
|
|
return (
|
|
`${String(minutes).padStart(2, "0")}:` +
|
|
`${String(secs).padStart(2, "0")}`
|
|
);
|
|
}
|
|
|
|
|
|
function getIncrementForTimeControl(value) {
|
|
const parts = String(value || "").split("+");
|
|
return Number(parts[1] || 0);
|
|
}
|
|
|
|
|
|
function getTimeControlSeconds(value) {
|
|
return TIME_CONTROLS[value] || 0;
|
|
}
|
|
|
|
|
|
/* =========================================================
|
|
TOAST
|
|
========================================================= */
|
|
|
|
function showToast(message, type = "info") {
|
|
if (!elements.toastHost) {
|
|
console.log(message);
|
|
return;
|
|
}
|
|
|
|
const toast = document.createElement("div");
|
|
|
|
toast.className = `toast ${type}`;
|
|
toast.textContent = message;
|
|
|
|
elements.toastHost.appendChild(toast);
|
|
|
|
window.setTimeout(
|
|
() => toast.remove(),
|
|
3200
|
|
);
|
|
}
|
|
|
|
|
|
/* =========================================================
|
|
MODE
|
|
========================================================= */
|
|
|
|
function setMode(mode) {
|
|
appState.mode = mode;
|
|
|
|
const isAi = mode === "ai";
|
|
const isParty = mode === "party";
|
|
|
|
elements.modeAiButton?.classList.toggle(
|
|
"active",
|
|
isAi
|
|
);
|
|
|
|
elements.modePartyButton?.classList.toggle(
|
|
"active",
|
|
isParty
|
|
);
|
|
|
|
elements.aiControls?.classList.toggle(
|
|
"hidden",
|
|
!isAi
|
|
);
|
|
|
|
elements.partyControls?.classList.toggle(
|
|
"hidden",
|
|
!isParty
|
|
);
|
|
|
|
if (elements.modeBadge) {
|
|
elements.modeBadge.textContent =
|
|
isAi
|
|
? "AI Arena"
|
|
: "Party Lounge";
|
|
}
|
|
}
|
|
|
|
|
|
/* =========================================================
|
|
URL
|
|
========================================================= */
|
|
|
|
function updatePartyUrl(code = null) {
|
|
const url = new URL(window.location.href);
|
|
|
|
if (code) {
|
|
url.searchParams.set("party", code);
|
|
} else {
|
|
url.searchParams.delete("party");
|
|
}
|
|
|
|
window.history.replaceState(
|
|
{},
|
|
"",
|
|
url
|
|
);
|
|
}
|
|
|
|
|
|
/* =========================================================
|
|
BOARD
|
|
========================================================= */
|
|
|
|
function renderBoard() {
|
|
if (!elements.board) {
|
|
return;
|
|
}
|
|
|
|
elements.board.innerHTML = "";
|
|
|
|
const selectedMoves =
|
|
getLegalMovesForSelected();
|
|
|
|
const moveTargets = new Map(
|
|
selectedMoves.map(move => [
|
|
move.to,
|
|
move,
|
|
])
|
|
);
|
|
|
|
const orientation =
|
|
appState.orientation;
|
|
|
|
const xOrder =
|
|
orientation === "w"
|
|
? [0, 1, 2, 3, 4, 5, 6, 7]
|
|
: [7, 6, 5, 4, 3, 2, 1, 0];
|
|
|
|
const yOrder =
|
|
orientation === "w"
|
|
? [7, 6, 5, 4, 3, 2, 1, 0]
|
|
: [0, 1, 2, 3, 4, 5, 6, 7];
|
|
|
|
const status = currentStatus();
|
|
|
|
const checkColor =
|
|
status.check
|
|
? appState.engine.state.turn
|
|
: null;
|
|
|
|
const lastMove =
|
|
appState.engine.state.lastMove;
|
|
|
|
for (const y of yOrder) {
|
|
for (const x of xOrder) {
|
|
const squareName =
|
|
ChessRules.toSquare(x, y);
|
|
|
|
const piece =
|
|
appState.engine.state.board[y][x];
|
|
|
|
const square =
|
|
document.createElement("button");
|
|
|
|
square.type = "button";
|
|
square.className =
|
|
`square ${
|
|
(x + y) % 2 === 0
|
|
? "dark"
|
|
: "light"
|
|
}`;
|
|
|
|
square.dataset.square =
|
|
squareName;
|
|
|
|
if (
|
|
appState.selectedSquare ===
|
|
squareName
|
|
) {
|
|
square.classList.add(
|
|
"selected"
|
|
);
|
|
}
|
|
|
|
const move =
|
|
moveTargets.get(squareName);
|
|
|
|
if (move) {
|
|
square.classList.add(
|
|
move.capture
|
|
? "capture-target"
|
|
: "legal-target"
|
|
);
|
|
}
|
|
|
|
if (
|
|
lastMove &&
|
|
(
|
|
lastMove.from === squareName ||
|
|
lastMove.to === squareName
|
|
)
|
|
) {
|
|
square.classList.add(
|
|
"last-move"
|
|
);
|
|
}
|
|
|
|
if (
|
|
piece &&
|
|
piece.type === "K" &&
|
|
piece.color === checkColor
|
|
) {
|
|
square.classList.add(
|
|
"check-square"
|
|
);
|
|
}
|
|
|
|
if (piece) {
|
|
const pieceNode =
|
|
document.createElement("span");
|
|
|
|
pieceNode.className =
|
|
`piece ${
|
|
piece.color === "w"
|
|
? "white"
|
|
: "black"
|
|
}`;
|
|
|
|
pieceNode.textContent =
|
|
PIECE_GLYPHS[
|
|
piece.color
|
|
][piece.type];
|
|
|
|
square.appendChild(pieceNode);
|
|
}
|
|
|
|
square.addEventListener(
|
|
"click",
|
|
() => handleSquareClick(squareName)
|
|
);
|
|
|
|
elements.board.appendChild(square);
|
|
}
|
|
}
|
|
}
|
|
|
|
|
|
/* =========================================================
|
|
GAME STATUS
|
|
========================================================= */
|
|
|
|
function formatStatus() {
|
|
if (appState.gameOver) {
|
|
return getGameOverMessage();
|
|
}
|
|
|
|
const status = currentStatus();
|
|
|
|
if (status.phase === "checkmate") {
|
|
return "Checkmate";
|
|
}
|
|
|
|
if (status.phase === "draw") {
|
|
return "Draw";
|
|
}
|
|
|
|
if (status.check) {
|
|
return appState.engine.state.turn === "w"
|
|
? "White is in check"
|
|
: "Black is in check";
|
|
}
|
|
|
|
return appState.engine.state.turn === "w"
|
|
? "White to move"
|
|
: "Black to move";
|
|
}
|
|
|
|
|
|
function getGameOverMessage() {
|
|
const snapshot =
|
|
appState.partySnapshot;
|
|
|
|
if (!snapshot) {
|
|
return (
|
|
appState.gameOverMessage ||
|
|
"Game over."
|
|
);
|
|
}
|
|
|
|
const reason =
|
|
snapshot.gameOverReason;
|
|
|
|
const winner =
|
|
snapshot.winner;
|
|
|
|
switch (reason) {
|
|
case "timeout":
|
|
return winner === "w"
|
|
? "White wins on time."
|
|
: "Black wins on time.";
|
|
|
|
case "checkmate":
|
|
return winner === "w"
|
|
? "White wins by checkmate."
|
|
: "Black wins by checkmate.";
|
|
|
|
case "draw":
|
|
return "Draw.";
|
|
|
|
default:
|
|
return (
|
|
appState.gameOverMessage ||
|
|
"Game over."
|
|
);
|
|
}
|
|
}
|
|
|
|
|
|
/* =========================================================
|
|
ROSTER
|
|
========================================================= */
|
|
|
|
function renderRoster() {
|
|
if (appState.mode === "ai") {
|
|
const human =
|
|
appState.playerName || "You";
|
|
|
|
const whiteName =
|
|
appState.playerColor === "w"
|
|
? human
|
|
: `Computer (${appState.aiLevel})`;
|
|
|
|
const blackName =
|
|
appState.playerColor === "b"
|
|
? human
|
|
: `Computer (${appState.aiLevel})`;
|
|
|
|
if (elements.whitePlayerLabel) {
|
|
elements.whitePlayerLabel.textContent =
|
|
whiteName;
|
|
}
|
|
|
|
if (elements.blackPlayerLabel) {
|
|
elements.blackPlayerLabel.textContent =
|
|
blackName;
|
|
}
|
|
|
|
if (elements.rosterWhite) {
|
|
elements.rosterWhite.textContent =
|
|
whiteName;
|
|
}
|
|
|
|
if (elements.rosterBlack) {
|
|
elements.rosterBlack.textContent =
|
|
blackName;
|
|
}
|
|
|
|
if (elements.spectatorList) {
|
|
elements.spectatorList.innerHTML =
|
|
"<li>None in AI mode</li>";
|
|
}
|
|
|
|
if (elements.spectatorCount) {
|
|
elements.spectatorCount.textContent =
|
|
"0 / 20";
|
|
}
|
|
|
|
return;
|
|
}
|
|
|
|
const white =
|
|
appState.partySnapshot?.players?.white;
|
|
|
|
const black =
|
|
appState.partySnapshot?.players?.black;
|
|
|
|
const whiteName =
|
|
white
|
|
? `${white.name}${
|
|
white.connected
|
|
? ""
|
|
: " · offline"
|
|
}`
|
|
: "Open seat";
|
|
|
|
const blackName =
|
|
black
|
|
? `${black.name}${
|
|
black.connected
|
|
? ""
|
|
: " · offline"
|
|
}`
|
|
: "Open seat";
|
|
|
|
elements.whitePlayerLabel &&
|
|
(elements.whitePlayerLabel.textContent =
|
|
whiteName);
|
|
|
|
elements.blackPlayerLabel &&
|
|
(elements.blackPlayerLabel.textContent =
|
|
blackName);
|
|
|
|
elements.rosterWhite &&
|
|
(elements.rosterWhite.textContent =
|
|
whiteName);
|
|
|
|
elements.rosterBlack &&
|
|
(elements.rosterBlack.textContent =
|
|
blackName);
|
|
|
|
const spectators =
|
|
appState.partySnapshot?.spectators || [];
|
|
|
|
if (elements.spectatorCount) {
|
|
elements.spectatorCount.textContent =
|
|
`${spectators.length} / 20`;
|
|
}
|
|
|
|
if (!elements.spectatorList) {
|
|
return;
|
|
}
|
|
|
|
if (!spectators.length) {
|
|
elements.spectatorList.innerHTML =
|
|
"<li>None yet</li>";
|
|
|
|
return;
|
|
}
|
|
|
|
elements.spectatorList.innerHTML =
|
|
spectators
|
|
.map(
|
|
spectator =>
|
|
`<li>${escapeHtml(
|
|
spectator.name
|
|
)}${
|
|
spectator.connected
|
|
? ""
|
|
: " · offline"
|
|
}</li>`
|
|
)
|
|
.join("");
|
|
}
|
|
|
|
|
|
/* =========================================================
|
|
PARTY SUMMARY
|
|
========================================================= */
|
|
|
|
function renderPartySummary() {
|
|
if (!elements.partySummary) {
|
|
return;
|
|
}
|
|
|
|
if (!appState.party) {
|
|
elements.partySummary.textContent =
|
|
"Create a room to become White. The next player joins as Black. Up to 20 spectators can watch live.";
|
|
|
|
return;
|
|
}
|
|
|
|
const role =
|
|
appState.party.role;
|
|
|
|
const roleText =
|
|
role === "white"
|
|
? "playing as White"
|
|
: role === "black"
|
|
? "playing as Black"
|
|
: "watching as a spectator";
|
|
|
|
const timeLabel =
|
|
appState.partySnapshot
|
|
?.timeControl?.label ||
|
|
"Без часов";
|
|
|
|
elements.partySummary.textContent =
|
|
`Party ${appState.party.code} · ${roleText}. ${timeLabel}.`;
|
|
}
|
|
|
|
|
|
/* =========================================================
|
|
CLOCKS
|
|
========================================================= */
|
|
|
|
function renderClocks() {
|
|
let clocks;
|
|
let timeControl;
|
|
|
|
if (appState.mode === "party") {
|
|
clocks =
|
|
appState.partySnapshot?.clocks;
|
|
|
|
timeControl =
|
|
appState.partySnapshot?.timeControl;
|
|
} else {
|
|
clocks =
|
|
appState.localClocks;
|
|
|
|
timeControl = {
|
|
initial:
|
|
getTimeControlSeconds(
|
|
appState.aiTimeControl
|
|
),
|
|
|
|
label:
|
|
elements.aiTimeControl
|
|
?.selectedOptions?.[0]
|
|
?.textContent ||
|
|
"Без часов",
|
|
};
|
|
}
|
|
|
|
const enabled =
|
|
Boolean(
|
|
timeControl?.initial > 0
|
|
);
|
|
|
|
if (!enabled) {
|
|
if (elements.whiteClock) {
|
|
elements.whiteClock.textContent = "∞";
|
|
elements.whiteClock.classList.remove(
|
|
"active",
|
|
"low"
|
|
);
|
|
}
|
|
|
|
if (elements.blackClock) {
|
|
elements.blackClock.textContent = "∞";
|
|
elements.blackClock.classList.remove(
|
|
"active",
|
|
"low"
|
|
);
|
|
}
|
|
|
|
return;
|
|
}
|
|
|
|
const white =
|
|
clocks?.white ?? 0;
|
|
|
|
const black =
|
|
clocks?.black ?? 0;
|
|
|
|
if (elements.whiteClock) {
|
|
elements.whiteClock.textContent =
|
|
formatClock(white);
|
|
|
|
elements.whiteClock.classList.toggle(
|
|
"active",
|
|
clocks?.turn === "w" &&
|
|
clocks?.running
|
|
);
|
|
|
|
elements.whiteClock.classList.toggle(
|
|
"low",
|
|
white <= 10
|
|
);
|
|
}
|
|
|
|
if (elements.blackClock) {
|
|
elements.blackClock.textContent =
|
|
formatClock(black);
|
|
|
|
elements.blackClock.classList.toggle(
|
|
"active",
|
|
clocks?.turn === "b" &&
|
|
clocks?.running
|
|
);
|
|
|
|
elements.blackClock.classList.toggle(
|
|
"low",
|
|
black <= 10
|
|
);
|
|
}
|
|
}
|
|
|
|
|
|
/* =========================================================
|
|
CONNECTION BADGE
|
|
========================================================= */
|
|
|
|
function renderConnectionBadge() {
|
|
if (!elements.connectionBadge) {
|
|
return;
|
|
}
|
|
|
|
if (appState.mode === "ai") {
|
|
elements.connectionBadge.textContent =
|
|
appState.isAiThinking
|
|
? "Computer thinking"
|
|
: `AI: ${appState.aiLevel}`;
|
|
|
|
return;
|
|
}
|
|
|
|
if (!appState.party) {
|
|
elements.connectionBadge.textContent =
|
|
"Party idle";
|
|
|
|
return;
|
|
}
|
|
|
|
const role =
|
|
appState.party.role === "white"
|
|
? "White"
|
|
: appState.party.role === "black"
|
|
? "Black"
|
|
: "Spectator";
|
|
|
|
elements.connectionBadge.textContent =
|
|
`Party ${appState.party.code} · ${role}`;
|
|
}
|
|
|
|
|
|
/* =========================================================
|
|
RENDER
|
|
========================================================= */
|
|
|
|
function render() {
|
|
renderBoard();
|
|
renderRoster();
|
|
renderPartySummary();
|
|
renderClocks();
|
|
renderConnectionBadge();
|
|
|
|
const status = formatStatus();
|
|
|
|
if (elements.turnBadge) {
|
|
elements.turnBadge.textContent =
|
|
status;
|
|
}
|
|
|
|
if (elements.statusMessage) {
|
|
elements.statusMessage.textContent =
|
|
status;
|
|
}
|
|
|
|
elements.copyInviteButton?.classList.toggle(
|
|
"hidden",
|
|
!appState.party
|
|
);
|
|
|
|
elements.leavePartyButton?.classList.toggle(
|
|
"hidden",
|
|
!appState.party
|
|
);
|
|
}
|
|
|
|
|
|
/* =========================================================
|
|
PROMOTION
|
|
========================================================= */
|
|
|
|
function openPromotionDialog(moves) {
|
|
appState.pendingPromotionMoves =
|
|
moves;
|
|
|
|
if (!elements.promotionOptions) {
|
|
return;
|
|
}
|
|
|
|
elements.promotionOptions.innerHTML = "";
|
|
|
|
for (const move of moves) {
|
|
const button =
|
|
document.createElement("button");
|
|
|
|
button.type = "button";
|
|
button.className =
|
|
"promotion-button";
|
|
|
|
const promotion =
|
|
String(move.promotion || "Q")
|
|
.toUpperCase();
|
|
|
|
button.textContent =
|
|
PIECE_GLYPHS[
|
|
move.color
|
|
][promotion];
|
|
|
|
button.addEventListener(
|
|
"click",
|
|
() => {
|
|
closePromotionDialog();
|
|
submitMove(move);
|
|
}
|
|
);
|
|
|
|
elements.promotionOptions.appendChild(
|
|
button
|
|
);
|
|
}
|
|
|
|
elements.promotionDialog?.classList.remove(
|
|
"hidden"
|
|
);
|
|
}
|
|
|
|
|
|
function closePromotionDialog() {
|
|
appState.pendingPromotionMoves = null;
|
|
|
|
elements.promotionDialog?.classList.add(
|
|
"hidden"
|
|
);
|
|
}
|
|
|
|
|
|
/* =========================================================
|
|
MOVE
|
|
========================================================= */
|
|
|
|
async function submitMove(move) {
|
|
if (appState.gameOver) {
|
|
return;
|
|
}
|
|
|
|
clearSelection();
|
|
|
|
if (
|
|
appState.mode === "party" &&
|
|
appState.party
|
|
) {
|
|
const response =
|
|
await postJson(
|
|
"/api/party/move",
|
|
{
|
|
partyCode:
|
|
appState.party.code,
|
|
|
|
clientId:
|
|
appState.party.clientId,
|
|
|
|
from: move.from,
|
|
to: move.to,
|
|
|
|
promotion:
|
|
move.promotion || null,
|
|
}
|
|
);
|
|
|
|
if (!response.ok) {
|
|
if (response.party) {
|
|
hydratePartyState(
|
|
response.party,
|
|
false
|
|
);
|
|
}
|
|
|
|
showToast(
|
|
response.error ||
|
|
"Move rejected.",
|
|
"error"
|
|
);
|
|
|
|
return;
|
|
}
|
|
|
|
hydratePartyState(
|
|
response.party,
|
|
false
|
|
);
|
|
|
|
return;
|
|
}
|
|
|
|
const result =
|
|
appState.engine.makeMove({
|
|
from: move.from,
|
|
to: move.to,
|
|
promotion:
|
|
move.promotion || null,
|
|
});
|
|
|
|
if (!result.ok) {
|
|
showToast(
|
|
result.error ||
|
|
"Illegal move.",
|
|
"error"
|
|
);
|
|
|
|
return;
|
|
}
|
|
|
|
updateAiClockAfterMove();
|
|
|
|
render();
|
|
|
|
scheduleAiTurn();
|
|
}
|
|
|
|
|
|
/* =========================================================
|
|
BOARD INPUT
|
|
========================================================= */
|
|
|
|
function handleSquareClick(square) {
|
|
if (
|
|
appState.pendingPromotionMoves ||
|
|
appState.gameOver
|
|
) {
|
|
return;
|
|
}
|
|
|
|
if (!isHumanTurn()) {
|
|
clearSelection();
|
|
renderBoard();
|
|
return;
|
|
}
|
|
|
|
const piece =
|
|
appState.engine.getPiece(square);
|
|
|
|
if (appState.selectedSquare) {
|
|
const matchingMoves =
|
|
getLegalMovesForSelected()
|
|
.filter(
|
|
move =>
|
|
move.to === square
|
|
);
|
|
|
|
if (matchingMoves.length === 1) {
|
|
submitMove(matchingMoves[0]);
|
|
return;
|
|
}
|
|
|
|
if (matchingMoves.length > 1) {
|
|
openPromotionDialog(
|
|
matchingMoves
|
|
);
|
|
return;
|
|
}
|
|
}
|
|
|
|
const humanColor =
|
|
currentHumanColor();
|
|
|
|
if (
|
|
piece &&
|
|
piece.color === humanColor &&
|
|
piece.color ===
|
|
appState.engine.state.turn
|
|
) {
|
|
appState.selectedSquare =
|
|
appState.selectedSquare === square
|
|
? null
|
|
: square;
|
|
|
|
renderBoard();
|
|
return;
|
|
}
|
|
|
|
clearSelection();
|
|
renderBoard();
|
|
}
|
|
|
|
|
|
/* =========================================================
|
|
AI
|
|
========================================================= */
|
|
|
|
function scheduleAiTurn() {
|
|
stopAiMoveTimer();
|
|
|
|
if (appState.mode !== "ai") {
|
|
return;
|
|
}
|
|
|
|
const status = currentStatus();
|
|
|
|
if (
|
|
!status ||
|
|
status.phase !== "playing" ||
|
|
appState.gameOver
|
|
) {
|
|
appState.isAiThinking = false;
|
|
render();
|
|
return;
|
|
}
|
|
|
|
if (
|
|
appState.engine.state.turn ===
|
|
appState.playerColor
|
|
) {
|
|
appState.isAiThinking = false;
|
|
render();
|
|
return;
|
|
}
|
|
|
|
appState.isAiThinking = true;
|
|
render();
|
|
|
|
appState.aiMoveTimer =
|
|
window.setTimeout(
|
|
() => {
|
|
appState.aiMoveTimer = null;
|
|
|
|
if (
|
|
appState.mode !== "ai" ||
|
|
appState.gameOver
|
|
) {
|
|
appState.isAiThinking = false;
|
|
render();
|
|
return;
|
|
}
|
|
|
|
const move =
|
|
chooseComputerMove(
|
|
appState.engine,
|
|
appState.aiLevel
|
|
);
|
|
|
|
appState.isAiThinking = false;
|
|
|
|
if (!move) {
|
|
render();
|
|
return;
|
|
}
|
|
|
|
submitMove(move);
|
|
},
|
|
400
|
|
);
|
|
}
|
|
|
|
|
|
function stopAiMoveTimer() {
|
|
if (appState.aiMoveTimer) {
|
|
window.clearTimeout(
|
|
appState.aiMoveTimer
|
|
);
|
|
|
|
appState.aiMoveTimer = null;
|
|
}
|
|
|
|
appState.isAiThinking = false;
|
|
}
|
|
|
|
|
|
/* =========================================================
|
|
AI CLOCK
|
|
========================================================= */
|
|
|
|
function startAiClock() {
|
|
stopAiClock();
|
|
|
|
const seconds =
|
|
getTimeControlSeconds(
|
|
appState.aiTimeControl
|
|
);
|
|
|
|
appState.localClocks = {
|
|
white: seconds,
|
|
black: seconds,
|
|
|
|
running: seconds > 0,
|
|
|
|
turn:
|
|
appState.engine.state.turn,
|
|
|
|
lastTick:
|
|
Date.now(),
|
|
};
|
|
|
|
render();
|
|
|
|
if (!seconds) {
|
|
return;
|
|
}
|
|
|
|
appState.aiClockTimer =
|
|
window.setInterval(
|
|
() => {
|
|
updateAiClock();
|
|
renderClocks();
|
|
},
|
|
250
|
|
);
|
|
}
|
|
|
|
|
|
function stopAiClock() {
|
|
if (appState.aiClockTimer) {
|
|
window.clearInterval(
|
|
appState.aiClockTimer
|
|
);
|
|
|
|
appState.aiClockTimer = null;
|
|
}
|
|
}
|
|
|
|
|
|
function updateAiClock() {
|
|
if (
|
|
appState.mode !== "ai" ||
|
|
appState.gameOver
|
|
) {
|
|
return;
|
|
}
|
|
|
|
const clocks =
|
|
appState.localClocks;
|
|
|
|
if (!clocks.running) {
|
|
return;
|
|
}
|
|
|
|
const now = Date.now();
|
|
|
|
const elapsed =
|
|
Math.max(
|
|
0,
|
|
(now - clocks.lastTick) / 1000
|
|
);
|
|
|
|
if (!elapsed) {
|
|
return;
|
|
}
|
|
|
|
clocks.lastTick = now;
|
|
|
|
const color =
|
|
clocks.turn;
|
|
|
|
const key =
|
|
color === "w"
|
|
? "white"
|
|
: "black";
|
|
|
|
clocks[key] =
|
|
Math.max(
|
|
0,
|
|
clocks[key] - elapsed
|
|
);
|
|
|
|
if (clocks[key] <= 0) {
|
|
clocks.running = false;
|
|
|
|
appState.gameOver = true;
|
|
|
|
appState.gameOverMessage =
|
|
color === "w"
|
|
? "Black wins on time."
|
|
: "White wins on time.";
|
|
|
|
stopAiClock();
|
|
|
|
showToast(
|
|
appState.gameOverMessage,
|
|
"error"
|
|
);
|
|
|
|
render();
|
|
}
|
|
}
|
|
|
|
|
|
function updateAiClockAfterMove() {
|
|
const clocks =
|
|
appState.localClocks;
|
|
|
|
if (!clocks.running) {
|
|
return;
|
|
}
|
|
|
|
updateAiClock();
|
|
|
|
if (appState.gameOver) {
|
|
return;
|
|
}
|
|
|
|
const previousTurn =
|
|
clocks.turn;
|
|
|
|
const key =
|
|
previousTurn === "w"
|
|
? "white"
|
|
: "black";
|
|
|
|
clocks[key] +=
|
|
getIncrementForTimeControl(
|
|
appState.aiTimeControl
|
|
);
|
|
|
|
clocks.turn =
|
|
appState.engine.state.turn;
|
|
|
|
clocks.lastTick =
|
|
Date.now();
|
|
}
|
|
|
|
|
|
/* =========================================================
|
|
LOCAL GAME
|
|
========================================================= */
|
|
|
|
function resetLocalGame() {
|
|
stopAiMoveTimer();
|
|
stopAiClock();
|
|
|
|
closePromotionDialog();
|
|
|
|
appState.engine =
|
|
new ChessEngine();
|
|
|
|
appState.partySnapshot = null;
|
|
|
|
appState.orientation =
|
|
appState.playerColor;
|
|
|
|
appState.gameOver = false;
|
|
appState.gameOverMessage = null;
|
|
|
|
startAiClock();
|
|
scheduleAiTurn();
|
|
}
|
|
|
|
|
|
/* =========================================================
|
|
API
|
|
========================================================= */
|
|
|
|
async function postJson(url, body) {
|
|
try {
|
|
const response =
|
|
await fetch(
|
|
url,
|
|
{
|
|
method: "POST",
|
|
|
|
headers: {
|
|
"Content-Type":
|
|
"application/json",
|
|
},
|
|
|
|
body:
|
|
JSON.stringify(body),
|
|
}
|
|
);
|
|
|
|
const contentType =
|
|
response.headers.get(
|
|
"content-type"
|
|
) || "";
|
|
|
|
if (
|
|
!contentType.includes(
|
|
"application/json"
|
|
)
|
|
) {
|
|
return {
|
|
ok: false,
|
|
|
|
error:
|
|
`Server returned ${response.status} instead of JSON.`,
|
|
};
|
|
}
|
|
|
|
return await response.json();
|
|
} catch (error) {
|
|
return {
|
|
ok: false,
|
|
|
|
error:
|
|
error instanceof Error
|
|
? error.message
|
|
: "Network error.",
|
|
};
|
|
}
|
|
}
|
|
|
|
|
|
/* =========================================================
|
|
PARTY STATE
|
|
========================================================= */
|
|
|
|
function hydratePartyState(
|
|
partyPayload,
|
|
announce = true
|
|
) {
|
|
if (!partyPayload) {
|
|
return;
|
|
}
|
|
|
|
const previousCode =
|
|
appState.party?.code;
|
|
|
|
const previousClientId =
|
|
appState.party?.clientId;
|
|
|
|
appState.party = {
|
|
code:
|
|
partyPayload.code,
|
|
|
|
clientId:
|
|
partyPayload.you?.id ||
|
|
previousClientId ||
|
|
null,
|
|
|
|
role:
|
|
partyPayload.you?.role ||
|
|
appState.party?.role ||
|
|
"spectator",
|
|
};
|
|
|
|
appState.partySnapshot =
|
|
partyPayload;
|
|
|
|
appState.engine =
|
|
new ChessEngine(
|
|
partyPayload.game
|
|
);
|
|
|
|
appState.gameOver =
|
|
Boolean(
|
|
partyPayload.gameOverReason
|
|
);
|
|
|
|
appState.gameOverMessage =
|
|
appState.gameOver
|
|
? getGameOverMessage()
|
|
: null;
|
|
|
|
if (
|
|
appState.party.role === "white"
|
|
) {
|
|
appState.orientation = "w";
|
|
} else if (
|
|
appState.party.role === "black"
|
|
) {
|
|
appState.orientation = "b";
|
|
}
|
|
|
|
setMode("party");
|
|
|
|
updatePartyUrl(
|
|
partyPayload.code
|
|
);
|
|
|
|
render();
|
|
|
|
if (
|
|
announce &&
|
|
partyPayload.gameOverReason
|
|
) {
|
|
showToast(
|
|
getGameOverMessage(),
|
|
"success"
|
|
);
|
|
}
|
|
|
|
if (
|
|
previousCode &&
|
|
previousCode !== partyPayload.code
|
|
) {
|
|
console.info(
|
|
"Party changed:",
|
|
previousCode,
|
|
"→",
|
|
partyPayload.code
|
|
);
|
|
}
|
|
}
|
|
|
|
|
|
/* =========================================================
|
|
PARTY SSE
|
|
========================================================= */
|
|
|
|
function connectPartyStream() {
|
|
if (!appState.party) {
|
|
return;
|
|
}
|
|
|
|
closePartyStream();
|
|
|
|
const {
|
|
code,
|
|
clientId,
|
|
} = appState.party;
|
|
|
|
const url =
|
|
`/api/party/events?partyCode=${
|
|
encodeURIComponent(code)
|
|
}&clientId=${
|
|
encodeURIComponent(clientId)
|
|
}`;
|
|
|
|
const stream =
|
|
new EventSource(url);
|
|
|
|
stream.addEventListener(
|
|
"party",
|
|
event => {
|
|
try {
|
|
const party =
|
|
JSON.parse(
|
|
event.data
|
|
);
|
|
|
|
hydratePartyState(
|
|
party,
|
|
false
|
|
);
|
|
} catch (error) {
|
|
console.error(
|
|
"Party SSE error:",
|
|
error
|
|
);
|
|
}
|
|
}
|
|
);
|
|
|
|
stream.onerror = () => {
|
|
if (
|
|
appState.mode === "party" &&
|
|
elements.connectionBadge
|
|
) {
|
|
elements.connectionBadge.textContent =
|
|
`Party ${code} · reconnecting`;
|
|
}
|
|
};
|
|
|
|
appState.partyStream = stream;
|
|
}
|
|
|
|
|
|
function closePartyStream() {
|
|
if (appState.partyStream) {
|
|
appState.partyStream.close();
|
|
appState.partyStream = null;
|
|
}
|
|
}
|
|
|
|
|
|
/* =========================================================
|
|
CREATE PARTY
|
|
========================================================= */
|
|
|
|
async function createParty() {
|
|
if (appState.party) {
|
|
await leaveParty(true);
|
|
}
|
|
|
|
const timeControl =
|
|
elements.partyTimeControl?.value ||
|
|
"10+0";
|
|
|
|
const response =
|
|
await postJson(
|
|
"/api/party/create",
|
|
{
|
|
name:
|
|
appState.playerName,
|
|
|
|
timeControl,
|
|
}
|
|
);
|
|
|
|
if (!response.ok) {
|
|
showToast(
|
|
response.error ||
|
|
"Unable to create party.",
|
|
"error"
|
|
);
|
|
|
|
return;
|
|
}
|
|
|
|
setStoredPartyId(
|
|
response.party.code,
|
|
response.clientId
|
|
);
|
|
|
|
hydratePartyState(
|
|
response.party,
|
|
false
|
|
);
|
|
|
|
connectPartyStream();
|
|
|
|
showToast(
|
|
`Party ${response.party.code} created.`,
|
|
"success"
|
|
);
|
|
}
|
|
|
|
|
|
/* =========================================================
|
|
JOIN PARTY
|
|
========================================================= */
|
|
|
|
async function joinParty(code) {
|
|
const normalizedCode =
|
|
String(code || "")
|
|
.trim()
|
|
.toUpperCase();
|
|
|
|
if (!normalizedCode) {
|
|
showToast(
|
|
"Enter a party code first.",
|
|
"error"
|
|
);
|
|
|
|
return;
|
|
}
|
|
|
|
if (
|
|
appState.party?.code ===
|
|
normalizedCode
|
|
) {
|
|
showToast(
|
|
`You are already in party ${normalizedCode}.`
|
|
);
|
|
|
|
return;
|
|
}
|
|
|
|
if (appState.party) {
|
|
await leaveParty(true);
|
|
}
|
|
|
|
const storedIds =
|
|
getStoredPartyIds();
|
|
|
|
const response =
|
|
await postJson(
|
|
"/api/party/join",
|
|
{
|
|
name:
|
|
appState.playerName,
|
|
|
|
partyCode:
|
|
normalizedCode,
|
|
|
|
clientId:
|
|
storedIds[normalizedCode] ||
|
|
null,
|
|
}
|
|
);
|
|
|
|
if (!response.ok) {
|
|
showToast(
|
|
response.error ||
|
|
"Unable to join party.",
|
|
"error"
|
|
);
|
|
|
|
return;
|
|
}
|
|
|
|
setStoredPartyId(
|
|
response.party.code,
|
|
response.clientId
|
|
);
|
|
|
|
hydratePartyState(
|
|
response.party,
|
|
false
|
|
);
|
|
|
|
connectPartyStream();
|
|
|
|
showToast(
|
|
`Joined party ${response.party.code}.`,
|
|
"success"
|
|
);
|
|
}
|
|
|
|
|
|
/* =========================================================
|
|
LEAVE PARTY
|
|
========================================================= */
|
|
|
|
async function leaveParty(
|
|
silent = false
|
|
) {
|
|
if (!appState.party) {
|
|
return;
|
|
}
|
|
|
|
const party =
|
|
appState.party;
|
|
|
|
closePartyStream();
|
|
|
|
await postJson(
|
|
"/api/party/leave",
|
|
{
|
|
partyCode:
|
|
party.code,
|
|
|
|
clientId:
|
|
party.clientId,
|
|
}
|
|
);
|
|
|
|
removeStoredPartyId(
|
|
party.code
|
|
);
|
|
|
|
appState.party = null;
|
|
appState.partySnapshot = null;
|
|
|
|
updatePartyUrl(null);
|
|
|
|
setMode("ai");
|
|
|
|
resetLocalGame();
|
|
|
|
if (!silent) {
|
|
showToast(
|
|
"Left the party room."
|
|
);
|
|
}
|
|
}
|
|
|
|
|
|
/* =========================================================
|
|
INVITE
|
|
========================================================= */
|
|
|
|
async function copyInviteLink() {
|
|
if (!appState.party) {
|
|
return;
|
|
}
|
|
|
|
const inviteUrl =
|
|
`${window.location.origin}` +
|
|
`${window.location.pathname}` +
|
|
`?party=${encodeURIComponent(
|
|
appState.party.code
|
|
)}`;
|
|
|
|
try {
|
|
await navigator.clipboard.writeText(
|
|
inviteUrl
|
|
);
|
|
|
|
showToast(
|
|
"Invite link copied.",
|
|
"success"
|
|
);
|
|
} catch {
|
|
showToast(
|
|
`Party code: ${appState.party.code}`
|
|
);
|
|
}
|
|
}
|
|
|
|
|
|
/* =========================================================
|
|
STORAGE
|
|
========================================================= */
|
|
|
|
function getStoredPartyIds() {
|
|
try {
|
|
return JSON.parse(
|
|
localStorage.getItem(
|
|
STORAGE_KEYS.partyIds
|
|
) || "{}"
|
|
);
|
|
} catch {
|
|
return {};
|
|
}
|
|
}
|
|
|
|
|
|
function setStoredPartyId(
|
|
code,
|
|
clientId
|
|
) {
|
|
if (!code || !clientId) {
|
|
return;
|
|
}
|
|
|
|
const ids =
|
|
getStoredPartyIds();
|
|
|
|
ids[code] = clientId;
|
|
|
|
localStorage.setItem(
|
|
STORAGE_KEYS.partyIds,
|
|
JSON.stringify(ids)
|
|
);
|
|
}
|
|
|
|
|
|
function removeStoredPartyId(code) {
|
|
if (!code) {
|
|
return;
|
|
}
|
|
|
|
const ids =
|
|
getStoredPartyIds();
|
|
|
|
delete ids[code];
|
|
|
|
localStorage.setItem(
|
|
STORAGE_KEYS.partyIds,
|
|
JSON.stringify(ids)
|
|
);
|
|
}
|
|
|
|
|
|
/* =========================================================
|
|
EVENTS
|
|
========================================================= */
|
|
|
|
function bindEvents() {
|
|
elements.playerNameInput?.addEventListener(
|
|
"input",
|
|
event => {
|
|
appState.playerName =
|
|
event.target.value.trim() ||
|
|
"Guest";
|
|
|
|
localStorage.setItem(
|
|
STORAGE_KEYS.name,
|
|
appState.playerName
|
|
);
|
|
|
|
renderRoster();
|
|
}
|
|
);
|
|
|
|
|
|
elements.aiLevelSelect?.addEventListener(
|
|
"change",
|
|
event => {
|
|
appState.aiLevel =
|
|
event.target.value;
|
|
|
|
render();
|
|
}
|
|
);
|
|
|
|
|
|
elements.playerColorSelect?.addEventListener(
|
|
"change",
|
|
event => {
|
|
appState.playerColor =
|
|
event.target.value;
|
|
|
|
if (appState.mode === "ai") {
|
|
resetLocalGame();
|
|
}
|
|
}
|
|
);
|
|
|
|
|
|
elements.aiTimeControl?.addEventListener(
|
|
"change",
|
|
event => {
|
|
appState.aiTimeControl =
|
|
event.target.value;
|
|
|
|
if (appState.mode === "ai") {
|
|
resetLocalGame();
|
|
}
|
|
}
|
|
);
|
|
|
|
|
|
elements.modeAiButton?.addEventListener(
|
|
"click",
|
|
async () => {
|
|
if (appState.party) {
|
|
await leaveParty(true);
|
|
return;
|
|
}
|
|
|
|
setMode("ai");
|
|
resetLocalGame();
|
|
}
|
|
);
|
|
|
|
|
|
elements.modePartyButton?.addEventListener(
|
|
"click",
|
|
() => {
|
|
setMode("party");
|
|
render();
|
|
}
|
|
);
|
|
|
|
|
|
elements.startAiButton?.addEventListener(
|
|
"click",
|
|
async () => {
|
|
if (appState.party) {
|
|
await leaveParty(true);
|
|
}
|
|
|
|
setMode("ai");
|
|
resetLocalGame();
|
|
|
|
showToast(
|
|
"New AI game ready.",
|
|
"success"
|
|
);
|
|
}
|
|
);
|
|
|
|
|
|
elements.createPartyButton?.addEventListener(
|
|
"click",
|
|
createParty
|
|
);
|
|
|
|
|
|
elements.joinPartyButton?.addEventListener(
|
|
"click",
|
|
() =>
|
|
joinParty(
|
|
elements.partyCodeInput?.value
|
|
)
|
|
);
|
|
|
|
|
|
elements.leavePartyButton?.addEventListener(
|
|
"click",
|
|
() => leaveParty(false)
|
|
);
|
|
|
|
|
|
elements.copyInviteButton?.addEventListener(
|
|
"click",
|
|
copyInviteLink
|
|
);
|
|
|
|
|
|
elements.flipBoardButton?.addEventListener(
|
|
"click",
|
|
() => {
|
|
appState.orientation =
|
|
appState.orientation === "w"
|
|
? "b"
|
|
: "w";
|
|
|
|
renderBoard();
|
|
}
|
|
);
|
|
|
|
|
|
elements.partyCodeInput?.addEventListener(
|
|
"input",
|
|
event => {
|
|
event.target.value =
|
|
event.target.value
|
|
.toUpperCase()
|
|
.replace(
|
|
/[^A-Z0-9]/g,
|
|
""
|
|
);
|
|
}
|
|
);
|
|
|
|
|
|
elements.partyCodeInput?.addEventListener(
|
|
"keydown",
|
|
event => {
|
|
if (event.key !== "Enter") {
|
|
return;
|
|
}
|
|
|
|
event.preventDefault();
|
|
|
|
joinParty(
|
|
elements.partyCodeInput.value
|
|
);
|
|
}
|
|
);
|
|
|
|
|
|
elements.promotionDialog?.addEventListener(
|
|
"click",
|
|
event => {
|
|
if (
|
|
event.target ===
|
|
elements.promotionDialog
|
|
) {
|
|
closePromotionDialog();
|
|
}
|
|
}
|
|
);
|
|
|
|
|
|
window.addEventListener(
|
|
"beforeunload",
|
|
() => {
|
|
stopAiMoveTimer();
|
|
stopAiClock();
|
|
closePartyStream();
|
|
}
|
|
);
|
|
}
|
|
|
|
|
|
/* =========================================================
|
|
INIT
|
|
========================================================= */
|
|
|
|
async function init() {
|
|
bindEvents();
|
|
|
|
const savedName =
|
|
localStorage.getItem(
|
|
STORAGE_KEYS.name
|
|
);
|
|
|
|
if (savedName) {
|
|
appState.playerName =
|
|
savedName;
|
|
|
|
if (elements.playerNameInput) {
|
|
elements.playerNameInput.value =
|
|
savedName;
|
|
}
|
|
}
|
|
|
|
const params =
|
|
new URLSearchParams(
|
|
window.location.search
|
|
);
|
|
|
|
const partyCode =
|
|
params.get("party");
|
|
|
|
if (partyCode) {
|
|
setMode("party");
|
|
|
|
if (elements.partyCodeInput) {
|
|
elements.partyCodeInput.value =
|
|
partyCode.toUpperCase();
|
|
}
|
|
|
|
await joinParty(partyCode);
|
|
return;
|
|
}
|
|
|
|
setMode("ai");
|
|
resetLocalGame();
|
|
}
|
|
|
|
|
|
/* =========================================================
|
|
START
|
|
========================================================= */
|
|
|
|
init();
|
|
|
|
|