2853 lines
110 KiB
HTML
2853 lines
110 KiB
HTML
<!doctype html>
|
|
<html>
|
|
<head>
|
|
<meta charset="utf-8" />
|
|
<title>ChessNut - Partie</title>
|
|
<link rel="stylesheet" href="/styles/style.css" />
|
|
<style>
|
|
/* veil overlay for brouillard effect */
|
|
.square {
|
|
position: relative;
|
|
}
|
|
/* médusa (petrified) visual */
|
|
.square.petrified::after {
|
|
content: "";
|
|
position: absolute;
|
|
left: 0;
|
|
top: 0;
|
|
right: 0;
|
|
bottom: 0;
|
|
background: rgba(120, 120, 160, 0.35);
|
|
pointer-events: none;
|
|
z-index: 60;
|
|
}
|
|
.square .veil {
|
|
position: absolute;
|
|
left: 0;
|
|
top: 0;
|
|
right: 0;
|
|
bottom: 0;
|
|
background: rgba(255, 255, 255, 0.95);
|
|
pointer-events: auto; /* block interactions so veiled squares are not clickable */
|
|
z-index: 50;
|
|
cursor: default;
|
|
}
|
|
/* activity feed for user-facing logs (card plays) */
|
|
#activityFeed {
|
|
max-height: 140px;
|
|
overflow: auto;
|
|
padding: 8px;
|
|
background: #fff;
|
|
border: 1px solid #eee;
|
|
border-radius: 6px;
|
|
font-size: 14px;
|
|
color: #222;
|
|
}
|
|
#log {
|
|
display: none;
|
|
}
|
|
/* effects feed for user-facing logs (card plays) */
|
|
#effectsFeed {
|
|
max-height: 140px;
|
|
overflow: auto;
|
|
padding: 8px;
|
|
background: #fff;
|
|
border: 1px solid #eee;
|
|
border-radius: 6px;
|
|
font-size: 14px;
|
|
color: #222;
|
|
}
|
|
#log {
|
|
display: none;
|
|
}
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<!-- Game over modal (hidden by default). Shown when server emits game:over -->
|
|
<div
|
|
id="gameOverModal"
|
|
style="
|
|
position: fixed;
|
|
left: 0;
|
|
top: 0;
|
|
right: 0;
|
|
bottom: 0;
|
|
display: none;
|
|
align-items: center;
|
|
justify-content: center;
|
|
background: rgba(0, 0, 0, 0.45);
|
|
z-index: 9999;
|
|
"
|
|
>
|
|
<div
|
|
style="
|
|
background: #fff;
|
|
padding: 24px;
|
|
border-radius: 10px;
|
|
min-width: 260px;
|
|
max-width: 90%;
|
|
text-align: center;
|
|
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.25);
|
|
"
|
|
>
|
|
<h2 id="gameOverTitle" style="margin-top: 0; margin-bottom: 8px">
|
|
Partie terminée
|
|
</h2>
|
|
<div id="gameOverMessage" style="margin-bottom: 12px">
|
|
Chargement...
|
|
</div>
|
|
<div style="display: flex; gap: 8px; justify-content: center">
|
|
<button
|
|
id="gameOverClose"
|
|
style="
|
|
background: #efefef;
|
|
border: 0;
|
|
padding: 8px 12px;
|
|
border-radius: 6px;
|
|
cursor: pointer;
|
|
"
|
|
>
|
|
Rester
|
|
</button>
|
|
<button
|
|
id="gameOverReload"
|
|
style="
|
|
background: linear-gradient(90deg, #ff7a59, #ffb199);
|
|
border: 0;
|
|
color: #fff;
|
|
padding: 8px 12px;
|
|
border-radius: 6px;
|
|
cursor: pointer;
|
|
"
|
|
>
|
|
Quitter
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<h2>ChessNut — Partie</h2>
|
|
<div id="turnBanner" style="margin-bottom: 8px"></div>
|
|
<div>
|
|
Room: <strong id="roomId">-</strong> • Vous jouez:
|
|
<strong id="myColor">-</strong>
|
|
<div class="layout-row">
|
|
<div>
|
|
<div id="board"></div>
|
|
</div>
|
|
<aside style="min-width: 320px; margin-left: 18px">
|
|
<section class="card" style="margin-top: 12px">
|
|
<div><strong>Outils / Logs</strong></div>
|
|
<div style="margin-top: 8px"></div>
|
|
<div
|
|
style="
|
|
margin-top: 8px;
|
|
display: flex;
|
|
gap: 8px;
|
|
align-items: center;
|
|
"
|
|
>
|
|
<pre
|
|
id="log"
|
|
style="margin-top: 0px; height: 140px; overflow: auto; flex: 1"
|
|
>
|
|
logs...</pre
|
|
>
|
|
</div>
|
|
<div style="margin-top: 8px; display:flex; gap:8px; align-items:flex-start;">
|
|
<div style="flex:1; min-width:0">
|
|
<div><strong>Activité</strong></div>
|
|
<div id="activityFeed" style="margin-top: 8px">Aucune activité pour l'instant.</div>
|
|
</div>
|
|
<div style="width:200px; min-width:160px">
|
|
<div><strong>Effets actifs</strong></div>
|
|
<div id="effectsFeed" style="margin-top: 8px">Aucun effet actif pour l'instant.</div>
|
|
</div>
|
|
</div>
|
|
</section>
|
|
<div
|
|
style="
|
|
margin-top: 10px;
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 12px;
|
|
"
|
|
>
|
|
<div
|
|
id="hands"
|
|
style="
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 8px;
|
|
flex: 1;
|
|
margin-left: 6px;
|
|
"
|
|
></div>
|
|
</div>
|
|
</aside>
|
|
</div>
|
|
|
|
<script src="https://cdn.socket.io/4.7.2/socket.io.min.js"></script>
|
|
<script>
|
|
function qs(name) {
|
|
const url = new URL(window.location.href);
|
|
return url.searchParams.get(name);
|
|
}
|
|
const roomId = qs("roomId");
|
|
const playerId = qs("playerId") || undefined;
|
|
const roomIdEl = document.getElementById("roomId");
|
|
const playerIdEl = document.getElementById("playerId");
|
|
if (roomIdEl) roomIdEl.textContent = roomId || "-";
|
|
if (playerIdEl) playerIdEl.textContent = playerId || "-";
|
|
|
|
const logEl = document.getElementById("log");
|
|
const playersEl = document.getElementById("players");
|
|
const effectsFeedEl = document.getElementById("effectsFeed");
|
|
// simple client-side registry of active effects we know about (keyed by effect.id)
|
|
const activeEffects = {};
|
|
|
|
function refreshEffectsFeed() {
|
|
try {
|
|
const dest = document.getElementById("effectsFeed");
|
|
if (!dest) return;
|
|
const effects = Object.values(activeEffects).filter(
|
|
(e) => e && (e.type === "brouillard" || e.type === "medusa" || e.type === "empathie"),
|
|
);
|
|
if (!effects || !effects.length) {
|
|
dest.textContent = "Aucun effet actif pour l'instant.";
|
|
return;
|
|
}
|
|
dest.innerHTML = "";
|
|
effects.forEach((e) => {
|
|
try {
|
|
const row = document.createElement("div");
|
|
row.style.padding = "4px 6px";
|
|
row.style.borderBottom = "1px solid #f3f3f3";
|
|
const rem = typeof e.remainingTurns !== "undefined" ? e.remainingTurns : "?";
|
|
row.textContent = `${e.title} — ${rem+1} tour(s) restant(s)`;
|
|
dest.appendChild(row);
|
|
} catch (_) {}
|
|
});
|
|
} catch (_) {}
|
|
}
|
|
// Apply a local visual tick when any player plays a card or moves.
|
|
// This updates the displayed remainingTurns immediately for UX, but
|
|
// does not replace server authority — incoming card:effect:updated or
|
|
// room:update will resync the true values.
|
|
function applyLocalPlayTick() {
|
|
try {
|
|
let changed = false;
|
|
Object.keys(activeEffects).forEach((id) => {
|
|
try {
|
|
const e = activeEffects[id];
|
|
if (e && typeof e.remainingTurns === "number") {
|
|
// decrement visually by one (but never below zero)
|
|
e.remainingTurns = Math.max(0, (e.remainingTurns || 0) - 1);
|
|
changed = true;
|
|
}
|
|
} catch (_) {}
|
|
});
|
|
if (changed) refreshEffectsFeed();
|
|
} catch (_) {}
|
|
}
|
|
const boardEl = document.getElementById("board");
|
|
const myColorEl = document.getElementById("myColor");
|
|
let socket = null;
|
|
let myColor = null;
|
|
let playerColors = {};
|
|
let selectedSquare = null;
|
|
let selectedSquareEl = null;
|
|
let myPlayerId = playerId || null;
|
|
const remoteSelections = {};
|
|
let isSpectating = false;
|
|
const remoteMoves = {};
|
|
let currentBoardState = null;
|
|
let myMines = [];
|
|
let currentVeiledSquares = [];
|
|
let pendingCard = null;
|
|
|
|
function log(...args) {
|
|
if (logEl) {
|
|
try {
|
|
logEl.textContent +=
|
|
"\n" +
|
|
args
|
|
.map((a) => (typeof a === "object" ? JSON.stringify(a) : a))
|
|
.join(" ");
|
|
logEl.scrollTop = logEl.scrollHeight;
|
|
} catch (e) {
|
|
console.log(...args);
|
|
}
|
|
} else {
|
|
console.log(...args);
|
|
}
|
|
}
|
|
|
|
function appendActivity(message) {
|
|
try {
|
|
const feed = document.getElementById("activityFeed");
|
|
if (!feed) return;
|
|
const line = document.createElement("div");
|
|
line.textContent = message;
|
|
if (
|
|
feed.textContent &&
|
|
feed.textContent.indexOf("Aucune activité") !== -1
|
|
) {
|
|
feed.textContent = "";
|
|
}
|
|
feed.insertBefore(line, feed.firstChild);
|
|
while (feed.children.length > 30) feed.removeChild(feed.lastChild);
|
|
} catch (_) {}
|
|
}
|
|
|
|
function showToast(message, opts) {
|
|
try {
|
|
opts = opts || {};
|
|
const id = "cn-toast";
|
|
const wrap = document.createElement("div");
|
|
wrap.className = "cn-toast";
|
|
wrap.textContent = message || "";
|
|
wrap.style.position = "fixed";
|
|
wrap.style.left = "50%";
|
|
wrap.style.transform = "translateX(-50%)";
|
|
wrap.style.top = opts.top || "18px";
|
|
wrap.style.background = opts.background || "rgba(0,0,0,0.8)";
|
|
wrap.style.color = opts.color || "#fff";
|
|
wrap.style.padding = "8px 12px";
|
|
wrap.style.borderRadius = "6px";
|
|
wrap.style.zIndex = 99999;
|
|
wrap.style.fontSize = "14px";
|
|
wrap.style.boxShadow = "0 6px 18px rgba(0,0,0,0.25)";
|
|
document.body.appendChild(wrap);
|
|
setTimeout(() => {
|
|
try {
|
|
wrap.style.transition = "opacity 300ms ease";
|
|
wrap.style.opacity = "0";
|
|
setTimeout(() => {
|
|
try {
|
|
wrap.remove();
|
|
} catch (_) {}
|
|
}, 320);
|
|
} catch (_) {}
|
|
}, opts.duration || 3500);
|
|
} catch (_) {}
|
|
}
|
|
|
|
function emitCardPlay(payload, cb) {
|
|
try {
|
|
if (!socket) return cb && cb({ error: "no_socket" });
|
|
let acked = false;
|
|
const timer = setTimeout(() => {
|
|
if (!acked) {
|
|
try {
|
|
socket.emit("room:refresh", { roomId }, () => {});
|
|
} catch (_) {}
|
|
}
|
|
}, 1500);
|
|
socket.emit("card:play", payload, (resp) => {
|
|
acked = true;
|
|
try {
|
|
clearTimeout(timer);
|
|
} catch (_) {}
|
|
try {
|
|
socket.emit("room:refresh", { roomId }, () => {});
|
|
} catch (_) {}
|
|
if (cb) cb(resp);
|
|
});
|
|
} catch (e) {
|
|
if (cb) cb({ error: "emit_failed" });
|
|
}
|
|
}
|
|
|
|
function playShuffleAnimation() {
|
|
if (!boardEl) return;
|
|
try {
|
|
boardEl.classList.add("shuffle-play");
|
|
setTimeout(() => {
|
|
try {
|
|
boardEl.classList.remove("shuffle-play");
|
|
} catch (_) {}
|
|
}, 700);
|
|
} catch (_) {}
|
|
}
|
|
|
|
function cardRequiresTarget(cardId) {
|
|
if (!cardId) return null;
|
|
let id = String(cardId).toLowerCase();
|
|
try {
|
|
id = id.normalize("NFD").replace(/\p{Diacritic}/gu, "");
|
|
} catch (e) {
|
|
id = id.replace(/[^\x00-\x7F]/g, "");
|
|
}
|
|
if (id.indexOf("mine") !== -1) return "empty";
|
|
if (id.indexOf("revol") !== -1) return null;
|
|
if (id.indexOf("sniper") !== -1) return "owned";
|
|
if (id.indexOf("doppel") !== -1) return "owned";
|
|
if (id.indexOf("toucher") !== -1) return "enemy";
|
|
if (id.indexOf("medusa") !== -1) return "enemy";
|
|
if (id.indexOf("parrure") !== -1) return "enemy_queen";
|
|
if (id.indexOf("tout") !== -1) return "piece";
|
|
if (id.indexOf("kamikaz") !== -1) return "owned";
|
|
if (id.indexOf("invisible") !== -1) return "owned";
|
|
if (id.indexOf("rebond") !== -1) return "owned";
|
|
if (id.indexOf("adoub") !== -1) return "owned";
|
|
if (id.indexOf("folie") !== -1) return "owned";
|
|
if (id.indexOf("fortification") !== -1) return "owned";
|
|
if (id.indexOf("promotion") !== -1) return "owned_pawn";
|
|
if (id.indexOf("vol_carte") !== -1) return "player";
|
|
if (id.indexOf("vole_piece") !== -1) return "enemy";
|
|
if (id.indexOf("coincoin") !== -1) return "owned";
|
|
if (id.indexOf("teleportation") !== -1) return "owned";
|
|
if (id.indexOf("inversion") !== -1) return "own_then_enemy";
|
|
return null;
|
|
}
|
|
|
|
if (!roomId) {
|
|
showToast("roomId manquant dans l'URL", {
|
|
background: "rgba(200,60,60,0.95)",
|
|
});
|
|
}
|
|
|
|
function connectAndJoin() {
|
|
socket = io();
|
|
socket.on("connect", () => {
|
|
log("socket connecté", socket.id);
|
|
});
|
|
|
|
socket.on("room:update", (data) => {
|
|
log("room:update", data);
|
|
// Always update local player color mapping and orientation even if the players list UI is not present
|
|
try {
|
|
(data.players || []).forEach((p) => {
|
|
try {
|
|
playerColors[p.id] = p.color;
|
|
} catch (_) {}
|
|
try {
|
|
if (p.id === myPlayerId) {
|
|
myColor = p.color;
|
|
if (myColorEl) myColorEl.textContent = myColor;
|
|
// ensure board orientation follows the (possibly changed) player color
|
|
try {
|
|
if (boardEl) {
|
|
if (myColor === "black")
|
|
boardEl.classList.add("flipped");
|
|
else boardEl.classList.remove("flipped");
|
|
}
|
|
} catch (_) {}
|
|
}
|
|
} catch (_) {}
|
|
});
|
|
} catch (_) {}
|
|
|
|
if (playersEl) {
|
|
playersEl.innerHTML = "";
|
|
(data.players || []).forEach((p) => {
|
|
const li = document.createElement("li");
|
|
li.textContent =
|
|
(p.color || "Joueur") +
|
|
(p.id === myPlayerId ? " (vous)" : "");
|
|
playersEl.appendChild(li);
|
|
});
|
|
}
|
|
if (data.size) {
|
|
buildGrid(data.size);
|
|
const side = Math.min(800, Math.max(320, data.size * 80));
|
|
boardEl.style.width = side + "px";
|
|
boardEl.style.height = side + "px";
|
|
}
|
|
try {
|
|
currentVeiledSquares = Array.isArray(data.veiledSquares)
|
|
? data.veiledSquares
|
|
: (data.boardState && data.boardState.veiledSquares) || [];
|
|
} catch (_) {
|
|
currentVeiledSquares = [];
|
|
}
|
|
if (data.boardState) {
|
|
renderBoardFromState(data.boardState);
|
|
updateTurnBanner(data.boardState);
|
|
// re-apply any active 'toucher' effects sent by the server (useful after refresh/reconnect)
|
|
try {
|
|
if (
|
|
Array.isArray(data.activeCardEffects) &&
|
|
data.activeCardEffects.length
|
|
) {
|
|
data.activeCardEffects.forEach((eff) => {
|
|
try {
|
|
if (eff && eff.type === "toucher") {
|
|
let targetSquare = eff.pieceSquare || null;
|
|
if (
|
|
!targetSquare &&
|
|
eff.pieceId &&
|
|
data.boardState &&
|
|
Array.isArray(data.boardState.pieces)
|
|
) {
|
|
const p = data.boardState.pieces.find(
|
|
(x) => x.id === eff.pieceId,
|
|
);
|
|
if (p && p.square) targetSquare = p.square;
|
|
}
|
|
if (targetSquare && boardEl) {
|
|
const el = boardEl.querySelector(
|
|
`.square[data-square="${targetSquare}"]`,
|
|
);
|
|
if (el) {
|
|
el.classList.add("targeted");
|
|
try {
|
|
el.dataset.toucherEffectId = eff.id || "";
|
|
} catch (_) {}
|
|
}
|
|
}
|
|
}
|
|
} catch (_) {}
|
|
});
|
|
}
|
|
} catch (_) {}
|
|
// restore last-move highlight from localStorage (persist across refresh)
|
|
try {
|
|
if (roomId) {
|
|
const key = `cn:lastMove:${roomId}`;
|
|
const raw = localStorage.getItem(key);
|
|
if (raw) {
|
|
const lm = JSON.parse(raw);
|
|
if (lm && lm.to && lm.from && lm.to !== lm.from) {
|
|
// apply visuals (renderBoardFromState cleared old highlights)
|
|
const dest = boardEl.querySelector(
|
|
`.square[data-square="${lm.to}"]`,
|
|
);
|
|
try {
|
|
let showDest = false;
|
|
if (
|
|
currentBoardState &&
|
|
Array.isArray(currentBoardState.pieces)
|
|
) {
|
|
showDest = currentBoardState.pieces.some(
|
|
(x) => x && x.square === lm.to,
|
|
);
|
|
}
|
|
if (showDest && dest) {
|
|
dest.classList.add("last-move-dest");
|
|
}
|
|
} catch (_) {}
|
|
const fromEl = boardEl.querySelector(
|
|
`.square[data-square="${lm.from}"]`,
|
|
);
|
|
try {
|
|
let showSource = false;
|
|
if (
|
|
currentBoardState &&
|
|
Array.isArray(currentBoardState.pieces)
|
|
) {
|
|
showSource = currentBoardState.pieces.some(
|
|
(x) => x && x.square === lm.from,
|
|
);
|
|
}
|
|
if (showSource && fromEl) {
|
|
fromEl.classList.add("last-move-source");
|
|
}
|
|
} catch (_) {}
|
|
}
|
|
}
|
|
}
|
|
} catch (e) {
|
|
/* ignore storage errors */
|
|
}
|
|
}
|
|
try {
|
|
if (data && data.status && data.status !== "finished") {
|
|
removeSpectatorQuitButton();
|
|
}
|
|
} catch (_) {}
|
|
// sync activeEffects from server-provided activeCardEffects (update remainingTurns and remove expired)
|
|
try {
|
|
if (Array.isArray(data.activeCardEffects)) {
|
|
const seen = new Set();
|
|
data.activeCardEffects.forEach((eff) => {
|
|
try {
|
|
if (eff && eff.type === "brouillard" && eff.id) {
|
|
activeEffects[eff.id] = eff;
|
|
seen.add(eff.id);
|
|
}
|
|
} catch (_) {}
|
|
});
|
|
// remove stale brouillard entries not present in server list
|
|
Object.keys(activeEffects).forEach((id) => {
|
|
try {
|
|
if (!seen.has(id) && activeEffects[id] && activeEffects[id].type === "brouillard") {
|
|
delete activeEffects[id];
|
|
}
|
|
} catch (_) {}
|
|
});
|
|
refreshEffectsFeed();
|
|
}
|
|
} catch (_) {}
|
|
try {
|
|
myMines = data.minesOwn || [];
|
|
} catch (e) {
|
|
myMines = [];
|
|
}
|
|
try {
|
|
renderMines();
|
|
} catch (e) {}
|
|
if (data.hands) {
|
|
const own =
|
|
data.hands && data.hands[myPlayerId]
|
|
? data.hands[myPlayerId]
|
|
: [];
|
|
const counts = {};
|
|
Object.entries(data.hands || {}).forEach(([pid, arr]) => {
|
|
counts[pid] = (arr && arr.length) || 0;
|
|
});
|
|
renderHands(own, counts);
|
|
} else if (data.handsOwn || data.handCounts) {
|
|
renderHands(data.handsOwn || [], data.handCounts || {});
|
|
}
|
|
try {
|
|
const drawBtn = document.getElementById("drawBtn");
|
|
if (drawBtn) {
|
|
const auto = !!data.autoDraw;
|
|
const board = data.boardState || null;
|
|
const myShort = myColor && myColor[0];
|
|
if (!auto && board && myShort && board.turn === myShort) {
|
|
const myHandLen =
|
|
(data.handsOwn && data.handsOwn.length) || 0;
|
|
drawBtn.style.display = "inline-block";
|
|
drawBtn.disabled = myHandLen >= 5;
|
|
} else {
|
|
drawBtn.style.display = "none";
|
|
}
|
|
}
|
|
} catch (_) {}
|
|
});
|
|
socket.on("game:over", (data) => {
|
|
try {
|
|
log("game:over", data);
|
|
if (data.boardState) {
|
|
renderBoardFromState(data.boardState);
|
|
updateTurnBanner(data.boardState);
|
|
}
|
|
try {
|
|
showGameOver(data);
|
|
} catch (_) {}
|
|
} catch (e) {
|
|
console.warn("game:over handler error", e);
|
|
}
|
|
});
|
|
socket.on("game:started", (data) => {
|
|
log("game:started", data);
|
|
if (data.boardState) {
|
|
renderBoardFromState(data.boardState);
|
|
updateTurnBanner(data.boardState);
|
|
}
|
|
});
|
|
socket.on("card:drawn", (data) => {
|
|
try {
|
|
log("recv card:drawn", data);
|
|
showCardDrawnNotification(data);
|
|
} catch (e) {
|
|
console.warn("card:drawn handler error", e);
|
|
}
|
|
});
|
|
socket.on("player:drew", (data) => {
|
|
try {
|
|
const pid = data && data.playerId;
|
|
if (!pid) return;
|
|
const actor =
|
|
pid === myPlayerId
|
|
? "Vous"
|
|
: "Adversaire";
|
|
appendActivity(`${actor} : a pioché une carte`);
|
|
try {
|
|
applyLocalPlayTick();
|
|
} catch (_) {}
|
|
} catch (_) {}
|
|
});
|
|
socket.on("card:played", (data) => {
|
|
try {
|
|
log("recv card:played", data);
|
|
const played = data && data.played ? data.played : data;
|
|
const cardObj = played && (played.card || played.cardObj || null);
|
|
const owner = played && played.playerId;
|
|
if (owner === myPlayerId) {
|
|
const row = document.querySelector(".cards-row");
|
|
if (row) {
|
|
const elems = Array.from(
|
|
row.querySelectorAll(".pokemon-card"),
|
|
);
|
|
elems.forEach((el) => {
|
|
const uuid = el.dataset.cardUuid || "";
|
|
const cid = el.dataset.cardId || "";
|
|
if (
|
|
(cardObj && cardObj.id && uuid && uuid === cardObj.id) ||
|
|
(cardObj &&
|
|
cardObj.cardId &&
|
|
cid &&
|
|
cid === cardObj.cardId)
|
|
) {
|
|
el.remove();
|
|
}
|
|
});
|
|
}
|
|
}
|
|
try {
|
|
const title =
|
|
(cardObj && (cardObj.title || cardObj.cardId)) ||
|
|
(played &&
|
|
played.card &&
|
|
(played.card.title || played.card.cardId)) ||
|
|
"Carte";
|
|
const actor =
|
|
owner === myPlayerId
|
|
? "Vous"
|
|
: "Adversaire";
|
|
const isHidden =
|
|
(cardObj && cardObj.hidden) ||
|
|
(played && played.card && played.card.hidden);
|
|
if (isHidden && owner !== myPlayerId) {
|
|
appendActivity(`${actor} : a joué une carte`);
|
|
} else {
|
|
appendActivity(`${actor} : a joué la carte "${title}"`);
|
|
}
|
|
try {
|
|
// Visual tick: decrement displayed counters immediately when any card is played
|
|
applyLocalPlayTick();
|
|
} catch (_) {}
|
|
} catch (_) {}
|
|
} catch (e) {
|
|
console.warn("card:played handler error", e);
|
|
}
|
|
});
|
|
socket.on("card:effect:applied", (data) => {
|
|
try {
|
|
const effect = data && data.effect ? data.effect : data;
|
|
if (!effect) return;
|
|
if (effect.type === "melange" || effect.type === "melange") {
|
|
try {
|
|
playShuffleAnimation();
|
|
} catch (_) {}
|
|
}
|
|
if (effect.playerId && effect.playerId === myPlayerId) {
|
|
showEffectNotification(effect);
|
|
} else if (
|
|
effect.type &&
|
|
(effect.type === "promotion" ||
|
|
effect.type === "steal" ||
|
|
effect.type === "mine")
|
|
) {
|
|
// public notable effects
|
|
showEffectNotification(effect);
|
|
}
|
|
|
|
// toucher + médusa
|
|
try {
|
|
if (effect.type === "toucher" || effect.type === "medusa_target") {
|
|
const sq = effect.pieceSquare || null;
|
|
let targetSquare = sq;
|
|
if (
|
|
!targetSquare &&
|
|
effect.pieceId &&
|
|
currentBoardState &&
|
|
Array.isArray(currentBoardState.pieces)
|
|
) {
|
|
const p = currentBoardState.pieces.find(
|
|
(x) => x.id === effect.pieceId,
|
|
);
|
|
if (p && p.square) targetSquare = p.square;
|
|
}
|
|
if (targetSquare && boardEl) {
|
|
const el = boardEl.querySelector(
|
|
`.square[data-square="${targetSquare}"]`,
|
|
);
|
|
if (el) {
|
|
el.classList.add("targeted");
|
|
try {
|
|
el.dataset.toucherEffectId = effect.id || "";
|
|
} catch (_) {}
|
|
}
|
|
}
|
|
}
|
|
} catch (e) {}
|
|
try {
|
|
if (effect.type === "medusa") {
|
|
const sq = effect.pieceSquare || null;
|
|
let targetSquare = sq;
|
|
if (
|
|
!targetSquare &&
|
|
effect.pieceId &&
|
|
currentBoardState &&
|
|
Array.isArray(currentBoardState.pieces)
|
|
) {
|
|
const p = currentBoardState.pieces.find(
|
|
(x) => x.id === effect.pieceId,
|
|
);
|
|
if (p && p.square) targetSquare = p.square;
|
|
}
|
|
if (targetSquare && boardEl) {
|
|
const el = boardEl.querySelector(
|
|
`.square[data-square="${targetSquare}"]`,
|
|
);
|
|
if (el) {
|
|
el.classList.add("petrified");
|
|
try {
|
|
el.dataset.medusaEffectId = effect.id || "";
|
|
} catch (_) {}
|
|
}
|
|
}
|
|
}
|
|
} catch (e) {}
|
|
// track brouillard / medusa / empathie effects for the sidebar feed
|
|
try {
|
|
if (
|
|
effect.type === "brouillard" ||
|
|
effect.type === "medusa" ||
|
|
effect.type === "empathie"
|
|
) {
|
|
activeEffects[effect.id] = effect;
|
|
refreshEffectsFeed();
|
|
}
|
|
} catch (_) {}
|
|
// If empathie was applied, explicitly request a room refresh as a reliable fallback
|
|
try {
|
|
if (effect.type === "empathie") {
|
|
log("recv empathie effect — requesting room refresh");
|
|
try {
|
|
socket.emit("room:refresh", { roomId }, (resp) => {
|
|
log("room:refresh ack", resp);
|
|
});
|
|
} catch (_) {
|
|
try {
|
|
// last-resort: ask server for update via general event
|
|
socket.emit("room:refresh", { roomId }, () => {});
|
|
} catch (_) {}
|
|
}
|
|
}
|
|
} catch (_) {}
|
|
} catch (e) {
|
|
console.warn("card:effect:applied handler error", e);
|
|
}
|
|
});
|
|
|
|
socket.on("card:effect:updated", (data) => {
|
|
try {
|
|
const eff = data && (data.effect || data);
|
|
if (!eff || !eff.id) return;
|
|
try {
|
|
// If the server reports remainingTurns <= 0, treat as expired and remove from feed
|
|
if (typeof eff.remainingTurns === "number" && eff.remainingTurns <= 0) {
|
|
try {
|
|
delete activeEffects[eff.id];
|
|
} catch (_) {}
|
|
try {
|
|
// also remove medusa visuals if present
|
|
if (eff.type === "medusa") {
|
|
const sq =
|
|
boardEl &&
|
|
boardEl.querySelector &&
|
|
boardEl.querySelector(
|
|
`.square[data-medusa-effect-id][data-medusa-effect-id="${eff.id}"]`,
|
|
);
|
|
if (sq) {
|
|
sq.classList.remove("petrified");
|
|
try {
|
|
delete sq.dataset.medusaEffectId;
|
|
} catch (_) {}
|
|
} else {
|
|
const all = boardEl.querySelectorAll(".square.petrified");
|
|
all.forEach((s) => {
|
|
try {
|
|
if (s.dataset && s.dataset.medusaEffectId === String(eff.id)) {
|
|
s.classList.remove("petrified");
|
|
delete s.dataset.medusaEffectId;
|
|
}
|
|
} catch (_) {}
|
|
});
|
|
}
|
|
}
|
|
} catch (_) {}
|
|
try {
|
|
refreshEffectsFeed();
|
|
} catch (_) {}
|
|
} else {
|
|
activeEffects[eff.id] = eff;
|
|
try {
|
|
refreshEffectsFeed();
|
|
} catch (_) {}
|
|
}
|
|
} catch (_) {}
|
|
} catch (e) {
|
|
console.warn("card:effect:updated handler error", e);
|
|
}
|
|
});
|
|
|
|
// toucher / medusa removals
|
|
socket.on("card:effect:removed", (data) => {
|
|
try {
|
|
const effId = data && (data.effectId || data.id);
|
|
const type = data && data.type;
|
|
if (!effId) return;
|
|
if (type === "toucher" || type === "medusa_target") {
|
|
// find any square with matching dataset.toucherEffectId and remove the visual
|
|
try {
|
|
const sq =
|
|
boardEl &&
|
|
boardEl.querySelector &&
|
|
boardEl.querySelector(
|
|
`.square[data-toucher-effect-id][data-toucher-effect-id="${effId}"]`,
|
|
);
|
|
if (sq) {
|
|
sq.classList.remove("last-move-source");
|
|
sq.classList.remove("targeted");
|
|
try {
|
|
delete sq.dataset.toucherEffectId;
|
|
} catch (_) {}
|
|
} else {
|
|
// fallback: remove any .last-move-source or .targeted if its dataset matches
|
|
const all = boardEl.querySelectorAll(
|
|
".square.last-move-source, .square.targeted",
|
|
);
|
|
all.forEach((s) => {
|
|
try {
|
|
if (
|
|
s.dataset &&
|
|
s.dataset.toucherEffectId === String(effId)
|
|
) {
|
|
s.classList.remove("last-move-source");
|
|
s.classList.remove("targeted");
|
|
delete s.dataset.toucherEffectId;
|
|
}
|
|
} catch (_) {}
|
|
});
|
|
}
|
|
} catch (_) {}
|
|
}
|
|
// remove any feed entry for effects that are removed
|
|
try {
|
|
if (
|
|
type === "brouillard" ||
|
|
type === "medusa" ||
|
|
type === "empathie"
|
|
) {
|
|
try {
|
|
delete activeEffects[effId];
|
|
} catch (_) {}
|
|
try {
|
|
refreshEffectsFeed();
|
|
} catch (_) {}
|
|
}
|
|
} catch (_) {}
|
|
if (type === "medusa") {
|
|
try {
|
|
const sq =
|
|
boardEl &&
|
|
boardEl.querySelector &&
|
|
boardEl.querySelector(
|
|
`.square[data-medusa-effect-id][data-medusa-effect-id="${effId}"]`,
|
|
);
|
|
if (sq) {
|
|
sq.classList.remove("petrified");
|
|
try {
|
|
delete sq.dataset.medusaEffectId;
|
|
} catch (_) {}
|
|
} else {
|
|
const all = boardEl.querySelectorAll(".square.petrified");
|
|
all.forEach((s) => {
|
|
try {
|
|
if (
|
|
s.dataset &&
|
|
s.dataset.medusaEffectId === String(effId)
|
|
) {
|
|
s.classList.remove("petrified");
|
|
delete s.dataset.medusaEffectId;
|
|
}
|
|
} catch (_) {}
|
|
});
|
|
}
|
|
} catch (_) {}
|
|
}
|
|
} catch (e) {
|
|
console.warn("card:effect:removed handler error", e);
|
|
}
|
|
});
|
|
// when the server sends the stolen card to the stealer (private)
|
|
socket.on("card:stolen", (data) => {
|
|
try {
|
|
log("recv card:stolen", data);
|
|
// show notification and a small highlight in hands
|
|
const handsEl = document.getElementById("hands");
|
|
if (handsEl) {
|
|
const note = document.createElement("div");
|
|
note.className = "card-draw-note";
|
|
note.textContent =
|
|
data && data.card
|
|
? `Vous avez volé : ${data.card.title || data.card.cardId || "une carte"}`
|
|
: "Vous avez volé une carte";
|
|
handsEl.prepend(note);
|
|
setTimeout(() => note.remove(), 6000);
|
|
}
|
|
} catch (e) {
|
|
console.warn("card:stolen handler error", e);
|
|
}
|
|
});
|
|
socket.on("card:lost", (data) => {
|
|
try {
|
|
log("recv card:lost", data);
|
|
showEffectNotification({ type: "card_lost" });
|
|
} catch (e) {}
|
|
});
|
|
// private event: your played card was blocked (e.g., by a totem)
|
|
socket.on("card:play_blocked:private", (data) => {
|
|
try {
|
|
log("recv card:play_blocked:private", data);
|
|
const msg =
|
|
(data && data.message) ||
|
|
"Votre carte a été annulée par un effet.";
|
|
// show a toast and also log in the side log
|
|
showToast(msg, { background: "rgba(200,60,60,0.95)" });
|
|
if (logEl) {
|
|
log("notification:", msg);
|
|
}
|
|
} catch (e) {
|
|
console.warn("card:play_blocked:private handler error", e);
|
|
}
|
|
});
|
|
// generic notification channel for targeted messages
|
|
socket.on("notification", (data) => {
|
|
try {
|
|
log("recv notification", data);
|
|
const msg =
|
|
data && data.message
|
|
? data.message
|
|
: (data && data.text) || "Notification";
|
|
showToast(msg, { background: "rgba(40,40,40,0.95)" });
|
|
} catch (e) {
|
|
console.warn("notification handler error", e);
|
|
}
|
|
});
|
|
// selection events (include moves) broadcasted by server
|
|
socket.on("game:select", (data) => {
|
|
try {
|
|
const pid = data && data.playerId;
|
|
const square = data && data.square;
|
|
const moves = data && data.moves;
|
|
log("recv game:select", {
|
|
pid,
|
|
square,
|
|
moves: Array.isArray(moves) ? moves.length : moves,
|
|
});
|
|
// cleared selection
|
|
if (!square) {
|
|
if (pid === myPlayerId) {
|
|
// our selection cleared (maybe by reconnection) - clear locally without re-emitting
|
|
clearSelection(false);
|
|
} else {
|
|
if (pid) {
|
|
clearRemoteSelection(pid);
|
|
}
|
|
}
|
|
return;
|
|
}
|
|
|
|
if (pid === myPlayerId) {
|
|
// re-apply local selection (don't emit) and show markers
|
|
setSelection(square, false);
|
|
if (Array.isArray(moves) && moves.length) showLegalMoves(moves);
|
|
else clearLegalMarkers();
|
|
} else {
|
|
// remote player's selection
|
|
if (pid) setRemoteSelection(pid, square);
|
|
if (Array.isArray(moves) && moves.length) {
|
|
remoteMoves[pid] = moves;
|
|
showRemoteLegalMoves(pid, moves);
|
|
} else {
|
|
remoteMoves[pid] = [];
|
|
if (pid) clearRemoteMoveMarkers(pid);
|
|
}
|
|
}
|
|
} catch (e) {
|
|
console.warn("game:select handler error", e);
|
|
}
|
|
});
|
|
|
|
socket.on("move:moved", (data) => {
|
|
try {
|
|
log("recv move:moved", data && { from: data.from, to: data.to });
|
|
const prevBoardState = currentBoardState;
|
|
let hasPieceFromBefore = false;
|
|
let hasPieceToBefore = false;
|
|
try {
|
|
const fromBeforeEl =
|
|
boardEl &&
|
|
boardEl.querySelector &&
|
|
boardEl.querySelector(
|
|
`.square[data-square="${data && data.from}"]`,
|
|
);
|
|
if (
|
|
fromBeforeEl &&
|
|
fromBeforeEl.querySelector &&
|
|
fromBeforeEl.querySelector("img.piece")
|
|
)
|
|
hasPieceFromBefore = true;
|
|
try {
|
|
// moving a piece is an in-game action — apply visual tick immediately
|
|
applyLocalPlayTick();
|
|
} catch (_) {}
|
|
} catch (_) {}
|
|
try {
|
|
const toBeforeEl =
|
|
boardEl &&
|
|
boardEl.querySelector &&
|
|
boardEl.querySelector(
|
|
`.square[data-square="${data && data.to}"]`,
|
|
);
|
|
if (
|
|
toBeforeEl &&
|
|
toBeforeEl.querySelector &&
|
|
toBeforeEl.querySelector("img.piece")
|
|
)
|
|
hasPieceToBefore = true;
|
|
} catch (_) {}
|
|
clearSelection(false);
|
|
try {
|
|
const prevHighlights = boardEl.querySelectorAll(
|
|
".last-move-dest, .last-move-source",
|
|
);
|
|
prevHighlights.forEach((el) => {
|
|
el.classList.remove("last-move-dest");
|
|
el.classList.remove("last-move-source");
|
|
});
|
|
} catch (_) {}
|
|
try {
|
|
const prevCircles =
|
|
boardEl.querySelectorAll(".last-move-circle");
|
|
prevCircles.forEach((c) => c.remove());
|
|
} catch (_) {}
|
|
|
|
if (data && data.boardState)
|
|
renderBoardFromState(data.boardState);
|
|
if (data && data.boardState) updateTurnBanner(data.boardState);
|
|
|
|
try {
|
|
if (data && data.from && data.to && data.from !== data.to) {
|
|
const dest = boardEl.querySelector(
|
|
`.square[data-square="${data.to}"]`,
|
|
);
|
|
const fromEl = boardEl.querySelector(
|
|
`.square[data-square="${data.from}"]`,
|
|
);
|
|
let showSource = false;
|
|
try {
|
|
if (
|
|
currentBoardState &&
|
|
Array.isArray(currentBoardState.pieces)
|
|
) {
|
|
showSource = currentBoardState.pieces.some(
|
|
(x) => x && x.square === data.from,
|
|
);
|
|
}
|
|
} catch (_) {
|
|
showSource = false;
|
|
}
|
|
if (!showSource) showSource = !!hasPieceFromBefore;
|
|
|
|
let showDest = false;
|
|
try {
|
|
if (
|
|
currentBoardState &&
|
|
Array.isArray(currentBoardState.pieces)
|
|
) {
|
|
showDest = currentBoardState.pieces.some(
|
|
(x) => x && x.square === data.to,
|
|
);
|
|
}
|
|
} catch (_) {
|
|
showDest = false;
|
|
}
|
|
if (!showDest) showDest = !!hasPieceToBefore;
|
|
|
|
try {
|
|
if (
|
|
!showDest &&
|
|
dest &&
|
|
dest.querySelector &&
|
|
dest.querySelector("img.piece")
|
|
)
|
|
showDest = true;
|
|
} catch (_) {}
|
|
try {
|
|
if (
|
|
!showSource &&
|
|
dest &&
|
|
dest.querySelector &&
|
|
dest.querySelector("img.piece")
|
|
)
|
|
showSource = true;
|
|
} catch (_) {}
|
|
|
|
try {
|
|
if (showDest && dest) {
|
|
dest.classList.add("last-move-dest");
|
|
const circle = document.createElement("div");
|
|
circle.className = "last-move-circle";
|
|
dest.appendChild(circle);
|
|
}
|
|
} catch (_) {}
|
|
try {
|
|
if (showSource && fromEl) {
|
|
fromEl.classList.add("last-move-source");
|
|
const circle = document.createElement("div");
|
|
circle.className = "last-move-circle";
|
|
fromEl.appendChild(circle);
|
|
}
|
|
} catch (_) {}
|
|
|
|
try {
|
|
if (roomId) {
|
|
const key = `cn:lastMove:${roomId}`;
|
|
localStorage.setItem(
|
|
key,
|
|
JSON.stringify({
|
|
from: data.from,
|
|
to: data.to,
|
|
ts: Date.now(),
|
|
}),
|
|
);
|
|
}
|
|
} catch (e) {}
|
|
}
|
|
} catch (_) {}
|
|
} catch (e) {
|
|
console.warn("move:moved handler error", e);
|
|
}
|
|
});
|
|
|
|
// mine detonation: show explosion animation at the square and remove local mine visual immediately
|
|
socket.on("mine:detonated", (evt) => {
|
|
try {
|
|
log("recv mine:detonated", evt);
|
|
// evt.square is algebraic name like 'e4'
|
|
if (evt && evt.square) showExplosionAt(evt.square);
|
|
// remove any mine image/dot at that square (owner will get updated minesOwn via room:update)
|
|
try {
|
|
const sqEl =
|
|
boardEl &&
|
|
boardEl.querySelector &&
|
|
boardEl.querySelector(`.square[data-square="${evt.square}"]`);
|
|
if (sqEl) {
|
|
const img = sqEl.querySelector(".mine-img");
|
|
if (img) img.remove();
|
|
const dot = sqEl.querySelector(".mine-dot");
|
|
if (dot) dot.remove();
|
|
}
|
|
} catch (_) {}
|
|
} catch (e) {
|
|
console.warn("mine:detonated handler error", e);
|
|
}
|
|
});
|
|
|
|
// private detonation info for the mine owner (optional handling)
|
|
socket.on("mine:detonated:private", (evt) => {
|
|
try {
|
|
log(
|
|
"recv mine:detonated:private",
|
|
evt,
|
|
); /* could show owner-specific UI later */
|
|
} catch (e) {}
|
|
});
|
|
|
|
socket.emit("room:join", { roomId, playerId }, (resp) => {
|
|
if (resp && resp.error) {
|
|
log("join error", resp);
|
|
showToast(resp.message || resp.error, {
|
|
background: "rgba(200,60,60,0.95)",
|
|
});
|
|
return;
|
|
}
|
|
log("Rejoint room", resp);
|
|
// remember assigned id/color from server
|
|
if (resp.playerId) {
|
|
myPlayerId = resp.playerId;
|
|
if (playerIdEl) playerIdEl.textContent = resp.playerId;
|
|
}
|
|
if (resp.color) {
|
|
myColor = resp.color;
|
|
if (myColorEl) myColorEl.textContent = myColor;
|
|
// orient board for black
|
|
if (boardEl) {
|
|
if (myColor === "black") boardEl.classList.add("flipped");
|
|
else boardEl.classList.remove("flipped");
|
|
}
|
|
}
|
|
});
|
|
}
|
|
|
|
connectAndJoin();
|
|
|
|
function updateTurnBanner(boardState) {
|
|
const banner = document.getElementById("turnBanner");
|
|
if (!banner) return;
|
|
if (!boardState || !boardState.turn || !myColor) {
|
|
banner.classList.remove("show");
|
|
return;
|
|
}
|
|
// myColor is 'white' or 'black', boardState.turn is 'w' or 'b'
|
|
const myShort = (myColor && myColor[0]) || "";
|
|
if (myShort === boardState.turn) {
|
|
banner.textContent = "C'est à vous de jouer";
|
|
banner.classList.add("show");
|
|
} else {
|
|
banner.textContent = "En attente du coup de l'adversaire";
|
|
banner.classList.add("show");
|
|
}
|
|
}
|
|
|
|
// Show the end-of-game modal with a helpful message and actions
|
|
function showGameOver(data) {
|
|
try {
|
|
const modal = document.getElementById("gameOverModal");
|
|
const title = document.getElementById("gameOverTitle");
|
|
const msg = document.getElementById("gameOverMessage");
|
|
const close = document.getElementById("gameOverClose");
|
|
const reload = document.getElementById("gameOverReload");
|
|
if (!modal || !title || !msg) return;
|
|
// determine outcome
|
|
if (data && data.draw) {
|
|
title.textContent = "Égalité";
|
|
msg.textContent =
|
|
"La partie est terminée: aucun roi n'est en jeu.";
|
|
} else if (data && data.winnerId) {
|
|
const youAreWinner =
|
|
myPlayerId && data.winnerId && myPlayerId === data.winnerId;
|
|
if (youAreWinner) {
|
|
title.textContent = "Victoire 🎉";
|
|
msg.textContent = "Félicitations — vous avez gagné !";
|
|
} else {
|
|
title.textContent = "Défaite";
|
|
msg.textContent =
|
|
"Vous avez perdu — l'adversaire a conservé son roi.";
|
|
}
|
|
} else if (data && data.winnerColor) {
|
|
// fallback: present color-based message
|
|
const youWin = myColor && myColor[0] === data.winnerColor;
|
|
if (youWin) {
|
|
title.textContent = "Victoire 🎉";
|
|
msg.textContent = "Félicitations — vous avez gagné !";
|
|
} else {
|
|
title.textContent = "Défaite";
|
|
msg.textContent = "Vous avez perdu — l'adversaire a gagné.";
|
|
}
|
|
} else {
|
|
title.textContent = "Partie terminée";
|
|
msg.textContent = "La partie est terminée.";
|
|
}
|
|
modal.style.display = "flex";
|
|
// disable interactions with board by adding an overlay class (optionally style via CSS)
|
|
if (boardEl) boardEl.classList.add("disabled-during-gameover");
|
|
// wire actions: stay (close modal) or quit (leave room and go back to index)
|
|
close.onclick = () => {
|
|
modal.style.display = "none";
|
|
try {
|
|
if (boardEl)
|
|
boardEl.classList.remove("disabled-during-gameover");
|
|
} catch (_) {}
|
|
// mark that this user stayed to spectate and show a persistent Quit button
|
|
try {
|
|
isSpectating = true;
|
|
createSpectatorQuitButton();
|
|
} catch (_) {}
|
|
};
|
|
reload.onclick = () => {
|
|
try {
|
|
// try to inform server that we voluntarily leave the room, then navigate away
|
|
if (socket && socket.connected) {
|
|
socket.emit("room:leave", { roomId }, (resp) => {
|
|
try {
|
|
window.location.href = "/";
|
|
} catch (_) {
|
|
window.location.reload();
|
|
}
|
|
});
|
|
} else {
|
|
window.location.href = "/";
|
|
}
|
|
} catch (e) {
|
|
try {
|
|
window.location.href = "/";
|
|
} catch (_) {
|
|
window.location.reload();
|
|
}
|
|
}
|
|
};
|
|
} catch (e) {
|
|
console.warn("showGameOver error", e);
|
|
}
|
|
}
|
|
|
|
// create a persistent 'Quitter' button for spectators (appears when user chose to stay)
|
|
function createSpectatorQuitButton() {
|
|
try {
|
|
removeSpectatorQuitButton(); // ensure only one
|
|
const btn = document.createElement("button");
|
|
btn.id = "spectatorQuitBtn";
|
|
btn.textContent = "Quitter la partie";
|
|
btn.style.position = "fixed";
|
|
btn.style.right = "18px";
|
|
btn.style.bottom = "18px";
|
|
btn.style.zIndex = 9999;
|
|
btn.style.padding = "10px 12px";
|
|
btn.style.borderRadius = "8px";
|
|
btn.style.border = "0";
|
|
btn.style.background = "linear-gradient(90deg,#ff7a59,#ffb199)";
|
|
btn.style.color = "#fff";
|
|
btn.style.cursor = "pointer";
|
|
btn.onclick = () => {
|
|
try {
|
|
if (socket && socket.connected) {
|
|
socket.emit("room:leave", { roomId }, (resp) => {
|
|
try {
|
|
window.location.href = "/";
|
|
} catch (_) {
|
|
window.location.reload();
|
|
}
|
|
});
|
|
} else {
|
|
window.location.href = "/";
|
|
}
|
|
} catch (e) {
|
|
try {
|
|
window.location.href = "/";
|
|
} catch (_) {
|
|
window.location.reload();
|
|
}
|
|
}
|
|
};
|
|
document.body.appendChild(btn);
|
|
} catch (e) {
|
|
console.warn("createSpectatorQuitButton error", e);
|
|
}
|
|
}
|
|
|
|
function removeSpectatorQuitButton() {
|
|
try {
|
|
const existing = document.getElementById("spectatorQuitBtn");
|
|
if (existing) existing.remove();
|
|
isSpectating = false;
|
|
} catch (_) {}
|
|
}
|
|
|
|
// movement UI removed: legalMoves and move markers are disabled
|
|
|
|
// selection helpers (client-side)
|
|
function clearSelection(emit = true) {
|
|
if (selectedSquareEl) {
|
|
selectedSquareEl.classList.remove("selected");
|
|
selectedSquareEl.classList.remove("hide-selection");
|
|
}
|
|
selectedSquare = null;
|
|
selectedSquareEl = null;
|
|
const fromEl = document.getElementById("from");
|
|
if (fromEl) fromEl.value = "";
|
|
// notify others that we cleared selection
|
|
if (emit && socket && myPlayerId) {
|
|
socket.emit("game:select", { roomId, square: null }, () => {});
|
|
}
|
|
// clear any shown legal-move markers
|
|
if (boardEl) clearLegalMarkers();
|
|
}
|
|
|
|
function setSelection(squareName, emit = true) {
|
|
if (!squareName) return clearSelection(emit);
|
|
if (selectedSquareEl) selectedSquareEl.classList.remove("selected");
|
|
// clear any existing markers immediately (so selecting a piece with no moves removes old markers)
|
|
clearLegalMarkers();
|
|
const sqEl = boardEl.querySelector(
|
|
`.square[data-square="${squareName}"]`,
|
|
);
|
|
if (!sqEl) {
|
|
selectedSquare = null;
|
|
selectedSquareEl = null;
|
|
return;
|
|
}
|
|
selectedSquare = squareName;
|
|
selectedSquareEl = sqEl;
|
|
sqEl.classList.add("selected");
|
|
// hide the square selection outline while keeping the piece
|
|
sqEl.classList.add("hide-selection");
|
|
const fromEl = document.getElementById("from");
|
|
if (fromEl) fromEl.value = squareName;
|
|
// notify others of our selection
|
|
if (emit && socket && myPlayerId) {
|
|
socket.emit(
|
|
"game:select",
|
|
{ roomId, square: squareName },
|
|
() => {},
|
|
);
|
|
}
|
|
// legal moves will be provided by server via the 'game:select' event
|
|
}
|
|
|
|
// show markers for legal moves when clicking any square (any piece color)
|
|
function clearLegalMarkers() {
|
|
if (!boardEl) return;
|
|
const prev = boardEl.querySelectorAll(".move-marker");
|
|
prev.forEach((p) => p.remove());
|
|
}
|
|
|
|
function showLegalMoves(moves) {
|
|
clearLegalMarkers();
|
|
(moves || []).forEach((m) => {
|
|
const to = m.to || (m.move && m.move.to);
|
|
if (!to) return;
|
|
const target = boardEl.querySelector(
|
|
`.square[data-square="${to}"]`,
|
|
);
|
|
if (!target) return;
|
|
// do not show legal-move markers on squares that are veiled/hidden
|
|
try {
|
|
if (
|
|
Array.isArray(currentVeiledSquares) &&
|
|
currentVeiledSquares.indexOf(to) !== -1
|
|
)
|
|
return;
|
|
if (target.classList && target.classList.contains("veiled"))
|
|
return;
|
|
} catch (_) {}
|
|
const marker = document.createElement("div");
|
|
marker.className = "move-marker";
|
|
// store metadata if needed later
|
|
marker.dataset.from = m.from || "";
|
|
marker.dataset.to = to;
|
|
target.appendChild(marker);
|
|
});
|
|
}
|
|
|
|
// remote move markers: attach remoteBy and allow clearing per player
|
|
function showRemoteLegalMoves(playerId, moves) {
|
|
clearRemoteMoveMarkers(playerId);
|
|
(moves || []).forEach((m) => {
|
|
const to = m.to || (m.move && m.move.to);
|
|
if (!to) return;
|
|
const target = boardEl.querySelector(
|
|
`.square[data-square="${to}"]`,
|
|
);
|
|
if (!target) return;
|
|
// do not show remote move markers on veiled/hidden squares
|
|
try {
|
|
if (
|
|
Array.isArray(currentVeiledSquares) &&
|
|
currentVeiledSquares.indexOf(to) !== -1
|
|
)
|
|
return;
|
|
if (target.classList && target.classList.contains("veiled"))
|
|
return;
|
|
} catch (_) {}
|
|
const marker = document.createElement("div");
|
|
marker.className = "move-marker";
|
|
marker.dataset.from = m.from || "";
|
|
marker.dataset.to = to;
|
|
marker.dataset.remoteBy = playerId;
|
|
target.appendChild(marker);
|
|
});
|
|
}
|
|
|
|
function clearRemoteMoveMarkers(playerId) {
|
|
if (!boardEl) return;
|
|
const prev = boardEl.querySelectorAll(
|
|
`.move-marker[data-remote-by="${playerId}"]`,
|
|
);
|
|
prev.forEach((p) => p.remove());
|
|
}
|
|
|
|
// remote selection helpers: mark a square as selected by another player
|
|
function setRemoteSelection(playerId, squareName) {
|
|
// clear previous for that player
|
|
clearRemoteSelection(playerId);
|
|
const sqEl = boardEl.querySelector(
|
|
`.square[data-square="${squareName}"]`,
|
|
);
|
|
if (!sqEl) return;
|
|
remoteSelections[playerId] = squareName;
|
|
sqEl.classList.add("selected-remote");
|
|
// hide the square selection outline for remote selection as well
|
|
sqEl.classList.add("hide-selection");
|
|
sqEl.dataset.selectedBy = playerId;
|
|
}
|
|
|
|
function clearRemoteSelection(playerId) {
|
|
const prev = remoteSelections[playerId];
|
|
if (!prev) return;
|
|
const prevEl = boardEl.querySelector(
|
|
`.square[data-square="${prev}"]`,
|
|
);
|
|
if (prevEl) {
|
|
prevEl.classList.remove("selected-remote");
|
|
delete prevEl.dataset.selectedBy;
|
|
prevEl.classList.remove("hide-selection");
|
|
}
|
|
delete remoteSelections[playerId];
|
|
// also clear remote move markers
|
|
clearRemoteMoveMarkers(playerId);
|
|
delete remoteMoves[playerId];
|
|
}
|
|
|
|
if (boardEl) {
|
|
boardEl.addEventListener("click", (ev) => {
|
|
const sqEl = ev.target.closest && ev.target.closest(".square");
|
|
if (!sqEl) return;
|
|
const square = sqEl.getAttribute("data-square");
|
|
if (!square) return;
|
|
|
|
// Do not allow interacting with veiled/hidden squares.
|
|
// `currentVeiledSquares` is populated from server `room:update` and
|
|
// we also add a `.veiled` class during rendering as a fallback.
|
|
try {
|
|
if (
|
|
Array.isArray(currentVeiledSquares) &&
|
|
currentVeiledSquares.indexOf(square) !== -1
|
|
) {
|
|
log("clicked veiled square, ignoring");
|
|
return;
|
|
}
|
|
if (sqEl.classList && sqEl.classList.contains("veiled")) {
|
|
log("clicked veiled square (DOM class), ignoring");
|
|
return;
|
|
}
|
|
} catch (_) {}
|
|
|
|
// If we're in a pending card-targeting mode, handle target selection and do NOT attempt moves
|
|
if (pendingCard) {
|
|
const req = pendingCard.requireType; // 'empty' | 'owned' | 'owned_pawn' | 'enemy' | 'own_then_enemy' | 'enemy_queen'
|
|
const myShort = (myColor && myColor[0]) || "";
|
|
// handle two-step selection for cards like 'inversion'
|
|
if (req === "own_then_enemy") {
|
|
// first select one of your own pieces
|
|
if (!pendingCard.firstTarget) {
|
|
const piece =
|
|
currentBoardState &&
|
|
Array.isArray(currentBoardState.pieces) &&
|
|
currentBoardState.pieces.find((p) => p.square === square);
|
|
if (!piece || piece.color !== myShort) {
|
|
log("select one of your pieces (first)");
|
|
return;
|
|
}
|
|
if (piece.type && piece.type.toLowerCase() === "k") {
|
|
log("cannot select king");
|
|
return;
|
|
}
|
|
// store first selection and highlight enemy pieces
|
|
pendingCard.firstTarget = square;
|
|
highlightTargets("enemy");
|
|
// mark visually the chosen source (temporary)
|
|
try {
|
|
const el = boardEl.querySelector(
|
|
`.square[data-square="${square}"]`,
|
|
);
|
|
if (el) el.classList.add("target-chosen");
|
|
} catch (_) {}
|
|
log("First piece selected for inversion:", square);
|
|
return;
|
|
}
|
|
// second selection: enemy piece to swap with
|
|
const occ =
|
|
currentBoardState &&
|
|
Array.isArray(currentBoardState.pieces) &&
|
|
currentBoardState.pieces.find((p) => p.square === square);
|
|
if (!occ || occ.color === myShort) {
|
|
log("select an enemy piece (second)");
|
|
return;
|
|
}
|
|
// emit swap payload: sourceSquare + targetSquare
|
|
emitCardPlay(
|
|
{
|
|
roomId,
|
|
playerId: myPlayerId,
|
|
cardId: pendingCard.cardId,
|
|
payload: {
|
|
sourceSquare: pendingCard.firstTarget,
|
|
targetSquare: square,
|
|
},
|
|
},
|
|
(resp) => {
|
|
if (resp && resp.error) {
|
|
log("card:play error", resp);
|
|
showToast(resp.message || resp.error, {
|
|
background: "rgba(200,60,60,0.95)",
|
|
});
|
|
} else {
|
|
log("inversion played", resp);
|
|
}
|
|
},
|
|
);
|
|
// cleanup visuals
|
|
try {
|
|
const el = boardEl.querySelector(
|
|
`.square[data-square="${pendingCard.firstTarget}"]`,
|
|
);
|
|
if (el) el.classList.remove("target-chosen");
|
|
} catch (_) {}
|
|
pendingCard = null;
|
|
clearTargetHighlights();
|
|
return;
|
|
}
|
|
if (req === "empty") {
|
|
const occupied =
|
|
currentBoardState &&
|
|
Array.isArray(currentBoardState.pieces) &&
|
|
currentBoardState.pieces.find((p) => p.square === square);
|
|
if (occupied) {
|
|
log("square not empty for mine placement");
|
|
return;
|
|
}
|
|
// send mine placement
|
|
emitCardPlay(
|
|
{
|
|
roomId,
|
|
playerId: myPlayerId,
|
|
cardId: pendingCard.cardId,
|
|
payload: { targetSquare: square },
|
|
},
|
|
(resp) => {
|
|
if (resp && resp.error) {
|
|
log("card:play error", resp);
|
|
showToast(resp.message || resp.error, {
|
|
background: "rgba(200,60,60,0.95)",
|
|
});
|
|
} else {
|
|
log("card played with target", resp);
|
|
}
|
|
},
|
|
);
|
|
pendingCard = null;
|
|
clearTargetHighlights();
|
|
return;
|
|
} else if (req === "owned") {
|
|
const piece =
|
|
currentBoardState &&
|
|
Array.isArray(currentBoardState.pieces) &&
|
|
currentBoardState.pieces.find((p) => p.square === square);
|
|
if (!piece || piece.color !== myShort) {
|
|
log("select one of your pieces");
|
|
return;
|
|
}
|
|
if (piece.type && piece.type.toLowerCase() === "k") {
|
|
log("cannot select king");
|
|
return;
|
|
}
|
|
emitCardPlay(
|
|
{
|
|
roomId,
|
|
playerId: myPlayerId,
|
|
cardId: pendingCard.cardId,
|
|
payload: { targetSquare: square },
|
|
},
|
|
(resp) => {
|
|
if (resp && resp.error) {
|
|
log("card:play error", resp);
|
|
showToast(resp.message || resp.error, {
|
|
background: "rgba(200,60,60,0.95)",
|
|
});
|
|
} else {
|
|
log("card played with target", resp);
|
|
}
|
|
},
|
|
);
|
|
pendingCard = null;
|
|
clearTargetHighlights();
|
|
return;
|
|
} else if (req === "owned_pawn") {
|
|
// must be one of your pawns — show promotion choice modal before sending
|
|
const piece =
|
|
currentBoardState &&
|
|
Array.isArray(currentBoardState.pieces) &&
|
|
currentBoardState.pieces.find((p) => p.square === square);
|
|
if (
|
|
!piece ||
|
|
piece.color !== myShort ||
|
|
!piece.type ||
|
|
piece.type.toLowerCase() !== "p"
|
|
) {
|
|
log("select one of your pawns for promotion");
|
|
return;
|
|
}
|
|
// show promotion UI; when the player picks a piece type, emit the card:play with payload.promotion
|
|
showPromotionModal(square, (promotionChoice) => {
|
|
if (!promotionChoice) {
|
|
// canceled
|
|
pendingCard = null;
|
|
clearTargetHighlights();
|
|
return;
|
|
}
|
|
emitCardPlay(
|
|
{
|
|
roomId,
|
|
playerId: myPlayerId,
|
|
cardId: pendingCard.cardId,
|
|
payload: {
|
|
targetSquare: square,
|
|
promotion: promotionChoice,
|
|
},
|
|
},
|
|
(resp) => {
|
|
if (resp && resp.error) {
|
|
log("card:play error", resp);
|
|
showToast(resp.message || resp.error, {
|
|
background: "rgba(200,60,60,0.95)",
|
|
});
|
|
} else {
|
|
log("card played with promotion", resp);
|
|
}
|
|
},
|
|
);
|
|
pendingCard = null;
|
|
clearTargetHighlights();
|
|
});
|
|
return;
|
|
} else if (req === "enemy") {
|
|
const occ =
|
|
currentBoardState &&
|
|
Array.isArray(currentBoardState.pieces) &&
|
|
currentBoardState.pieces.find((p) => p.square === square);
|
|
if (!occ || occ.color === myShort) {
|
|
log("select an enemy piece");
|
|
return;
|
|
}
|
|
if (occ.type && occ.type.toLowerCase() === "k") {
|
|
log("cannot select king");
|
|
return;
|
|
}
|
|
emitCardPlay(
|
|
{
|
|
roomId,
|
|
playerId: myPlayerId,
|
|
cardId: pendingCard.cardId,
|
|
payload: { targetSquare: square },
|
|
},
|
|
(resp) => {
|
|
if (resp && resp.error) {
|
|
log("card:play error", resp);
|
|
showToast(resp.message || resp.error, {
|
|
background: "rgba(200,60,60,0.95)",
|
|
});
|
|
} else {
|
|
log("card played with target", resp);
|
|
try {
|
|
setSelection(square);
|
|
} catch (_) {}
|
|
}
|
|
},
|
|
);
|
|
pendingCard = null;
|
|
clearTargetHighlights();
|
|
return;
|
|
} else if (req === "enemy_queen") {
|
|
const occ =
|
|
currentBoardState &&
|
|
Array.isArray(currentBoardState.pieces) &&
|
|
currentBoardState.pieces.find((p) => p.square === square);
|
|
if (
|
|
!occ ||
|
|
occ.color === myShort ||
|
|
!occ.type ||
|
|
occ.type.toLowerCase() !== "q"
|
|
) {
|
|
log("select an enemy queen");
|
|
return;
|
|
}
|
|
emitCardPlay(
|
|
{
|
|
roomId,
|
|
playerId: myPlayerId,
|
|
cardId: pendingCard.cardId,
|
|
payload: { targetSquare: square },
|
|
},
|
|
(resp) => {
|
|
if (resp && resp.error) {
|
|
log("card:play error", resp);
|
|
showToast(resp.message || resp.error, {
|
|
background: "rgba(200,60,60,0.95)",
|
|
});
|
|
} else {
|
|
log("card played with target", resp);
|
|
try {
|
|
setSelection(square);
|
|
} catch (_) {}
|
|
}
|
|
},
|
|
);
|
|
pendingCard = null;
|
|
clearTargetHighlights();
|
|
return;
|
|
} else if (req === "piece") {
|
|
const occ =
|
|
currentBoardState &&
|
|
Array.isArray(currentBoardState.pieces) &&
|
|
currentBoardState.pieces.find((p) => p.square === square);
|
|
if (!occ) {
|
|
log("select a piece");
|
|
return;
|
|
}
|
|
if (occ.type && occ.type.toLowerCase() === "k") {
|
|
log("cannot select king");
|
|
return;
|
|
}
|
|
emitCardPlay(
|
|
{
|
|
roomId,
|
|
playerId: myPlayerId,
|
|
cardId: pendingCard.cardId,
|
|
payload: { targetSquare: square },
|
|
},
|
|
(resp) => {
|
|
if (resp && resp.error) {
|
|
log("card:play error", resp);
|
|
showToast(resp.message || resp.error, {
|
|
background: "rgba(200,60,60,0.95)",
|
|
});
|
|
} else {
|
|
log("card played with target", resp);
|
|
}
|
|
},
|
|
);
|
|
pendingCard = null;
|
|
clearTargetHighlights();
|
|
return;
|
|
}
|
|
}
|
|
|
|
// If this square contains a move-marker (either clicked marker or square area), attempt the move.
|
|
// Prefer the actual clicked marker if present, otherwise look for a marker element inside the square.
|
|
const clickedMarker =
|
|
ev.target.closest && ev.target.closest(".move-marker");
|
|
const marker = clickedMarker || sqEl.querySelector(".move-marker");
|
|
if (marker) {
|
|
// ignore remote player's markers
|
|
const remoteBy = marker.dataset && marker.dataset.remoteBy;
|
|
if (remoteBy && remoteBy !== myPlayerId) {
|
|
log("marker belongs to remote player, ignoring");
|
|
return;
|
|
}
|
|
const to = marker.dataset && marker.dataset.to;
|
|
const from =
|
|
marker.dataset && marker.dataset.from
|
|
? marker.dataset.from
|
|
: selectedSquare;
|
|
if (!to || !from) {
|
|
log("invalid marker metadata", { from, to });
|
|
return;
|
|
}
|
|
// only allow if it's our turn
|
|
const myShort = (myColor && myColor[0]) || "";
|
|
if (!currentBoardState || currentBoardState.turn !== myShort) {
|
|
log("not your turn");
|
|
return;
|
|
}
|
|
socket.emit("game:move", { roomId, from, to }, (resp) => {
|
|
if (resp && resp.error) {
|
|
log("move error", resp);
|
|
showToast(resp.message || resp.error, {
|
|
background: "rgba(200,60,60,0.95)",
|
|
});
|
|
} else {
|
|
log("move sent", resp);
|
|
}
|
|
});
|
|
return;
|
|
}
|
|
|
|
// clicked a piece -> select it (no legal moves requested)
|
|
const pieceImg =
|
|
ev.target.closest && ev.target.closest("img.piece");
|
|
if (pieceImg) {
|
|
setSelection(square);
|
|
return;
|
|
}
|
|
|
|
// default: clicked empty square -> select it (toggle)
|
|
if (selectedSquare === square) {
|
|
clearSelection();
|
|
} else {
|
|
setSelection(square);
|
|
}
|
|
});
|
|
}
|
|
|
|
// move button removed
|
|
|
|
// Board rendering using provided SVG assets and `boardState` JSON.
|
|
const pieceSetPath = "/assets/chess-pieces/chess_maestro_bw";
|
|
// board visual defaults are handled by CSS (background, size, border, position)
|
|
|
|
function renderBoardFromState(boardState) {
|
|
if (!boardState) return;
|
|
// cache boardState for client-side checks (turn etc.)
|
|
currentBoardState = boardState;
|
|
// clear existing pieces but keep squares if present
|
|
const squares = boardEl.querySelectorAll(".square");
|
|
squares.forEach((s) => {
|
|
// remove any last-move visual state from previous turns
|
|
try {
|
|
s.classList.remove("last-move-dest");
|
|
s.classList.remove("last-move-source");
|
|
} catch (_) {}
|
|
try {
|
|
s.classList.remove("petrified");
|
|
try {
|
|
delete s.dataset.medusaEffectId;
|
|
} catch (_) {}
|
|
} catch (_) {}
|
|
// remove any lingering circle markers
|
|
try {
|
|
const prev = s.querySelectorAll(".last-move-circle");
|
|
prev.forEach((c) => c.remove());
|
|
} catch (_) {}
|
|
// clear square contents (pieces/markers)
|
|
s.innerHTML = "";
|
|
});
|
|
|
|
// place pieces according to boardState.pieces (expects algebraic square names)
|
|
(boardState.pieces || []).forEach((p) => {
|
|
const squareName = p.square;
|
|
if (!squareName) return;
|
|
const sqEl = boardEl.querySelector(
|
|
`.square[data-square="${squareName}"]`,
|
|
);
|
|
if (!sqEl) return;
|
|
const letter = p.type ? p.type.toUpperCase() : "?";
|
|
const filename =
|
|
(p.color === "w" ? "w" + letter : "b" + letter) + ".svg";
|
|
const img = document.createElement("img");
|
|
img.src = `${pieceSetPath}/${filename}`;
|
|
img.className = "piece";
|
|
sqEl.appendChild(img);
|
|
});
|
|
|
|
// re-apply selection if the previously selected square still exists
|
|
if (selectedSquare) {
|
|
// re-apply local selection without re-emitting
|
|
setSelection(selectedSquare, false);
|
|
}
|
|
// re-apply remote selections
|
|
Object.entries(remoteSelections).forEach(([pid, sq]) => {
|
|
const el = boardEl.querySelector(`.square[data-square="${sq}"]`);
|
|
if (el) {
|
|
el.classList.add("selected-remote");
|
|
el.classList.add("hide-selection");
|
|
}
|
|
});
|
|
// re-apply remote move markers
|
|
Object.entries(remoteMoves).forEach(([pid, moves]) => {
|
|
if (Array.isArray(moves) && moves.length)
|
|
showRemoteLegalMoves(pid, moves);
|
|
});
|
|
// render any mines owned by this player
|
|
try {
|
|
renderMines();
|
|
} catch (e) {}
|
|
// render veils for brouillard effect (if any)
|
|
try {
|
|
// remove existing veils
|
|
const allSq = boardEl.querySelectorAll(".square");
|
|
allSq.forEach((sqEl) => {
|
|
const v = sqEl.querySelector(".veil");
|
|
if (v) v.remove();
|
|
// also remove prior veiled marker class
|
|
try {
|
|
sqEl.classList.remove("veiled");
|
|
} catch (_) {}
|
|
});
|
|
if (
|
|
Array.isArray(currentVeiledSquares) &&
|
|
currentVeiledSquares.length
|
|
) {
|
|
currentVeiledSquares.forEach((sq) => {
|
|
const el = boardEl.querySelector(
|
|
`.square[data-square="${sq}"]`,
|
|
);
|
|
if (!el) return;
|
|
const veil = document.createElement("div");
|
|
veil.className = "veil";
|
|
el.appendChild(veil);
|
|
// add a class to hide piece images as a fallback in case z-index/stacking causes overlay issues
|
|
try {
|
|
el.classList.add("veiled");
|
|
// remove any move markers that may have been added previously on this square
|
|
const prevMarkers = el.querySelectorAll(".move-marker");
|
|
prevMarkers.forEach((m) => m.remove());
|
|
} catch (_) {}
|
|
});
|
|
}
|
|
} catch (_) {}
|
|
}
|
|
|
|
// draw mine icons on squares for mines owned by this client
|
|
function renderMines() {
|
|
if (!boardEl) return;
|
|
// remove old mine markers
|
|
// remove old mine markers (either image or dot)
|
|
const prev = boardEl.querySelectorAll(".mine-dot, .mine-img");
|
|
prev.forEach((p) => p.remove());
|
|
(myMines || []).forEach((sq) => {
|
|
const el = boardEl.querySelector(`.square[data-square="${sq}"]`);
|
|
if (!el) return;
|
|
// create an image element so the mine has a transparent background and crisp edges
|
|
const img = document.createElement("img");
|
|
img.className = "mine-img";
|
|
// use a project asset (SVG) that has a transparent background
|
|
img.src = "/assets/img/bomb.jpg";
|
|
img.alt = "mine";
|
|
img.onerror = function () {
|
|
try {
|
|
if (this.src && this.src.indexOf("bomb.jpg") === -1)
|
|
this.src = "/assets/img/bomb.jpg";
|
|
} catch (e) {}
|
|
};
|
|
img.draggable = false;
|
|
// ensure container is positioned so absolute centering works
|
|
if (!el.style.position) el.style.position = "relative";
|
|
el.appendChild(img);
|
|
});
|
|
}
|
|
|
|
// show an explosion animation centered on a square (client-side visual)
|
|
function showExplosionAt(square) {
|
|
if (!boardEl || !square) return;
|
|
const sqEl = boardEl.querySelector(
|
|
`.square[data-square="${square}"]`,
|
|
);
|
|
if (!sqEl) return;
|
|
// ensure container is positioned so absolute centering works
|
|
if (!sqEl.style.position) sqEl.style.position = "relative";
|
|
|
|
// Try to play an authored explosion video if available; fallback to CSS explosion if not.
|
|
const videoPath = "/assets/media/FireElement01.mov";
|
|
const vid = document.createElement("video");
|
|
vid.className = "explosion-video";
|
|
vid.src = videoPath;
|
|
vid.autoplay = true;
|
|
vid.muted = true;
|
|
vid.playsInline = true;
|
|
vid.loop = false;
|
|
vid.preload = "auto";
|
|
vid.style.pointerEvents = "none";
|
|
|
|
let usedVideo = false;
|
|
// Attach event listeners to handle success or failure
|
|
const cleanup = () => {
|
|
try {
|
|
if (vid && vid.parentNode) vid.parentNode.removeChild(vid);
|
|
} catch (_) {}
|
|
};
|
|
vid.addEventListener(
|
|
"canplay",
|
|
() => {
|
|
// video is available and can play — use it
|
|
try {
|
|
sqEl.appendChild(vid);
|
|
usedVideo = true;
|
|
vid.play().catch(() => {});
|
|
} catch (_) {
|
|
usedVideo = false;
|
|
}
|
|
},
|
|
{ once: true },
|
|
);
|
|
// if video errors (file missing or codec issue), fallback to CSS explosion
|
|
vid.addEventListener(
|
|
"error",
|
|
() => {
|
|
try {
|
|
cleanup();
|
|
} catch (_) {}
|
|
// fallback animation
|
|
const ex = document.createElement("div");
|
|
ex.className = "explosion";
|
|
sqEl.appendChild(ex);
|
|
requestAnimationFrame(() => ex.classList.add("play"));
|
|
setTimeout(() => {
|
|
try {
|
|
ex.remove();
|
|
} catch (_) {
|
|
if (ex && ex.parentNode) ex.parentNode.removeChild(ex);
|
|
}
|
|
}, 700);
|
|
},
|
|
{ once: true },
|
|
);
|
|
// If video ends, remove it
|
|
vid.addEventListener(
|
|
"ended",
|
|
() => {
|
|
try {
|
|
cleanup();
|
|
} catch (_) {}
|
|
},
|
|
{ once: true },
|
|
);
|
|
// start loading — if canplay won't fire within a short time, fallback to CSS
|
|
vid.load();
|
|
// fallback timeout: if video hasn't started in 180ms, show CSS explosion instead
|
|
setTimeout(() => {
|
|
if (!usedVideo) {
|
|
try {
|
|
vid.pause();
|
|
if (vid && vid.parentNode) vid.parentNode.removeChild(vid);
|
|
} catch (_) {}
|
|
const ex = document.createElement("div");
|
|
ex.className = "explosion";
|
|
sqEl.appendChild(ex);
|
|
requestAnimationFrame(() => ex.classList.add("play"));
|
|
setTimeout(() => {
|
|
try {
|
|
ex.remove();
|
|
} catch (_) {
|
|
if (ex && ex.parentNode) ex.parentNode.removeChild(ex);
|
|
}
|
|
}, 700);
|
|
}
|
|
}, 180);
|
|
}
|
|
|
|
// target highlighting helpers
|
|
function clearTargetHighlights() {
|
|
if (!boardEl) return;
|
|
const prev = boardEl.querySelectorAll(".square.targetable");
|
|
prev.forEach((p) => p.classList.remove("targetable"));
|
|
const note = document.getElementById("pendingNote");
|
|
if (note) note.remove();
|
|
}
|
|
function highlightTargets(type) {
|
|
if (!boardEl || !currentBoardState) return;
|
|
clearTargetHighlights();
|
|
const myShort = (myColor && myColor[0]) || "";
|
|
Array.from(boardEl.querySelectorAll(".square")).forEach((sqEl) => {
|
|
const sq = sqEl.getAttribute("data-square");
|
|
if (!sq) return;
|
|
if (type === "empty") {
|
|
const occ = (currentBoardState.pieces || []).find(
|
|
(p) => p.square === sq,
|
|
);
|
|
if (!occ) sqEl.classList.add("targetable");
|
|
} else if (type === "owned") {
|
|
const occ = (currentBoardState.pieces || []).find(
|
|
(p) => p.square === sq,
|
|
);
|
|
if (
|
|
occ &&
|
|
occ.color === myShort &&
|
|
occ.type &&
|
|
occ.type.toLowerCase() !== "k"
|
|
)
|
|
sqEl.classList.add("targetable");
|
|
} else if (type === "owned_pawn") {
|
|
const occ = (currentBoardState.pieces || []).find(
|
|
(p) => p.square === sq,
|
|
);
|
|
if (
|
|
occ &&
|
|
occ.color === myShort &&
|
|
occ.type &&
|
|
occ.type.toLowerCase() === "p"
|
|
)
|
|
sqEl.classList.add("targetable");
|
|
} else if (type === "own_then_enemy") {
|
|
// initial phase: highlight only own pieces (client will switch to enemy after first selection)
|
|
const occ = (currentBoardState.pieces || []).find(
|
|
(p) => p.square === sq,
|
|
);
|
|
if (
|
|
occ &&
|
|
occ.color === myShort &&
|
|
occ.type &&
|
|
occ.type.toLowerCase() !== "k"
|
|
)
|
|
sqEl.classList.add("targetable");
|
|
} else if (type === "piece") {
|
|
const occ = (currentBoardState.pieces || []).find(
|
|
(p) => p.square === sq,
|
|
);
|
|
if (occ && occ.type && occ.type.toLowerCase() !== "k")
|
|
sqEl.classList.add("targetable");
|
|
} else if (type === "enemy_queen") {
|
|
const occ = (currentBoardState.pieces || []).find(
|
|
(p) => p.square === sq,
|
|
);
|
|
if (
|
|
occ &&
|
|
occ.color &&
|
|
occ.color !== myShort &&
|
|
occ.type &&
|
|
occ.type.toLowerCase() === "q"
|
|
)
|
|
sqEl.classList.add("targetable");
|
|
} else if (type === "enemy") {
|
|
const occ = (currentBoardState.pieces || []).find(
|
|
(p) => p.square === sq,
|
|
);
|
|
if (
|
|
occ &&
|
|
occ.color &&
|
|
occ.color !== myShort &&
|
|
occ.type &&
|
|
occ.type.toLowerCase() !== "k"
|
|
)
|
|
sqEl.classList.add("targetable");
|
|
}
|
|
});
|
|
// add a small note near hands
|
|
try {
|
|
const handsEl = document.getElementById("hands");
|
|
if (handsEl) {
|
|
const note = document.createElement("div");
|
|
note.id = "pendingNote";
|
|
note.style.marginTop = "8px";
|
|
note.style.display = "flex";
|
|
note.style.alignItems = "center";
|
|
note.style.gap = "8px";
|
|
const txt = document.createElement("span");
|
|
txt.textContent = "Sélectionnez la case cible pour la carte...";
|
|
txt.style.fontWeight = "700";
|
|
note.appendChild(txt);
|
|
const cancelBtn = document.createElement("button");
|
|
cancelBtn.type = "button";
|
|
cancelBtn.id = "pendingCancelBtn";
|
|
cancelBtn.textContent = "Annuler";
|
|
// use a dedicated class to ensure good contrast against the hands background
|
|
cancelBtn.className = "btn-cancel";
|
|
cancelBtn.addEventListener("click", (ev) => {
|
|
ev.stopPropagation();
|
|
cancelPendingCard();
|
|
});
|
|
note.appendChild(cancelBtn);
|
|
handsEl.prepend(note);
|
|
}
|
|
} catch (e) {}
|
|
}
|
|
|
|
// cancel a pending card selection (client-only)
|
|
function cancelPendingCard() {
|
|
if (!pendingCard) return;
|
|
pendingCard = null;
|
|
clearTargetHighlights();
|
|
log("Pending card selection annulée");
|
|
// remove pendingNote if present
|
|
const note = document.getElementById("pendingNote");
|
|
if (note) note.remove();
|
|
}
|
|
|
|
// build grid with independent square elements and data-square attributes
|
|
function buildGrid(size) {
|
|
if (!size || size < 1) size = 8;
|
|
boardEl.style.gridTemplateColumns = `repeat(${size}, 1fr)`;
|
|
boardEl.style.gridTemplateRows = `repeat(${size}, 1fr)`;
|
|
// if grid already has correct number of squares, keep them
|
|
const existing = boardEl.querySelectorAll(".square");
|
|
if (existing.length === size * size) return;
|
|
boardEl.innerHTML = "";
|
|
// create rows from rank size..1 (so that a1 is bottom-left if CSS doesn't flip)
|
|
for (let rank = size; rank >= 1; rank--) {
|
|
for (let file = 0; file < size; file++) {
|
|
const fileLetter = String.fromCharCode("a".charCodeAt(0) + file);
|
|
const squareName = `${fileLetter}${rank}`;
|
|
const sq = document.createElement("div");
|
|
// color squares in checker pattern
|
|
const dark = (file + rank) % 2 === 0;
|
|
sq.className = "square " + (dark ? "dark" : "light");
|
|
sq.setAttribute("data-square", squareName);
|
|
boardEl.appendChild(sq);
|
|
}
|
|
}
|
|
}
|
|
|
|
// render player's own hand and public counts for others
|
|
// New: render as horizontal 'pokemon-style' cards side-by-side
|
|
function renderHands(handsOwn, handCounts) {
|
|
const handsEl = document.getElementById("hands");
|
|
if (!handsEl) return;
|
|
handsEl.innerHTML = "";
|
|
|
|
const wrap = document.createElement("div");
|
|
wrap.className = "player-hand pokemon-hand";
|
|
|
|
const header = document.createElement("div");
|
|
header.className = "pokemon-hand-header";
|
|
const title = document.createElement("div");
|
|
title.textContent = "Vos cartes";
|
|
title.className = "pokemon-hand-title";
|
|
header.appendChild(title);
|
|
wrap.appendChild(header);
|
|
|
|
const row = document.createElement("div");
|
|
row.className = "cards-row";
|
|
|
|
(handsOwn || []).slice(0, 10).forEach((c) => {
|
|
const cardEl = document.createElement("div");
|
|
cardEl.className = "pokemon-card";
|
|
cardEl.dataset.cardId = c.cardId || c.id || "";
|
|
cardEl.dataset.cardUuid = c.id || "";
|
|
|
|
const top = document.createElement("div");
|
|
top.className = "pokemon-card-top";
|
|
const art = document.createElement("div");
|
|
art.className = "pokemon-card-art";
|
|
art.textContent = "";
|
|
try {
|
|
const cid = (c.cardId || c.id || "").toString().toLowerCase();
|
|
const title = (c.title || "").toString().toLowerCase();
|
|
let imgSrc = null;
|
|
if (cid === "adoubement")
|
|
imgSrc = "/assets/img/cards/adoubement.png";
|
|
if (cid === "folie") imgSrc = "/assets/img/cards/folie.png";
|
|
if (cid === "fortification")
|
|
imgSrc = "/assets/img/cards/fortification.png";
|
|
if (cid === "rebond") imgSrc = "/assets/img/cards/rebond.png";
|
|
if (cid === "anneau") imgSrc = "/assets/img/cards/anneau.png";
|
|
if (cid === "brouillard")
|
|
imgSrc = "/assets/img/cards/brouillard.png";
|
|
if (cid === "coincoin") imgSrc = "/assets/img/cards/coincoin.png";
|
|
if (cid === "inversion")
|
|
imgSrc = "/assets/img/cards/inversion.png";
|
|
if (cid === "invisible")
|
|
imgSrc = "/assets/img/cards/invisible.png";
|
|
if (cid === "kamikaze") imgSrc = "/assets/img/cards/kamikaze.png";
|
|
if (cid === "melange") imgSrc = "/assets/img/cards/mélange.png";
|
|
if (cid === "mine") imgSrc = "/assets/img/cards/mine.png";
|
|
if (cid === "promotion")
|
|
imgSrc = "/assets/img/cards/promotion.png";
|
|
if (cid === "resurection")
|
|
imgSrc = "/assets/img/cards/resurection.png";
|
|
if (cid === "sniper") imgSrc = "/assets/img/cards/sniper.png";
|
|
if (cid === "totem") imgSrc = "/assets/img/cards/totem.png";
|
|
if (cid === "toucher") imgSrc = "/assets/img/cards/toucher.png";
|
|
if (cid === "vole_piece")
|
|
imgSrc = "/assets/img/cards/vole_piece.png";
|
|
if (cid === "double") imgSrc = "/assets/img/cards/double.png";
|
|
if (cid === "empathie") imgSrc = "/assets/img/cards/empathie.png";
|
|
if (cid === "medusa") imgSrc = "/assets/img/cards/medusa.png";
|
|
if (cid === "parrure") imgSrc = "/assets/img/cards/parrure.png";
|
|
if (cid === "revolution")
|
|
imgSrc = "/assets/img/cards/révolution.png";
|
|
if (cid === "doppelganger")
|
|
imgSrc = "/assets/img/cards/doppelganger.png";
|
|
if (cid === "pareil") imgSrc = "/assets/img/cards/pareil.png";
|
|
if (cid === "sans_effet")
|
|
imgSrc = "/assets/img/cards/sans_effet.png";
|
|
if (cid === "teleportation")
|
|
imgSrc = "/assets/img/cards/teleportation.png";
|
|
if (cid === "tout") imgSrc = "/assets/img/cards/tout.png";
|
|
if (cid === "vole_carte")
|
|
imgSrc = "/assets/img/cards/vole_carte.png";
|
|
if (cid === "medusa")
|
|
imgSrc = "/assets/img/cards/medusa.png";
|
|
if (cid === "cachotier")
|
|
imgSrc = "/assets/img/cards/cachotier.png";
|
|
|
|
if (imgSrc) {
|
|
const img = document.createElement("img");
|
|
img.src = imgSrc;
|
|
img.alt = c.title || cid || "card";
|
|
img.className = "card-art-img";
|
|
img.style.width = "100%";
|
|
img.style.height = "100%";
|
|
img.style.objectFit = "cover";
|
|
art.appendChild(img);
|
|
}
|
|
} catch (e) {
|
|
/* ignore artwork errors */
|
|
}
|
|
top.appendChild(art);
|
|
|
|
const mid = document.createElement("div");
|
|
mid.className = "pokemon-card-mid";
|
|
const h = document.createElement("div");
|
|
h.className = "pokemon-card-title";
|
|
h.textContent = c.title || c.cardId || "Carte";
|
|
const p = document.createElement("div");
|
|
p.className = "pokemon-card-desc";
|
|
p.textContent = c.description || "";
|
|
mid.appendChild(h);
|
|
mid.appendChild(p);
|
|
|
|
const footer = document.createElement("div");
|
|
footer.className = "pokemon-card-footer";
|
|
const playBtn = document.createElement("button");
|
|
playBtn.className = "card-play-btn";
|
|
playBtn.textContent = "Jouer";
|
|
footer.appendChild(playBtn);
|
|
|
|
cardEl.appendChild(top);
|
|
cardEl.appendChild(mid);
|
|
cardEl.appendChild(footer);
|
|
|
|
// clicking the whole card or the play button emits card:play
|
|
function emitPlay() {
|
|
if (!socket) return;
|
|
const cid = cardEl.dataset.cardId;
|
|
// try cardId first, then fall back to the human title (helps with accented ids like "Téléportation")
|
|
const requireType =
|
|
cardRequiresTarget(cid) || cardRequiresTarget(c.title || "");
|
|
if (requireType) {
|
|
if (requireType === "player") {
|
|
// If there's only one opponent, auto-select them (no modal needed). Otherwise show modal.
|
|
const opponents = Object.keys(handCounts || {}).filter(
|
|
(pid) => pid !== myPlayerId,
|
|
);
|
|
if (opponents.length === 1) {
|
|
const targetPlayerId = opponents[0];
|
|
emitCardPlay(
|
|
{
|
|
roomId,
|
|
playerId: myPlayerId,
|
|
cardId: cid,
|
|
payload: { targetPlayerId },
|
|
},
|
|
(resp) => {
|
|
if (resp && resp.error) {
|
|
log("card:play error", resp);
|
|
showToast(resp.message || resp.error, {
|
|
background: "rgba(200,60,60,0.95)",
|
|
});
|
|
} else {
|
|
log("card played (steal card)", resp);
|
|
}
|
|
},
|
|
);
|
|
return;
|
|
}
|
|
// show modal to pick which player to steal from
|
|
showStealModal(handCounts, (targetPlayerId) => {
|
|
if (!targetPlayerId) return; // canceled
|
|
emitCardPlay(
|
|
{
|
|
roomId,
|
|
playerId: myPlayerId,
|
|
cardId: cid,
|
|
payload: { targetPlayerId },
|
|
},
|
|
(resp) => {
|
|
if (resp && resp.error) {
|
|
log("card:play error", resp);
|
|
showToast(resp.message || resp.error, {
|
|
background: "rgba(200,60,60,0.95)",
|
|
});
|
|
} else {
|
|
log("card played (steal card)", resp);
|
|
}
|
|
},
|
|
);
|
|
});
|
|
return;
|
|
}
|
|
// enter pending target mode: next square click will send the card with payload.targetSquare
|
|
pendingCard = {
|
|
cardId: cid,
|
|
cardUuid: cardEl.dataset.cardUuid || "",
|
|
requireType,
|
|
};
|
|
highlightTargets(requireType);
|
|
log("Pending card selection for", cid, "-> select target");
|
|
return;
|
|
}
|
|
// immediate-play card (no target required)
|
|
const payload = {};
|
|
emitCardPlay(
|
|
{ roomId, playerId: myPlayerId, cardId: cid, payload },
|
|
(resp) => {
|
|
if (resp && resp.error) {
|
|
log("card:play error", resp);
|
|
showToast(resp.message || resp.error, {
|
|
background: "rgba(200,60,60,0.95)",
|
|
});
|
|
} else {
|
|
log("card played", resp);
|
|
}
|
|
},
|
|
);
|
|
}
|
|
cardEl.addEventListener("click", (ev) => {
|
|
if (ev.target === playBtn) return;
|
|
emitPlay();
|
|
});
|
|
playBtn.addEventListener("click", (ev) => {
|
|
ev.stopPropagation();
|
|
emitPlay();
|
|
});
|
|
|
|
row.appendChild(cardEl);
|
|
});
|
|
|
|
// draw button inside the 'Vos cartes' header (visible only when autoDraw is off and it's your turn)
|
|
try {
|
|
const drawBtn = document.createElement("button");
|
|
drawBtn.id = "drawBtn";
|
|
drawBtn.type = "button";
|
|
drawBtn.className = "btn-draw";
|
|
drawBtn.style.display = "none";
|
|
drawBtn.textContent = "Piocher";
|
|
// attach handler that emits player:draw and temporarily disables the button
|
|
drawBtn.addEventListener("click", (ev) => {
|
|
ev.stopPropagation();
|
|
if (!socket) return;
|
|
drawBtn.disabled = true;
|
|
socket.emit("player:draw", { roomId }, (resp) => {
|
|
if (resp && resp.error) {
|
|
log("player:draw error", resp);
|
|
showToast(resp.message || resp.error, {
|
|
background: "rgba(200,60,60,0.95)",
|
|
});
|
|
drawBtn.disabled = false;
|
|
return;
|
|
}
|
|
log("player:draw ok", resp);
|
|
// server will send room:update which will hide/disable the button as needed
|
|
});
|
|
});
|
|
header.appendChild(drawBtn);
|
|
} catch (_) {}
|
|
|
|
wrap.appendChild(row);
|
|
handsEl.appendChild(wrap);
|
|
}
|
|
|
|
function showCardDrawnNotification(evt) {
|
|
try {
|
|
const handsEl = document.getElementById("hands");
|
|
if (!handsEl) return;
|
|
const note = document.createElement("div");
|
|
note.className = "card-draw-note";
|
|
note.textContent =
|
|
evt && evt.card
|
|
? `Pioché: ${evt.card.title || evt.card.cardId}`
|
|
: "Carte piochée";
|
|
note.style.padding = "6px 8px";
|
|
note.style.border = "1px solid #ccc";
|
|
note.style.background = "#fff8e1";
|
|
note.style.marginTop = "8px";
|
|
handsEl.prepend(note);
|
|
setTimeout(() => note.remove(), 5000);
|
|
} catch (e) {
|
|
console.warn("showCardDrawnNotification error", e);
|
|
}
|
|
}
|
|
|
|
function showEffectNotification(effect) {
|
|
try {
|
|
const handsEl = document.getElementById("hands");
|
|
if (!handsEl) return;
|
|
const note = document.createElement("div");
|
|
note.className = "card-draw-note";
|
|
if (effect.type === "promotion_failed")
|
|
note.textContent = "Promotion échouée — la carte est consommée";
|
|
else if (effect.type === "promotion")
|
|
note.textContent = "Promotion réussie !";
|
|
else if (effect.type === "mine_failed")
|
|
note.textContent = "Placement de mine échoué — carte consommée";
|
|
else if (effect.type === "steal_failed")
|
|
note.textContent = "Vol échoué — carte consommée";
|
|
else if (effect.type === "card_lost")
|
|
note.textContent = "Une de vos cartes a été volée";
|
|
else note.textContent = `Effet: ${effect.type}`;
|
|
note.style.padding = "6px 8px";
|
|
note.style.border = "1px solid #ccc";
|
|
note.style.background = "#fff8e1";
|
|
note.style.marginTop = "8px";
|
|
handsEl.prepend(note);
|
|
setTimeout(() => note.remove(), 6000);
|
|
} catch (e) {
|
|
console.warn("showEffectNotification error", e);
|
|
}
|
|
}
|
|
|
|
// show a simple promotion choice modal. callback receives one of 'q','r','b','n' or null if cancelled
|
|
function showPromotionModal(square, callback) {
|
|
try {
|
|
if (document.getElementById("promotionModal")) return;
|
|
const modal = document.createElement("div");
|
|
modal.id = "promotionModal";
|
|
modal.style.position = "fixed";
|
|
modal.style.left = "0";
|
|
modal.style.top = "0";
|
|
modal.style.right = "0";
|
|
modal.style.bottom = "0";
|
|
modal.style.display = "flex";
|
|
modal.style.alignItems = "center";
|
|
modal.style.justifyContent = "center";
|
|
modal.style.background = "rgba(0,0,0,0.45)";
|
|
modal.style.zIndex = "9999";
|
|
|
|
const box = document.createElement("div");
|
|
box.style.background = "#fff";
|
|
box.style.padding = "16px";
|
|
box.style.borderRadius = "8px";
|
|
box.style.minWidth = "320px";
|
|
box.style.textAlign = "center";
|
|
const title = document.createElement("div");
|
|
title.textContent = "Choisir la promotion";
|
|
title.style.fontWeight = "700";
|
|
title.style.marginBottom = "8px";
|
|
box.appendChild(title);
|
|
|
|
const desc = document.createElement("div");
|
|
desc.textContent = `Promouvoir le pion en:`;
|
|
desc.style.marginBottom = "12px";
|
|
box.appendChild(desc);
|
|
|
|
const btnRow = document.createElement("div");
|
|
btnRow.style.display = "flex";
|
|
btnRow.style.gap = "12px";
|
|
btnRow.style.justifyContent = "center";
|
|
btnRow.style.marginBottom = "8px";
|
|
|
|
// determine the piece color at the square (should be a pawn)
|
|
const piece =
|
|
currentBoardState &&
|
|
Array.isArray(currentBoardState.pieces) &&
|
|
currentBoardState.pieces.find((p) => p.square === square);
|
|
const colorPrefix =
|
|
piece && piece.color
|
|
? piece.color === "w"
|
|
? "w"
|
|
: "b"
|
|
: (myColor && myColor[0]) || "w";
|
|
const promoTypes = [
|
|
["q", "Dame"],
|
|
["r", "Tour"],
|
|
["b", "Fou"],
|
|
["n", "Cavalier"],
|
|
];
|
|
promoTypes.forEach(([code, label]) => {
|
|
const btn = document.createElement("button");
|
|
btn.type = "button";
|
|
btn.style.padding = "6px";
|
|
btn.style.border = "1px solid #ccc";
|
|
btn.style.background = "#fff";
|
|
btn.style.cursor = "pointer";
|
|
btn.style.display = "flex";
|
|
btn.style.flexDirection = "column";
|
|
btn.style.alignItems = "center";
|
|
btn.style.gap = "6px";
|
|
// image
|
|
const letter = code.toUpperCase();
|
|
const img = document.createElement("img");
|
|
img.src = `${pieceSetPath}/${colorPrefix}${letter}.svg`;
|
|
img.alt = label;
|
|
img.style.width = "48px";
|
|
img.style.height = "48px";
|
|
img.style.display = "block";
|
|
// if image fails, show text label
|
|
img.addEventListener("error", () => {
|
|
img.style.display = "none";
|
|
});
|
|
const lbl = document.createElement("div");
|
|
lbl.textContent = label;
|
|
lbl.style.fontSize = "12px";
|
|
btn.appendChild(img);
|
|
btn.appendChild(lbl);
|
|
btn.addEventListener("click", (ev) => {
|
|
ev.stopPropagation();
|
|
try {
|
|
modal.remove();
|
|
} catch (_) {}
|
|
callback(code);
|
|
});
|
|
btnRow.appendChild(btn);
|
|
});
|
|
box.appendChild(btnRow);
|
|
|
|
const cancel = document.createElement("button");
|
|
cancel.type = "button";
|
|
cancel.textContent = "Annuler";
|
|
cancel.style.marginTop = "6px";
|
|
cancel.style.padding = "8px 12px";
|
|
cancel.addEventListener("click", (ev) => {
|
|
ev.stopPropagation();
|
|
try {
|
|
modal.remove();
|
|
} catch (_) {}
|
|
callback(null);
|
|
});
|
|
box.appendChild(cancel);
|
|
|
|
modal.appendChild(box);
|
|
document.body.appendChild(modal);
|
|
} catch (e) {
|
|
console.warn("showPromotionModal error", e);
|
|
callback(null);
|
|
}
|
|
}
|
|
|
|
// show a modal to pick a player to steal from. handCounts is an object mapping playerId -> count
|
|
function showStealModal(handCounts, callback) {
|
|
try {
|
|
if (document.getElementById("stealModal")) return;
|
|
const modal = document.createElement("div");
|
|
modal.id = "stealModal";
|
|
modal.style.position = "fixed";
|
|
modal.style.left = "0";
|
|
modal.style.top = "0";
|
|
modal.style.right = "0";
|
|
modal.style.bottom = "0";
|
|
modal.style.display = "flex";
|
|
modal.style.alignItems = "center";
|
|
modal.style.justifyContent = "center";
|
|
modal.style.background = "rgba(0,0,0,0.45)";
|
|
modal.style.zIndex = "9999";
|
|
const box = document.createElement("div");
|
|
box.style.background = "#fff";
|
|
box.style.padding = "12px";
|
|
box.style.borderRadius = "8px";
|
|
box.style.minWidth = "320px";
|
|
const title = document.createElement("div");
|
|
title.textContent = "Choisir le joueur à voler";
|
|
title.style.fontWeight = "700";
|
|
title.style.marginBottom = "8px";
|
|
box.appendChild(title);
|
|
const list = document.createElement("div");
|
|
list.style.display = "flex";
|
|
list.style.flexDirection = "column";
|
|
list.style.gap = "8px";
|
|
list.style.marginBottom = "8px";
|
|
Object.entries(handCounts || {}).forEach(([pid, count]) => {
|
|
if (pid === myPlayerId) return; // skip self
|
|
const row = document.createElement("div");
|
|
row.style.display = "flex";
|
|
row.style.justifyContent = "space-between";
|
|
row.style.alignItems = "center";
|
|
const label = document.createElement("div");
|
|
label.textContent = `${pid} — ${count} carte(s)`;
|
|
label.style.fontWeight = "600";
|
|
const btn = document.createElement("button");
|
|
btn.type = "button";
|
|
btn.textContent = "Voler";
|
|
btn.style.padding = "6px 10px";
|
|
btn.addEventListener("click", (ev) => {
|
|
ev.stopPropagation();
|
|
try {
|
|
modal.remove();
|
|
} catch (_) {}
|
|
callback(pid);
|
|
});
|
|
row.appendChild(label);
|
|
row.appendChild(btn);
|
|
list.appendChild(row);
|
|
});
|
|
box.appendChild(list);
|
|
const cancel = document.createElement("button");
|
|
cancel.type = "button";
|
|
cancel.textContent = "Annuler";
|
|
cancel.style.padding = "8px 10px";
|
|
cancel.addEventListener("click", (ev) => {
|
|
ev.stopPropagation();
|
|
try {
|
|
modal.remove();
|
|
} catch (_) {}
|
|
callback(null);
|
|
});
|
|
box.appendChild(cancel);
|
|
modal.appendChild(box);
|
|
document.body.appendChild(modal);
|
|
} catch (e) {
|
|
console.warn("showStealModal error", e);
|
|
callback(null);
|
|
}
|
|
}
|
|
</script>
|
|
</div>
|
|
</body>
|
|
</html>
|