Files
overlord/plugins/connect-four/connect-four.js
T

253 lines
9.5 KiB
JavaScript
Raw Normal View History

2026-07-30 22:31:13 -05:00
(() => {
const board = document.getElementById("c4-board");
if (!board || board.dataset.ready === "true") return;
board.dataset.ready = "true";
const columnsElement = document.getElementById("c4-columns");
const statusElement = document.getElementById("c4-status");
const turnElement = document.getElementById("c4-turn");
const playerRElement = document.getElementById("c4-player-r");
const playerYElement = document.getElementById("c4-player-y");
const challengesElement = document.getElementById("c4-challenges");
const emptyElement = document.getElementById("c4-empty");
const newButton = document.getElementById("c4-new");
const refreshButton = document.getElementById("c4-refresh");
const lobbyElement = document.querySelector(".c4-lobby");
const clientId = new URLSearchParams(window.location.search).get("clientId")?.trim() || "";
const directClientMode = Boolean(clientId);
if (directClientMode) {
lobbyElement.hidden = true;
newButton.textContent = "New match";
const subtitle = document.querySelector(".c4-subtitle");
if (subtitle) subtitle.textContent = `Playing against ${clientId}.`;
}
let currentGame = null;
let celebratedGame = "";
let busy = false;
const cells = [];
const dropButtons = [];
async function rpc(method, params = {}) {
const response = await fetch("/api/plugins/connect-four/rpc", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ method, params }),
});
const body = await response.json();
if (!response.ok || body.ok === false) throw new Error(body.error || response.statusText);
return body.result;
}
async function syncClient(game) {
if (!directClientMode || !game) return;
const response = await fetch(`/api/clients/${encodeURIComponent(clientId)}/plugins/connect-four/event`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ event: "state", payload: game }),
});
if (!response.ok) throw new Error(`Client sync failed: ${response.statusText}`);
}
function celebrateWinner(game) {
const celebrationId = game?.status === "won" && game.winner === game.yourColor
? `${game.id}:${game.winner}`
: "";
if (!celebrationId || celebrationId === celebratedGame) return;
celebratedGame = celebrationId;
if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) return;
const colors = game.winner === "R"
? ["#ef4444", "#fca5a5", "#ffffff"]
: ["#facc15", "#fef08a", "#ffffff"];
const layer = document.createElement("div");
layer.className = "c4-confetti";
layer.setAttribute("aria-hidden", "true");
for (let index = 0; index < 72; index += 1) {
const piece = document.createElement("i");
piece.style.left = `${(index * 37) % 100}%`;
piece.style.background = colors[index % colors.length];
piece.style.animationDelay = `${(index % 12) * 35}ms`;
piece.style.animationDuration = `${900 + (index % 7) * 90}ms`;
layer.append(piece);
}
document.body.append(layer);
window.setTimeout(() => layer.remove(), 1800);
}
function createBoard() {
const controls = document.createDocumentFragment();
for (let column = 0; column < 7; column += 1) {
const button = document.createElement("button");
button.type = "button";
button.className = "c4-drop";
button.dataset.column = String(column);
button.textContent = "▼";
button.setAttribute("aria-label", `Drop in column ${column + 1}`);
dropButtons.push(button);
controls.append(button);
}
columnsElement.append(controls);
const fragment = document.createDocumentFragment();
for (let index = 0; index < 42; index += 1) {
const cell = document.createElement("div");
const disc = document.createElement("span");
cell.className = "c4-cell";
cell.dataset.column = String(index % 7);
cell.setAttribute("aria-label", `Drop in column ${(index % 7) + 1}`);
cell.setAttribute("role", "gridcell");
disc.className = "c4-disc";
cell.append(disc);
cells.push(disc);
fragment.append(cell);
}
board.append(fragment);
}
function renderGame(game) {
currentGame = game || null;
const values = game?.board || Array(42).fill("");
const canMove = game?.status === "active" && game.yourColor === game.turn;
cells.forEach((disc, index) => {
const color = values[index];
disc.className = `c4-disc${color === "R" ? " red" : color === "Y" ? " yellow" : ""}`;
disc.parentElement.classList.toggle("full-column", Boolean(values[index % 7]));
});
dropButtons.forEach((button, column) => {
button.disabled = !canMove || Boolean(values[column]);
});
board.classList.toggle("can-drop", canMove);
playerRElement.textContent = `Red · ${game?.playerR?.username || "Waiting"}`;
playerYElement.textContent = `Yellow · ${game?.playerY?.username || "Waiting"}`;
if (!game) {
turnElement.textContent = "No active match";
statusElement.textContent = "Create or accept a challenge to play.";
return;
}
turnElement.textContent = game.message;
if (game.status === "open") statusElement.textContent = "Challenge created. Waiting for another user.";
else if (game.status === "active") statusElement.textContent = canMove ? `Your turn as ${game.yourColor === "R" ? "Red" : "Yellow"}.` : "Waiting for your opponent.";
else if (game.status === "won") statusElement.textContent = game.winner === game.yourColor ? "You won." : "You lost.";
else statusElement.textContent = "The match ended in a draw.";
celebrateWinner(game);
}
function renderChallenges(challenges) {
challengesElement.replaceChildren();
const rows = Array.isArray(challenges) ? challenges : [];
emptyElement.hidden = rows.length > 0;
for (const challenge of rows) {
const row = document.createElement("div");
const label = document.createElement("span");
row.className = "c4-challenge";
label.textContent = `${challenge.username} · ${challenge.role}`;
row.append(label);
if (challenge.canAccept) {
const button = document.createElement("button");
button.type = "button";
button.textContent = "Accept";
button.addEventListener("click", () => void acceptChallenge(challenge.id));
row.append(button);
} else {
const waiting = document.createElement("span");
waiting.textContent = "Your challenge";
row.append(waiting);
}
challengesElement.append(row);
}
}
async function refresh() {
try {
const result = await rpc("snapshot", { gameId: currentGame?.id });
renderGame(result.game);
renderChallenges(result.challenges);
if (directClientMode && result.game) await syncClient(result.game);
} catch (error) {
statusElement.textContent = `Refresh failed: ${error instanceof Error ? error.message : String(error)}`;
}
}
async function startClientMatch() {
if (busy) return;
busy = true;
newButton.disabled = true;
try {
const result = await rpc("startClientMatch", { clientId, clientName: clientId });
renderGame(result.game);
await syncClient(result.game);
} catch (error) {
statusElement.textContent = `Could not start client match: ${error instanceof Error ? error.message : String(error)}`;
} finally {
busy = false;
newButton.disabled = false;
}
}
async function createChallenge() {
if (busy) return;
busy = true;
newButton.disabled = true;
try {
const result = await rpc("createChallenge");
renderGame(result.game);
await refresh();
} catch (error) {
statusElement.textContent = `Could not create challenge: ${error instanceof Error ? error.message : String(error)}`;
} finally {
busy = false;
newButton.disabled = false;
}
}
async function acceptChallenge(gameId) {
if (busy) return;
busy = true;
try {
const result = await rpc("acceptChallenge", { gameId });
renderGame(result.game);
await refresh();
} catch (error) {
statusElement.textContent = `Could not accept challenge: ${error instanceof Error ? error.message : String(error)}`;
} finally {
busy = false;
}
}
async function drop(column) {
if (busy || !currentGame) return;
busy = true;
try {
const result = await rpc("move", { gameId: currentGame.id, column });
renderGame(result.game);
await syncClient(result.game);
} catch (error) {
statusElement.textContent = `Move rejected: ${error instanceof Error ? error.message : String(error)}`;
} finally {
busy = false;
}
}
columnsElement.addEventListener("click", (event) => {
const button = event.target.closest(".c4-drop");
if (button) void drop(Number(button.dataset.column));
});
board.addEventListener("click", (event) => {
const cell = event.target.closest(".c4-cell");
if (!cell || !currentGame || currentGame.status !== "active" || currentGame.yourColor !== currentGame.turn) return;
const column = Number(cell.dataset.column);
if (!currentGame.board?.[column]) void drop(column);
});
newButton.addEventListener("click", () => void (directClientMode ? startClientMatch() : createChallenge()));
refreshButton.addEventListener("click", () => void refresh());
try {
const stream = new EventSource("/api/plugins/connect-four/stream");
stream.addEventListener("changed", () => void refresh());
} catch {}
createBoard();
void (directClientMode ? startClientMatch() : refresh());
})();