small plugin changes + make minesweeper and snake plugin
This commit is contained in:
@@ -1,5 +1,6 @@
|
||||
export type PluginRpcCaller = {
|
||||
id: number;
|
||||
username: string;
|
||||
role: string;
|
||||
};
|
||||
|
||||
|
||||
@@ -11,7 +11,7 @@ declare const self: {
|
||||
type PluginExports = {
|
||||
setup?: (ctx: PluginContext) => unknown | Promise<unknown>;
|
||||
onEvent?: (ctx: PluginContext, clientId: string, event: string, payload: unknown) => unknown | Promise<unknown>;
|
||||
rpc?: Record<string, (ctx: PluginContext, params: unknown, meta: { caller: { id: number; role: string } }) => unknown | Promise<unknown>>;
|
||||
rpc?: Record<string, (ctx: PluginContext, params: unknown, meta: { caller: { id: number; username: string; role: string } }) => unknown | Promise<unknown>>;
|
||||
buildHooks?: Record<string, (ctx: PluginContext, payload: unknown) => unknown | Promise<unknown>>;
|
||||
onBuildPrepare?: (ctx: PluginContext, payload: unknown) => unknown | Promise<unknown>;
|
||||
onBuildTarget?: (ctx: PluginContext, payload: unknown) => unknown | Promise<unknown>;
|
||||
|
||||
@@ -278,7 +278,7 @@ export async function handlePluginRoutes(
|
||||
plugin.id,
|
||||
"dashboardContributions",
|
||||
{ clientIds: authorizedClientIds },
|
||||
{ id: user.userId, role: user.role },
|
||||
{ id: user.userId, username: user.username, role: user.role },
|
||||
);
|
||||
const rows = Array.isArray(result?.contributions) ? result.contributions : Array.isArray(result) ? result : [];
|
||||
for (const row of rows) {
|
||||
@@ -1293,6 +1293,7 @@ export async function handlePluginRoutes(
|
||||
try {
|
||||
const result = await deps.pluginRuntime.rpc(pluginId, method, body.params, {
|
||||
id: user.userId,
|
||||
username: user.username,
|
||||
role: user.role,
|
||||
});
|
||||
metrics.recordCommand("plugin_rpc");
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
@echo off
|
||||
setlocal
|
||||
|
||||
set "PLUGIN_DIR=%~dp0"
|
||||
set "PLUGIN_NAME=minesweeper"
|
||||
set "ZIP_OUT=%PLUGIN_DIR%%PLUGIN_NAME%.zip"
|
||||
|
||||
for %%F in (config.json minesweeper.html minesweeper.css minesweeper.js server.js) do (
|
||||
if not exist "%PLUGIN_DIR%%%F" (
|
||||
echo [error] Missing %%F
|
||||
exit /b 1
|
||||
)
|
||||
)
|
||||
|
||||
if exist "%ZIP_OUT%" del /f /q "%ZIP_OUT%"
|
||||
pushd "%PLUGIN_DIR%"
|
||||
powershell -NoProfile -ExecutionPolicy Bypass -Command "Compress-Archive -Path @('config.json','minesweeper.html','minesweeper.css','minesweeper.js','server.js') -DestinationPath '%ZIP_OUT%' -Force"
|
||||
set "ZIP_STATUS=%ERRORLEVEL%"
|
||||
popd
|
||||
if not "%ZIP_STATUS%"=="0" exit /b %ZIP_STATUS%
|
||||
|
||||
echo [ok] %ZIP_OUT%
|
||||
@@ -0,0 +1,73 @@
|
||||
body {
|
||||
margin: 0;
|
||||
min-height: 100vh;
|
||||
background: #080d16;
|
||||
color: #e5e7eb;
|
||||
font-family: Inter, system-ui, sans-serif;
|
||||
}
|
||||
|
||||
.mine-shell { max-width: 1120px; margin: 0 auto; padding: 16px; }
|
||||
.mine-header,
|
||||
.mine-leaderboard-heading { display: flex; align-items: center; justify-content: space-between; gap: 16px; }
|
||||
.mine-header { margin-bottom: 16px; }
|
||||
.mine-header h2 { margin: 0; font-size: 32px; }
|
||||
.mine-kicker { margin: 0 0 4px; color: #facc15; font-size: 11px; font-weight: 700; letter-spacing: 2px; }
|
||||
.mine-subtitle,
|
||||
.mine-status,
|
||||
.mine-help,
|
||||
.mine-empty { color: #94a3b8; font-size: 13px; }
|
||||
.mine-subtitle { margin: 4px 0 0; }
|
||||
|
||||
.mine-counters { display: flex; border: 1px solid #334155; }
|
||||
.mine-counters div { min-width: 72px; padding: 8px 12px; text-align: center; }
|
||||
.mine-counters div + div { border-left: 1px solid #334155; }
|
||||
.mine-counters span { display: block; color: #94a3b8; font-size: 10px; text-transform: uppercase; }
|
||||
.mine-counters strong { display: block; color: #fde047; font-size: 22px; }
|
||||
|
||||
.mine-toolbar { display: flex; align-items: center; justify-content: center; gap: 8px; margin-bottom: 16px; }
|
||||
.mine-toolbar label { color: #94a3b8; font-size: 12px; }
|
||||
.mine-toolbar select,
|
||||
.mine-toolbar button,
|
||||
.mine-leaderboard button { border: 1px solid #475569; border-radius: 0; padding: 8px 10px; background: #111827; color: #e5e7eb; }
|
||||
.mine-toolbar button,
|
||||
.mine-leaderboard button { cursor: pointer; }
|
||||
.mine-toolbar button:hover,
|
||||
.mine-leaderboard button:hover { background: #1e293b; }
|
||||
|
||||
.mine-layout { display: flex; flex-direction: column; align-items: center; gap: 24px; }
|
||||
.mine-board-wrap { box-sizing: border-box; width: max-content; max-width: 100%; overflow: auto; border: 8px solid #1e293b; background: #0f172a; padding: 8px; }
|
||||
.mine-board { display: grid; width: max-content; gap: 1px; background: #334155; }
|
||||
.mine-cell { display: flex; align-items: center; justify-content: center; width: var(--cell-size); height: var(--cell-size); border: 0; border-radius: 0; padding: 0; background: #475569; color: #e5e7eb; font-size: calc(var(--cell-size) * 0.5); font-weight: 700; cursor: pointer; }
|
||||
.mine-cell:hover:not(:disabled) { background: #64748b; }
|
||||
.mine-cell.revealed { background: #111827; cursor: default; }
|
||||
.mine-cell.flagged { color: #facc15; }
|
||||
.mine-cell.mine-hit { background: #991b1b; color: #fff; }
|
||||
.mine-cell.mine-shown { background: #3f1d24; color: #fca5a5; }
|
||||
.mine-cell.value-1 { color: #60a5fa; }
|
||||
.mine-cell.value-2 { color: #4ade80; }
|
||||
.mine-cell.value-3 { color: #f87171; }
|
||||
.mine-cell.value-4 { color: #a78bfa; }
|
||||
.mine-cell.value-5,
|
||||
.mine-cell.value-6,
|
||||
.mine-cell.value-7,
|
||||
.mine-cell.value-8 { color: #fbbf24; }
|
||||
.mine-status { min-height: 18px; margin: 8px 0 0; }
|
||||
.mine-help { margin: 2px 0 0; }
|
||||
|
||||
.mine-leaderboard { box-sizing: border-box; width: min(100%, 480px); padding: 12px; border: 1px solid #334155; background: #0c1420; }
|
||||
.mine-leaderboard h3 { margin: 0; font-size: 18px; }
|
||||
.mine-leaderboard button { padding: 6px 8px; font-size: 11px; }
|
||||
.mine-leaderboard ol { list-style: none; margin: 12px 0 0; padding: 0; }
|
||||
.mine-leaderboard li { display: grid; grid-template-columns: 24px 1fr auto; gap: 8px; align-items: center; min-height: 38px; border-bottom: 1px solid #1e293b; font-size: 12px; }
|
||||
.mine-rank { color: #64748b; }
|
||||
.mine-player { overflow: hidden; color: #cbd5e1; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.mine-result { color: #fde047; text-align: right; }
|
||||
.mine-result small { display: block; color: #64748b; }
|
||||
|
||||
|
||||
@media (max-width: 520px) {
|
||||
.mine-shell { padding: 8px; }
|
||||
.mine-subtitle { display: none; }
|
||||
.mine-toolbar { align-items: stretch; flex-direction: column; }
|
||||
.mine-counters div { min-width: 54px; padding: 6px; }
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Minesweeper</title>
|
||||
<link rel="stylesheet" href="minesweeper.css">
|
||||
</head>
|
||||
<body>
|
||||
<main class="mine-shell">
|
||||
<header class="mine-header">
|
||||
<div>
|
||||
<p class="mine-kicker">SERVER ARCADE</p>
|
||||
<h2>Minesweeper</h2>
|
||||
<p class="mine-subtitle">Clear the field without touching a mine.</p>
|
||||
</div>
|
||||
<div class="mine-counters" aria-live="polite">
|
||||
<div><span>Mines</span><strong id="mine-count">10</strong></div>
|
||||
<div><span>Time</span><strong id="mine-time">0</strong></div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="mine-toolbar">
|
||||
<label for="mine-difficulty">Difficulty</label>
|
||||
<select id="mine-difficulty">
|
||||
<option value="beginner">Beginner · 9 × 9 · 10 mines</option>
|
||||
<option value="intermediate">Intermediate · 16 × 16 · 40 mines</option>
|
||||
<option value="expert">Expert · 30 × 16 · 99 mines</option>
|
||||
</select>
|
||||
<button id="mine-new-game" type="button">New game</button>
|
||||
</div>
|
||||
|
||||
<div class="mine-layout">
|
||||
<section class="mine-game" aria-label="Minesweeper game">
|
||||
<div class="mine-board-wrap">
|
||||
<div id="mine-board" class="mine-board" role="grid" aria-label="Mine field"></div>
|
||||
</div>
|
||||
<p id="mine-status" class="mine-status" role="status">Choose a difficulty and start a game.</p>
|
||||
<p class="mine-help">Left click to reveal. Right click to flag.</p>
|
||||
</section>
|
||||
|
||||
<aside class="mine-leaderboard">
|
||||
<div class="mine-leaderboard-heading">
|
||||
<div>
|
||||
<p class="mine-kicker">BEST TIMES</p>
|
||||
<h3>Leaderboard</h3>
|
||||
</div>
|
||||
<button id="mine-refresh" type="button" aria-label="Refresh leaderboard">Refresh</button>
|
||||
</div>
|
||||
<ol id="mine-leaderboard-list"></ol>
|
||||
<p id="mine-leaderboard-empty" class="mine-empty">No wins at this difficulty.</p>
|
||||
</aside>
|
||||
</div>
|
||||
</main>
|
||||
<script src="minesweeper.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,245 @@
|
||||
(() => {
|
||||
const board = document.getElementById("mine-board");
|
||||
if (!board || board.dataset.ready === "true") return;
|
||||
board.dataset.ready = "true";
|
||||
|
||||
const difficultySelect = document.getElementById("mine-difficulty");
|
||||
const newGameButton = document.getElementById("mine-new-game");
|
||||
const refreshButton = document.getElementById("mine-refresh");
|
||||
const mineCount = document.getElementById("mine-count");
|
||||
const timeElement = document.getElementById("mine-time");
|
||||
const statusElement = document.getElementById("mine-status");
|
||||
const leaderboardList = document.getElementById("mine-leaderboard-list");
|
||||
const leaderboardEmpty = document.getElementById("mine-leaderboard-empty");
|
||||
|
||||
let game = null;
|
||||
let timer = null;
|
||||
let busy = false;
|
||||
|
||||
async function rpc(method, params = {}) {
|
||||
const response = await fetch("/api/plugins/minesweeper/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;
|
||||
}
|
||||
|
||||
function setStatus(message) {
|
||||
statusElement.textContent = message;
|
||||
}
|
||||
|
||||
function formatTime(milliseconds) {
|
||||
return `${(Math.max(0, Number(milliseconds) || 0) / 1000).toFixed(1)}s`;
|
||||
}
|
||||
|
||||
function updateClock() {
|
||||
if (!game?.startedAt || game.status !== "playing") return;
|
||||
timeElement.textContent = String(Math.floor((Date.now() - game.startedAt) / 1000));
|
||||
}
|
||||
|
||||
function renderLeaderboard(entries) {
|
||||
leaderboardList.replaceChildren();
|
||||
const safeEntries = Array.isArray(entries) ? entries : [];
|
||||
leaderboardEmpty.hidden = safeEntries.length > 0;
|
||||
safeEntries.forEach((entry, index) => {
|
||||
const item = document.createElement("li");
|
||||
const rank = document.createElement("span");
|
||||
const player = document.createElement("span");
|
||||
const result = document.createElement("strong");
|
||||
const moves = document.createElement("small");
|
||||
rank.className = "mine-rank";
|
||||
player.className = "mine-player";
|
||||
result.className = "mine-result";
|
||||
rank.textContent = String(index + 1).padStart(2, "0");
|
||||
player.textContent = String(entry.player || "Player");
|
||||
result.textContent = formatTime(entry.timeMs);
|
||||
moves.textContent = `${Number(entry.moves) || 0} moves`;
|
||||
result.append(moves);
|
||||
item.append(rank, player, result);
|
||||
leaderboardList.append(item);
|
||||
});
|
||||
}
|
||||
|
||||
async function loadLeaderboard() {
|
||||
try {
|
||||
const result = await rpc("leaderboard", { difficulty: difficultySelect.value });
|
||||
renderLeaderboard(result.entries);
|
||||
} catch (error) {
|
||||
setStatus(`Leaderboard unavailable: ${error instanceof Error ? error.message : String(error)}`);
|
||||
}
|
||||
}
|
||||
|
||||
function updateMineCounter() {
|
||||
if (!game) return;
|
||||
mineCount.textContent = String(Math.max(0, game.mines - game.flags.size));
|
||||
}
|
||||
|
||||
function buildBoard() {
|
||||
board.replaceChildren();
|
||||
board.style.gridTemplateColumns = `repeat(${game.width}, var(--cell-size))`;
|
||||
board.style.setProperty("--cell-size", game.width >= 30 ? "25px" : game.width >= 16 ? "28px" : "34px");
|
||||
const fragment = document.createDocumentFragment();
|
||||
game.cells = [];
|
||||
for (let y = 0; y < game.height; y += 1) {
|
||||
for (let x = 0; x < game.width; x += 1) {
|
||||
const cell = document.createElement("button");
|
||||
cell.type = "button";
|
||||
cell.className = "mine-cell";
|
||||
cell.dataset.x = String(x);
|
||||
cell.dataset.y = String(y);
|
||||
cell.setAttribute("role", "gridcell");
|
||||
cell.setAttribute("aria-label", `Hidden cell ${x + 1}, ${y + 1}`);
|
||||
game.cells.push(cell);
|
||||
fragment.append(cell);
|
||||
}
|
||||
}
|
||||
board.append(fragment);
|
||||
}
|
||||
|
||||
function renderCells(cells, hit) {
|
||||
for (const data of Array.isArray(cells) ? cells : []) {
|
||||
const index = Number(data.y) * game.width + Number(data.x);
|
||||
const cell = game.cells[index];
|
||||
if (!cell) continue;
|
||||
game.revealed.add(index);
|
||||
game.flags.delete(index);
|
||||
cell.className = "mine-cell revealed";
|
||||
cell.disabled = true;
|
||||
if (data.mine) {
|
||||
const isHit = hit && Number(hit.x) === Number(data.x) && Number(hit.y) === Number(data.y);
|
||||
cell.classList.add(isHit ? "mine-hit" : "mine-shown");
|
||||
cell.textContent = "*";
|
||||
cell.setAttribute("aria-label", isHit ? "Triggered mine" : "Mine");
|
||||
} else {
|
||||
const value = Number(data.value) || 0;
|
||||
if (value) {
|
||||
cell.classList.add(`value-${value}`);
|
||||
cell.textContent = String(value);
|
||||
} else {
|
||||
cell.textContent = "";
|
||||
}
|
||||
cell.setAttribute("aria-label", value ? `${value} adjacent mines` : "Clear cell");
|
||||
}
|
||||
}
|
||||
updateMineCounter();
|
||||
}
|
||||
|
||||
function finish(status, elapsedMs, entries) {
|
||||
game.status = status;
|
||||
clearInterval(timer);
|
||||
timer = null;
|
||||
timeElement.textContent = String(Math.floor(elapsedMs / 1000));
|
||||
for (const cell of game.cells) cell.disabled = true;
|
||||
if (status === "won") {
|
||||
setStatus(`Field cleared in ${formatTime(elapsedMs)}.`);
|
||||
if (entries) renderLeaderboard(entries);
|
||||
} else {
|
||||
setStatus("Mine triggered. Start a new game to try again.");
|
||||
}
|
||||
}
|
||||
|
||||
async function reveal(x, y) {
|
||||
if (!game || game.status !== "playing" || busy) return;
|
||||
const index = y * game.width + x;
|
||||
if (game.flags.has(index) || game.revealed.has(index)) return;
|
||||
busy = true;
|
||||
if (!game.startedAt) {
|
||||
game.startedAt = Date.now();
|
||||
timer = setInterval(updateClock, 250);
|
||||
}
|
||||
try {
|
||||
const result = await rpc("reveal", { gameId: game.id, x, y });
|
||||
game.startedAt = Date.now() - Number(result.elapsedMs || 0);
|
||||
renderCells(result.cells, result.hit);
|
||||
if (result.status === "won" || result.status === "lost") {
|
||||
finish(result.status, Number(result.elapsedMs || 0), result.entries);
|
||||
} else {
|
||||
setStatus(`${Number(result.moves) || 0} moves.`);
|
||||
}
|
||||
} catch (error) {
|
||||
setStatus(`Reveal failed: ${error instanceof Error ? error.message : String(error)}`);
|
||||
} finally {
|
||||
busy = false;
|
||||
}
|
||||
}
|
||||
|
||||
function toggleFlag(x, y) {
|
||||
if (!game || game.status !== "playing" || busy) return;
|
||||
const index = y * game.width + x;
|
||||
if (game.revealed.has(index)) return;
|
||||
const cell = game.cells[index];
|
||||
if (game.flags.has(index)) {
|
||||
game.flags.delete(index);
|
||||
cell.classList.remove("flagged");
|
||||
cell.textContent = "";
|
||||
cell.setAttribute("aria-label", `Hidden cell ${x + 1}, ${y + 1}`);
|
||||
} else if (game.flags.size < game.mines) {
|
||||
game.flags.add(index);
|
||||
cell.classList.add("flagged");
|
||||
cell.textContent = "!";
|
||||
cell.setAttribute("aria-label", `Flagged cell ${x + 1}, ${y + 1}`);
|
||||
}
|
||||
updateMineCounter();
|
||||
}
|
||||
|
||||
async function startGame() {
|
||||
newGameButton.disabled = true;
|
||||
clearInterval(timer);
|
||||
timer = null;
|
||||
setStatus("Creating a server game…");
|
||||
try {
|
||||
const session = await rpc("start", { difficulty: difficultySelect.value });
|
||||
game = {
|
||||
id: session.gameId,
|
||||
difficulty: session.difficulty,
|
||||
width: Number(session.width),
|
||||
height: Number(session.height),
|
||||
mines: Number(session.mines),
|
||||
cells: [],
|
||||
flags: new Set(),
|
||||
revealed: new Set(),
|
||||
status: "playing",
|
||||
startedAt: 0,
|
||||
};
|
||||
mineCount.textContent = String(game.mines);
|
||||
timeElement.textContent = "0";
|
||||
buildBoard();
|
||||
setStatus("Game ready. The first cell is always safe.");
|
||||
await loadLeaderboard();
|
||||
} catch (error) {
|
||||
setStatus(`Could not start: ${error instanceof Error ? error.message : String(error)}`);
|
||||
} finally {
|
||||
newGameButton.disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
board.addEventListener("click", (event) => {
|
||||
const cell = event.target.closest(".mine-cell");
|
||||
if (!cell) return;
|
||||
void reveal(Number(cell.dataset.x), Number(cell.dataset.y));
|
||||
});
|
||||
board.addEventListener("contextmenu", (event) => {
|
||||
const cell = event.target.closest(".mine-cell");
|
||||
if (!cell) return;
|
||||
event.preventDefault();
|
||||
toggleFlag(Number(cell.dataset.x), Number(cell.dataset.y));
|
||||
});
|
||||
newGameButton.addEventListener("click", () => void startGame());
|
||||
difficultySelect.addEventListener("change", () => void startGame());
|
||||
refreshButton.addEventListener("click", () => void loadLeaderboard());
|
||||
|
||||
try {
|
||||
const stream = new EventSource("/api/plugins/minesweeper/stream");
|
||||
stream.addEventListener("leaderboard", (event) => {
|
||||
try {
|
||||
const payload = JSON.parse(event.data);
|
||||
if (payload.difficulty === difficultySelect.value) renderLeaderboard(payload.entries);
|
||||
} catch {}
|
||||
});
|
||||
} catch {}
|
||||
|
||||
void startGame();
|
||||
})();
|
||||
@@ -0,0 +1,257 @@
|
||||
const DIFFICULTIES = {
|
||||
beginner: { width: 9, height: 9, mines: 10 },
|
||||
intermediate: { width: 16, height: 16, mines: 40 },
|
||||
expert: { width: 30, height: 16, mines: 99 },
|
||||
};
|
||||
const GAME_TTL_MS = 24 * 60 * 60 * 1000;
|
||||
|
||||
function parseSet(json) {
|
||||
try {
|
||||
const values = JSON.parse(json || "[]");
|
||||
return new Set(Array.isArray(values) ? values.map(Number).filter(Number.isInteger) : []);
|
||||
} catch {
|
||||
return new Set();
|
||||
}
|
||||
}
|
||||
|
||||
function adjacent(index, width, height) {
|
||||
const x = index % width;
|
||||
const y = Math.floor(index / width);
|
||||
const result = [];
|
||||
for (let dy = -1; dy <= 1; dy += 1) {
|
||||
for (let dx = -1; dx <= 1; dx += 1) {
|
||||
if (dx === 0 && dy === 0) continue;
|
||||
const nx = x + dx;
|
||||
const ny = y + dy;
|
||||
if (nx >= 0 && ny >= 0 && nx < width && ny < height) result.push(ny * width + nx);
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function generateMines(width, height, mineCount, firstIndex) {
|
||||
const excluded = new Set([firstIndex, ...adjacent(firstIndex, width, height)]);
|
||||
const candidates = [];
|
||||
for (let index = 0; index < width * height; index += 1) {
|
||||
if (!excluded.has(index)) candidates.push(index);
|
||||
}
|
||||
const random = crypto.getRandomValues(new Uint32Array(candidates.length));
|
||||
for (let index = candidates.length - 1; index > 0; index -= 1) {
|
||||
const swapIndex = random[index] % (index + 1);
|
||||
[candidates[index], candidates[swapIndex]] = [candidates[swapIndex], candidates[index]];
|
||||
}
|
||||
return new Set(candidates.slice(0, mineCount));
|
||||
}
|
||||
|
||||
function cellValue(index, mines, width, height) {
|
||||
let count = 0;
|
||||
for (const neighbor of adjacent(index, width, height)) {
|
||||
if (mines.has(neighbor)) count += 1;
|
||||
}
|
||||
return count;
|
||||
}
|
||||
|
||||
function revealArea(firstIndex, revealed, mines, width, height) {
|
||||
const pending = [firstIndex];
|
||||
while (pending.length) {
|
||||
const index = pending.pop();
|
||||
if (revealed.has(index) || mines.has(index)) continue;
|
||||
revealed.add(index);
|
||||
if (cellValue(index, mines, width, height) !== 0) continue;
|
||||
for (const neighbor of adjacent(index, width, height)) {
|
||||
if (!revealed.has(neighbor) && !mines.has(neighbor)) pending.push(neighbor);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function visibleCells(revealed, mines, width, height, showMines) {
|
||||
const cells = [];
|
||||
for (const index of revealed) {
|
||||
cells.push({
|
||||
x: index % width,
|
||||
y: Math.floor(index / width),
|
||||
value: cellValue(index, mines, width, height),
|
||||
});
|
||||
}
|
||||
if (showMines) {
|
||||
for (const index of mines) {
|
||||
cells.push({ x: index % width, y: Math.floor(index / width), mine: true });
|
||||
}
|
||||
}
|
||||
return cells;
|
||||
}
|
||||
|
||||
function leaderboard(ctx, difficulty) {
|
||||
return ctx.db.prepare(`
|
||||
SELECT caller_id AS callerId, username, time_ms AS timeMs, moves, played_at AS playedAt
|
||||
FROM minesweeper_scores
|
||||
WHERE difficulty = ?
|
||||
ORDER BY time_ms ASC, moves ASC, played_at ASC
|
||||
LIMIT 10
|
||||
`).all(difficulty).map((row) => ({
|
||||
player: String(row.username || `Player #${row.callerId}`),
|
||||
timeMs: Number(row.timeMs),
|
||||
moves: Number(row.moves),
|
||||
playedAt: Number(row.playedAt),
|
||||
}));
|
||||
}
|
||||
|
||||
function getDifficulty(value) {
|
||||
const key = typeof value === "string" ? value : "beginner";
|
||||
if (!DIFFICULTIES[key]) throw new Error("Unknown difficulty");
|
||||
return { key, ...DIFFICULTIES[key] };
|
||||
}
|
||||
|
||||
export default {
|
||||
setup(ctx) {
|
||||
ctx.db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS minesweeper_games (
|
||||
id TEXT PRIMARY KEY,
|
||||
caller_id INTEGER NOT NULL,
|
||||
difficulty TEXT NOT NULL,
|
||||
width INTEGER NOT NULL,
|
||||
height INTEGER NOT NULL,
|
||||
mine_count INTEGER NOT NULL,
|
||||
mines_json TEXT,
|
||||
revealed_json TEXT NOT NULL DEFAULT '[]',
|
||||
moves INTEGER NOT NULL DEFAULT 0,
|
||||
created_at INTEGER NOT NULL,
|
||||
started_at INTEGER,
|
||||
finished INTEGER NOT NULL DEFAULT 0
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS minesweeper_scores (
|
||||
caller_id INTEGER NOT NULL,
|
||||
username TEXT,
|
||||
difficulty TEXT NOT NULL,
|
||||
time_ms INTEGER NOT NULL,
|
||||
moves INTEGER NOT NULL,
|
||||
played_at INTEGER NOT NULL,
|
||||
PRIMARY KEY (caller_id, difficulty)
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS minesweeper_games_created_at ON minesweeper_games(created_at);
|
||||
`);
|
||||
const scoreColumns = ctx.db.prepare("PRAGMA table_info(minesweeper_scores)").all();
|
||||
if (!scoreColumns.some((column) => column.name === "username")) {
|
||||
ctx.db.exec("ALTER TABLE minesweeper_scores ADD COLUMN username TEXT");
|
||||
}
|
||||
ctx.db.prepare("DELETE FROM minesweeper_games WHERE created_at < ?").run(Date.now() - GAME_TTL_MS);
|
||||
ctx.log.info("Minesweeper ready");
|
||||
},
|
||||
|
||||
rpc: {
|
||||
start(ctx, params, meta) {
|
||||
const difficulty = getDifficulty(params?.difficulty);
|
||||
const callerId = Number(meta.caller.id);
|
||||
const gameId = crypto.randomUUID();
|
||||
ctx.db.prepare("DELETE FROM minesweeper_games WHERE caller_id = ? AND finished = 0").run(callerId);
|
||||
ctx.db.prepare(`
|
||||
INSERT INTO minesweeper_games
|
||||
(id, caller_id, difficulty, width, height, mine_count, created_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||
`).run(gameId, callerId, difficulty.key, difficulty.width, difficulty.height, difficulty.mines, Date.now());
|
||||
return {
|
||||
gameId,
|
||||
difficulty: difficulty.key,
|
||||
width: difficulty.width,
|
||||
height: difficulty.height,
|
||||
mines: difficulty.mines,
|
||||
};
|
||||
},
|
||||
|
||||
leaderboard(ctx, params, meta) {
|
||||
const difficulty = getDifficulty(params?.difficulty);
|
||||
const username = String(meta.caller.username || `Player #${meta.caller.id}`).trim();
|
||||
ctx.db.prepare("UPDATE minesweeper_scores SET username = ? WHERE caller_id = ? AND difficulty = ?")
|
||||
.run(username, Number(meta.caller.id), difficulty.key);
|
||||
return { difficulty: difficulty.key, entries: leaderboard(ctx, difficulty.key) };
|
||||
},
|
||||
|
||||
reveal(ctx, params, meta) {
|
||||
const gameId = typeof params?.gameId === "string" ? params.gameId : "";
|
||||
const x = Number(params?.x);
|
||||
const y = Number(params?.y);
|
||||
const game = ctx.db.prepare(`
|
||||
SELECT id, caller_id AS callerId, difficulty, width, height,
|
||||
mine_count AS mineCount, mines_json AS minesJson,
|
||||
revealed_json AS revealedJson, moves, started_at AS startedAt, finished
|
||||
FROM minesweeper_games WHERE id = ?
|
||||
`).get(gameId);
|
||||
if (!game || Number(game.callerId) !== Number(meta.caller.id)) throw new Error("Game not found");
|
||||
if (Number(game.finished) !== 0) throw new Error("Game is already finished");
|
||||
|
||||
const width = Number(game.width);
|
||||
const height = Number(game.height);
|
||||
if (!Number.isInteger(x) || !Number.isInteger(y) || x < 0 || y < 0 || x >= width || y >= height) {
|
||||
throw new Error("Invalid cell");
|
||||
}
|
||||
|
||||
const index = y * width + x;
|
||||
const revealed = parseSet(game.revealedJson);
|
||||
let mines = game.minesJson ? parseSet(game.minesJson) : null;
|
||||
const startedAt = game.startedAt ? Number(game.startedAt) : Date.now();
|
||||
if (!mines) mines = generateMines(width, height, Number(game.mineCount), index);
|
||||
|
||||
if (revealed.has(index)) {
|
||||
return {
|
||||
status: "playing",
|
||||
cells: visibleCells(revealed, mines, width, height, false),
|
||||
moves: Number(game.moves),
|
||||
elapsedMs: Date.now() - startedAt,
|
||||
};
|
||||
}
|
||||
|
||||
const moves = Number(game.moves) + 1;
|
||||
if (mines.has(index)) {
|
||||
ctx.db.prepare(`
|
||||
UPDATE minesweeper_games
|
||||
SET mines_json = ?, started_at = ?, moves = ?, finished = 2
|
||||
WHERE id = ?
|
||||
`).run(JSON.stringify([...mines]), startedAt, moves, gameId);
|
||||
return {
|
||||
status: "lost",
|
||||
cells: visibleCells(revealed, mines, width, height, true),
|
||||
hit: { x, y },
|
||||
moves,
|
||||
elapsedMs: Date.now() - startedAt,
|
||||
};
|
||||
}
|
||||
|
||||
revealArea(index, revealed, mines, width, height);
|
||||
const won = revealed.size === width * height - Number(game.mineCount);
|
||||
const elapsedMs = Date.now() - startedAt;
|
||||
ctx.db.prepare(`
|
||||
UPDATE minesweeper_games
|
||||
SET mines_json = ?, revealed_json = ?, started_at = ?, moves = ?, finished = ?
|
||||
WHERE id = ?
|
||||
`).run(JSON.stringify([...mines]), JSON.stringify([...revealed]), startedAt, moves, won ? 1 : 0, gameId);
|
||||
|
||||
let entries;
|
||||
if (won) {
|
||||
const playedAt = Date.now();
|
||||
const username = String(meta.caller.username || `Player #${meta.caller.id}`).trim();
|
||||
ctx.db.prepare("UPDATE minesweeper_scores SET username = ? WHERE caller_id = ? AND difficulty = ?")
|
||||
.run(username, Number(meta.caller.id), game.difficulty);
|
||||
ctx.db.prepare(`
|
||||
INSERT INTO minesweeper_scores (caller_id, username, difficulty, time_ms, moves, played_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(caller_id, difficulty) DO UPDATE SET
|
||||
time_ms = excluded.time_ms,
|
||||
moves = excluded.moves,
|
||||
played_at = excluded.played_at
|
||||
WHERE excluded.time_ms < minesweeper_scores.time_ms
|
||||
OR (excluded.time_ms = minesweeper_scores.time_ms AND excluded.moves < minesweeper_scores.moves)
|
||||
`).run(Number(meta.caller.id), username, game.difficulty, elapsedMs, moves, playedAt);
|
||||
entries = leaderboard(ctx, game.difficulty);
|
||||
ctx.broadcast("leaderboard", { difficulty: game.difficulty, entries });
|
||||
}
|
||||
|
||||
return {
|
||||
status: won ? "won" : "playing",
|
||||
cells: visibleCells(revealed, mines, width, height, won),
|
||||
moves,
|
||||
elapsedMs,
|
||||
entries,
|
||||
};
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,22 @@
|
||||
@echo off
|
||||
setlocal
|
||||
|
||||
set "PLUGIN_DIR=%~dp0"
|
||||
set "PLUGIN_NAME=snake"
|
||||
set "ZIP_OUT=%PLUGIN_DIR%%PLUGIN_NAME%.zip"
|
||||
|
||||
for %%F in (config.json snake.html snake.css snake.js server.js) do (
|
||||
if not exist "%PLUGIN_DIR%%%F" (
|
||||
echo [error] Missing %%F
|
||||
exit /b 1
|
||||
)
|
||||
)
|
||||
|
||||
if exist "%ZIP_OUT%" del /f /q "%ZIP_OUT%"
|
||||
pushd "%PLUGIN_DIR%"
|
||||
powershell -NoProfile -ExecutionPolicy Bypass -Command "Compress-Archive -Path @('config.json','snake.html','snake.css','snake.js','server.js') -DestinationPath '%ZIP_OUT%' -Force"
|
||||
set "ZIP_STATUS=%ERRORLEVEL%"
|
||||
popd
|
||||
if not "%ZIP_STATUS%"=="0" exit /b %ZIP_STATUS%
|
||||
|
||||
echo [ok] %ZIP_OUT%
|
||||
@@ -0,0 +1,200 @@
|
||||
const BOARD_SIZE = 24;
|
||||
const MAX_TICKS = 100_000;
|
||||
const SESSION_TTL_MS = 2 * 60 * 60 * 1000;
|
||||
const DIRECTIONS = {
|
||||
up: { x: 0, y: -1, opposite: "down" },
|
||||
down: { x: 0, y: 1, opposite: "up" },
|
||||
left: { x: -1, y: 0, opposite: "right" },
|
||||
right: { x: 1, y: 0, opposite: "left" },
|
||||
};
|
||||
|
||||
function makeRng(seed) {
|
||||
let value = seed >>> 0;
|
||||
return () => {
|
||||
value ^= value << 13;
|
||||
value ^= value >>> 17;
|
||||
value ^= value << 5;
|
||||
return (value >>> 0) / 0x100000000;
|
||||
};
|
||||
}
|
||||
|
||||
function cellKey(cell) {
|
||||
return `${cell.x},${cell.y}`;
|
||||
}
|
||||
|
||||
function chooseFood(snake, rng) {
|
||||
const occupied = new Set(snake.map(cellKey));
|
||||
const free = [];
|
||||
for (let y = 0; y < BOARD_SIZE; y += 1) {
|
||||
for (let x = 0; x < BOARD_SIZE; x += 1) {
|
||||
if (!occupied.has(`${x},${y}`)) free.push({ x, y });
|
||||
}
|
||||
}
|
||||
return free.length ? free[Math.floor(rng() * free.length)] : null;
|
||||
}
|
||||
|
||||
function initialState(seed) {
|
||||
const snake = [{ x: 12, y: 12 }, { x: 11, y: 12 }, { x: 10, y: 12 }];
|
||||
const rng = makeRng(seed);
|
||||
return { snake, direction: "right", food: chooseFood(snake, rng), score: 0, rng };
|
||||
}
|
||||
|
||||
function step(state) {
|
||||
const vector = DIRECTIONS[state.direction];
|
||||
const head = { x: state.snake[0].x + vector.x, y: state.snake[0].y + vector.y };
|
||||
const ate = state.food && head.x === state.food.x && head.y === state.food.y;
|
||||
const collisionBody = ate ? state.snake : state.snake.slice(0, -1);
|
||||
const collided = head.x < 0 || head.y < 0 || head.x >= BOARD_SIZE || head.y >= BOARD_SIZE ||
|
||||
collisionBody.some((cell) => cell.x === head.x && cell.y === head.y);
|
||||
if (collided) return { dead: true, won: false };
|
||||
|
||||
state.snake.unshift(head);
|
||||
if (ate) {
|
||||
state.score += 1;
|
||||
state.food = chooseFood(state.snake, state.rng);
|
||||
if (!state.food) return { dead: true, won: true };
|
||||
} else {
|
||||
state.snake.pop();
|
||||
}
|
||||
return { dead: false, won: false };
|
||||
}
|
||||
|
||||
function validateMoves(value, ticks) {
|
||||
if (!Array.isArray(value) || value.length > ticks) throw new Error("Invalid move log");
|
||||
let lastTick = 0;
|
||||
return value.map((move) => {
|
||||
const tick = Number(move?.tick);
|
||||
const direction = move?.direction;
|
||||
if (!Number.isInteger(tick) || tick <= lastTick || tick > ticks || !DIRECTIONS[direction]) {
|
||||
throw new Error("Invalid move log");
|
||||
}
|
||||
lastTick = tick;
|
||||
return { tick, direction };
|
||||
});
|
||||
}
|
||||
|
||||
function replay(seed, ticks, moves) {
|
||||
const state = initialState(seed);
|
||||
let moveIndex = 0;
|
||||
for (let tick = 1; tick <= ticks; tick += 1) {
|
||||
if (moveIndex < moves.length && moves[moveIndex].tick === tick) {
|
||||
const next = moves[moveIndex].direction;
|
||||
if (DIRECTIONS[state.direction].opposite === next) throw new Error("Invalid direction change");
|
||||
state.direction = next;
|
||||
moveIndex += 1;
|
||||
}
|
||||
const outcome = step(state);
|
||||
if (outcome.dead) {
|
||||
if (tick !== ticks) throw new Error("Run continued after game over");
|
||||
return { score: state.score, won: outcome.won };
|
||||
}
|
||||
}
|
||||
throw new Error("Run has not ended");
|
||||
}
|
||||
|
||||
function leaderboard(ctx) {
|
||||
return ctx.db.prepare(`
|
||||
SELECT caller_id AS callerId, username, score, played_at AS playedAt
|
||||
FROM snake_scores
|
||||
ORDER BY score DESC, played_at ASC
|
||||
LIMIT 10
|
||||
`).all().map((row) => ({
|
||||
player: String(row.username || `Player #${row.callerId}`),
|
||||
score: Number(row.score),
|
||||
playedAt: Number(row.playedAt),
|
||||
}));
|
||||
}
|
||||
|
||||
export default {
|
||||
setup(ctx) {
|
||||
ctx.db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS snake_games (
|
||||
id TEXT PRIMARY KEY,
|
||||
caller_id INTEGER NOT NULL,
|
||||
seed INTEGER NOT NULL,
|
||||
created_at INTEGER NOT NULL,
|
||||
completed INTEGER NOT NULL DEFAULT 0
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS snake_scores (
|
||||
caller_id INTEGER PRIMARY KEY,
|
||||
username TEXT,
|
||||
score INTEGER NOT NULL,
|
||||
played_at INTEGER NOT NULL
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS snake_games_created_at ON snake_games(created_at);
|
||||
`);
|
||||
const scoreColumns = ctx.db.prepare("PRAGMA table_info(snake_scores)").all();
|
||||
if (!scoreColumns.some((column) => column.name === "username")) {
|
||||
ctx.db.exec("ALTER TABLE snake_scores ADD COLUMN username TEXT");
|
||||
}
|
||||
ctx.db.prepare("DELETE FROM snake_games WHERE created_at < ?").run(Date.now() - SESSION_TTL_MS);
|
||||
ctx.log.info("Snake arcade ready");
|
||||
},
|
||||
|
||||
rpc: {
|
||||
start(ctx, _params, meta) {
|
||||
const callerId = Number(meta.caller.id);
|
||||
const id = crypto.randomUUID();
|
||||
const seed = crypto.getRandomValues(new Uint32Array(1))[0];
|
||||
const createdAt = Date.now();
|
||||
ctx.db.prepare("DELETE FROM snake_games WHERE caller_id = ? AND completed = 0").run(callerId);
|
||||
ctx.db.prepare("INSERT INTO snake_games (id, caller_id, seed, created_at) VALUES (?, ?, ?, ?)")
|
||||
.run(id, callerId, seed, createdAt);
|
||||
const state = initialState(seed);
|
||||
return {
|
||||
gameId: id,
|
||||
seed,
|
||||
boardSize: BOARD_SIZE,
|
||||
snake: state.snake,
|
||||
food: state.food,
|
||||
direction: state.direction,
|
||||
tickMs: 115,
|
||||
};
|
||||
},
|
||||
|
||||
leaderboard(ctx, _params, meta) {
|
||||
const username = String(meta.caller.username || `Player #${meta.caller.id}`).trim();
|
||||
ctx.db.prepare("UPDATE snake_scores SET username = ? WHERE caller_id = ?")
|
||||
.run(username, Number(meta.caller.id));
|
||||
return { entries: leaderboard(ctx) };
|
||||
},
|
||||
|
||||
submit(ctx, params, meta) {
|
||||
const gameId = typeof params?.gameId === "string" ? params.gameId : "";
|
||||
const ticks = Number(params?.ticks);
|
||||
if (!gameId || !Number.isInteger(ticks) || ticks < 1 || ticks > MAX_TICKS) {
|
||||
throw new Error("Invalid completed run");
|
||||
}
|
||||
|
||||
const game = ctx.db.prepare(
|
||||
"SELECT id, caller_id AS callerId, seed, created_at AS createdAt, completed FROM snake_games WHERE id = ?",
|
||||
).get(gameId);
|
||||
if (!game || Number(game.callerId) !== Number(meta.caller.id)) throw new Error("Game not found");
|
||||
if (game.completed) throw new Error("Game was already submitted");
|
||||
if (Date.now() - Number(game.createdAt) > SESSION_TTL_MS) throw new Error("Game expired");
|
||||
|
||||
const moves = validateMoves(params?.moves, ticks);
|
||||
const result = replay(Number(game.seed), ticks, moves);
|
||||
const username = String(meta.caller.username || `Player #${meta.caller.id}`).trim();
|
||||
const playedAt = Date.now();
|
||||
const save = ctx.db.transaction(() => {
|
||||
const updated = ctx.db.prepare("UPDATE snake_games SET completed = 1 WHERE id = ? AND completed = 0").run(gameId);
|
||||
if (updated.changes !== 1) throw new Error("Game was already submitted");
|
||||
ctx.db.prepare("UPDATE snake_scores SET username = ? WHERE caller_id = ?")
|
||||
.run(username, Number(meta.caller.id));
|
||||
ctx.db.prepare(`
|
||||
INSERT INTO snake_scores (caller_id, username, score, played_at) VALUES (?, ?, ?, ?)
|
||||
ON CONFLICT(caller_id) DO UPDATE SET
|
||||
score = excluded.score,
|
||||
played_at = excluded.played_at
|
||||
WHERE excluded.score > snake_scores.score
|
||||
`).run(Number(meta.caller.id), username, result.score, playedAt);
|
||||
});
|
||||
save();
|
||||
|
||||
const entries = leaderboard(ctx);
|
||||
ctx.broadcast("leaderboard", { entries });
|
||||
return { score: result.score, won: result.won, entries };
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,80 @@
|
||||
body {
|
||||
margin: 0;
|
||||
min-height: 100vh;
|
||||
background: #080d16;
|
||||
color: #e5e7eb;
|
||||
font-family: Inter, system-ui, sans-serif;
|
||||
}
|
||||
|
||||
.snake-shell {
|
||||
max-width: 1080px;
|
||||
margin: 0 auto;
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.snake-header,
|
||||
.snake-leaderboard-heading {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.snake-header { margin-bottom: 16px; }
|
||||
.snake-header h2 { margin: 0; font-size: 32px; }
|
||||
.snake-leaderboard h3 { margin: 0; font-size: 18px; }
|
||||
.snake-kicker { margin: 0 0 4px; color: #5ee38e; font-size: 11px; font-weight: 700; letter-spacing: 2px; }
|
||||
.snake-subtitle { margin: 4px 0 0; color: #94a3b8; font-size: 14px; }
|
||||
|
||||
.snake-scoreboard { display: flex; border: 1px solid #263342; }
|
||||
.snake-scoreboard div { min-width: 72px; padding: 8px 12px; text-align: center; }
|
||||
.snake-scoreboard div + div { border-left: 1px solid #263342; }
|
||||
.snake-scoreboard span { display: block; color: #94a3b8; font-size: 10px; text-transform: uppercase; }
|
||||
.snake-scoreboard strong { display: block; color: #86efac; font-size: 22px; }
|
||||
|
||||
.snake-layout { display: grid; grid-template-columns: minmax(0, 680px); justify-content: center; justify-items: center; gap: 24px; align-items: start; }
|
||||
.snake-game-column { width: 100%; max-width: 680px; }
|
||||
.snake-canvas-frame { box-sizing: border-box; position: relative; width: min(100%, 680px); aspect-ratio: 1; border: 8px solid #1e293b; background: #06100c; overflow: hidden; }
|
||||
#snake-board { display: block; width: 100%; height: 100%; }
|
||||
|
||||
.snake-overlay { position: absolute; inset: 0; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 10px; padding: 24px; background: rgba(3, 10, 8, 0.85); text-align: center; }
|
||||
.snake-overlay[hidden] { display: none; }
|
||||
.snake-overlay strong { font-size: 24px; }
|
||||
.snake-overlay span,
|
||||
.snake-status,
|
||||
.snake-empty { color: #94a3b8; font-size: 13px; }
|
||||
|
||||
.snake-overlay button,
|
||||
.snake-controls button,
|
||||
.snake-leaderboard-heading button { border: 1px solid #3b6b4b; border-radius: 0; padding: 8px 12px; background: #123522; color: #dcfce7; cursor: pointer; }
|
||||
.snake-overlay button:hover,
|
||||
.snake-controls button:hover,
|
||||
.snake-leaderboard-heading button:hover { background: #19492f; }
|
||||
.snake-overlay button:disabled { opacity: 0.5; }
|
||||
|
||||
.snake-status { min-height: 18px; margin: 8px 0 0; }
|
||||
.snake-controls { display: none; grid-template-columns: repeat(3, 48px); grid-template-areas: ". up ." "left down right"; justify-content: center; gap: 4px; margin-top: 8px; }
|
||||
.snake-controls button { width: 48px; height: 42px; padding: 0; }
|
||||
.snake-controls [data-direction="up"] { grid-area: up; }
|
||||
.snake-controls [data-direction="left"] { grid-area: left; }
|
||||
.snake-controls [data-direction="down"] { grid-area: down; }
|
||||
.snake-controls [data-direction="right"] { grid-area: right; }
|
||||
|
||||
.snake-leaderboard { box-sizing: border-box; width: min(100%, 680px); padding: 12px; border: 1px solid #263342; background: #0c1420; }
|
||||
.snake-leaderboard-heading button { width: 32px; height: 32px; padding: 0; }
|
||||
.snake-leaderboard ol { list-style: none; margin: 12px 0 0; padding: 0; }
|
||||
.snake-leaderboard li { display: grid; grid-template-columns: 24px 1fr auto; gap: 8px; align-items: center; min-height: 36px; border-bottom: 1px solid #1e293b; }
|
||||
.snake-rank { color: #64748b; font-size: 11px; }
|
||||
.snake-player { overflow: hidden; color: #cbd5e1; font-size: 13px; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.snake-points { color: #86efac; font-size: 13px; }
|
||||
.snake-help { display: flex; flex-direction: column; gap: 4px; margin-top: 12px; padding-top: 12px; border-top: 1px solid #263342; color: #94a3b8; font-size: 11px; }
|
||||
|
||||
@media (max-width: 960px) {
|
||||
.snake-controls { display: grid; }
|
||||
}
|
||||
|
||||
@media (max-width: 520px) {
|
||||
.snake-shell { padding: 8px; }
|
||||
.snake-subtitle { display: none; }
|
||||
.snake-scoreboard div { min-width: 54px; padding: 6px; }
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Snake</title>
|
||||
<link rel="stylesheet" href="snake.css">
|
||||
</head>
|
||||
<body>
|
||||
<section class="snake-shell" aria-labelledby="snake-title">
|
||||
<header class="snake-header">
|
||||
<div>
|
||||
<p class="snake-kicker">SERVER ARCADE</p>
|
||||
<h2 id="snake-title">Snake</h2>
|
||||
<p class="snake-subtitle">Eat the signal. Avoid the walls. Claim the leaderboard.</p>
|
||||
</div>
|
||||
<div class="snake-scoreboard" aria-live="polite">
|
||||
<div><span>Score</span><strong id="snake-score">0</strong></div>
|
||||
<div><span>Best</span><strong id="snake-best">0</strong></div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="snake-layout">
|
||||
<div class="snake-game-column">
|
||||
<div class="snake-canvas-frame">
|
||||
<canvas id="snake-board" width="600" height="600" aria-label="Snake game board"></canvas>
|
||||
<div id="snake-overlay" class="snake-overlay">
|
||||
<strong id="snake-overlay-title">Ready?</strong>
|
||||
<span id="snake-overlay-copy">Use arrow keys or WASD to steer.</span>
|
||||
<button id="snake-start" type="button">Start game</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="snake-controls" aria-label="Touch controls">
|
||||
<button type="button" data-direction="up" aria-label="Move up">▲</button>
|
||||
<button type="button" data-direction="left" aria-label="Move left">◀</button>
|
||||
<button type="button" data-direction="down" aria-label="Move down">▼</button>
|
||||
<button type="button" data-direction="right" aria-label="Move right">▶</button>
|
||||
</div>
|
||||
<p id="snake-status" class="snake-status" role="status">Waiting for a game.</p>
|
||||
</div>
|
||||
|
||||
<aside class="snake-leaderboard" aria-labelledby="snake-leaderboard-title">
|
||||
<div class="snake-leaderboard-heading">
|
||||
<div>
|
||||
<p class="snake-kicker">TOP OPERATORS</p>
|
||||
<h3 id="snake-leaderboard-title">Leaderboard</h3>
|
||||
</div>
|
||||
<button id="snake-refresh" type="button" title="Refresh leaderboard" aria-label="Refresh leaderboard">↻</button>
|
||||
</div>
|
||||
<ol id="snake-leaderboard-list"></ol>
|
||||
<p id="snake-leaderboard-empty" class="snake-empty">No completed runs yet.</p>
|
||||
<div class="snake-help">
|
||||
<strong>Controls</strong>
|
||||
<span>Arrow keys / WASD</span>
|
||||
<span>Space to pause</span>
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
</section>
|
||||
<script src="snake.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,277 @@
|
||||
(() => {
|
||||
const board = document.getElementById("snake-board");
|
||||
if (!(board instanceof HTMLCanvasElement) || board.dataset.ready === "true") return;
|
||||
board.dataset.ready = "true";
|
||||
|
||||
const context = board.getContext("2d");
|
||||
const scoreElement = document.getElementById("snake-score");
|
||||
const bestElement = document.getElementById("snake-best");
|
||||
const statusElement = document.getElementById("snake-status");
|
||||
const overlay = document.getElementById("snake-overlay");
|
||||
const overlayTitle = document.getElementById("snake-overlay-title");
|
||||
const overlayCopy = document.getElementById("snake-overlay-copy");
|
||||
const startButton = document.getElementById("snake-start");
|
||||
const refreshButton = document.getElementById("snake-refresh");
|
||||
const leaderboardList = document.getElementById("snake-leaderboard-list");
|
||||
const leaderboardEmpty = document.getElementById("snake-leaderboard-empty");
|
||||
|
||||
const directions = {
|
||||
up: { x: 0, y: -1, opposite: "down" },
|
||||
down: { x: 0, y: 1, opposite: "up" },
|
||||
left: { x: -1, y: 0, opposite: "right" },
|
||||
right: { x: 1, y: 0, opposite: "left" },
|
||||
};
|
||||
const keyDirections = {
|
||||
ArrowUp: "up", KeyW: "up",
|
||||
ArrowDown: "down", KeyS: "down",
|
||||
ArrowLeft: "left", KeyA: "left",
|
||||
ArrowRight: "right", KeyD: "right",
|
||||
};
|
||||
|
||||
let game = null;
|
||||
let timer = null;
|
||||
let pendingDirection = null;
|
||||
let paused = false;
|
||||
let best = 0;
|
||||
|
||||
function makeRng(seed) {
|
||||
let value = seed >>> 0;
|
||||
return () => {
|
||||
value ^= value << 13;
|
||||
value ^= value >>> 17;
|
||||
value ^= value << 5;
|
||||
return (value >>> 0) / 0x100000000;
|
||||
};
|
||||
}
|
||||
|
||||
function chooseFood(snake, rng, size) {
|
||||
const occupied = new Set(snake.map((cell) => `${cell.x},${cell.y}`));
|
||||
const free = [];
|
||||
for (let y = 0; y < size; y += 1) {
|
||||
for (let x = 0; x < size; x += 1) {
|
||||
if (!occupied.has(`${x},${y}`)) free.push({ x, y });
|
||||
}
|
||||
}
|
||||
return free.length ? free[Math.floor(rng() * free.length)] : null;
|
||||
}
|
||||
|
||||
async function rpc(method, params = {}) {
|
||||
const response = await fetch("/api/plugins/snake/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;
|
||||
}
|
||||
|
||||
function setStatus(message) {
|
||||
if (statusElement) statusElement.textContent = message;
|
||||
}
|
||||
|
||||
function setOverlay(title, copy, buttonLabel) {
|
||||
if (overlayTitle) overlayTitle.textContent = title;
|
||||
if (overlayCopy) overlayCopy.textContent = copy;
|
||||
if (startButton) startButton.textContent = buttonLabel;
|
||||
if (overlay) overlay.hidden = false;
|
||||
}
|
||||
|
||||
function renderLeaderboard(entries) {
|
||||
if (!leaderboardList || !leaderboardEmpty) return;
|
||||
leaderboardList.replaceChildren();
|
||||
const safeEntries = Array.isArray(entries) ? entries : [];
|
||||
leaderboardEmpty.hidden = safeEntries.length > 0;
|
||||
safeEntries.forEach((entry, index) => {
|
||||
const item = document.createElement("li");
|
||||
const rank = document.createElement("span");
|
||||
const player = document.createElement("span");
|
||||
const points = document.createElement("strong");
|
||||
rank.className = "snake-rank";
|
||||
player.className = "snake-player";
|
||||
points.className = "snake-points";
|
||||
rank.textContent = String(index + 1).padStart(2, "0");
|
||||
player.textContent = String(entry.player || "Player");
|
||||
points.textContent = String(Number(entry.score) || 0);
|
||||
item.append(rank, player, points);
|
||||
leaderboardList.append(item);
|
||||
});
|
||||
best = safeEntries.reduce((maximum, entry) => Math.max(maximum, Number(entry.score) || 0), 0);
|
||||
if (bestElement) bestElement.textContent = String(best);
|
||||
}
|
||||
|
||||
async function loadLeaderboard() {
|
||||
try {
|
||||
const result = await rpc("leaderboard");
|
||||
renderLeaderboard(result.entries);
|
||||
} catch (error) {
|
||||
setStatus(`Leaderboard unavailable: ${error instanceof Error ? error.message : String(error)}`);
|
||||
}
|
||||
}
|
||||
|
||||
function draw() {
|
||||
if (!context) return;
|
||||
const size = game?.size || 24;
|
||||
const cell = board.width / size;
|
||||
context.fillStyle = "#07100d";
|
||||
context.fillRect(0, 0, board.width, board.height);
|
||||
|
||||
context.strokeStyle = "rgba(94, 227, 142, 0.055)";
|
||||
context.lineWidth = 1;
|
||||
for (let i = 1; i < size; i += 1) {
|
||||
const offset = Math.round(i * cell) + 0.5;
|
||||
context.beginPath(); context.moveTo(offset, 0); context.lineTo(offset, board.height); context.stroke();
|
||||
context.beginPath(); context.moveTo(0, offset); context.lineTo(board.width, offset); context.stroke();
|
||||
}
|
||||
if (!game) return;
|
||||
|
||||
if (game.food) {
|
||||
const inset = 3;
|
||||
context.fillStyle = "#ef4444";
|
||||
context.fillRect(game.food.x * cell + inset, game.food.y * cell + inset, cell - inset * 2, cell - inset * 2);
|
||||
}
|
||||
|
||||
game.snake.forEach((segment) => {
|
||||
const inset = 2;
|
||||
context.fillStyle = "#22c55e";
|
||||
context.fillRect(segment.x * cell + inset, segment.y * cell + inset, cell - inset * 2, cell - inset * 2);
|
||||
});
|
||||
}
|
||||
|
||||
function queueDirection(direction) {
|
||||
if (!game || game.ended || paused || pendingDirection || !directions[direction]) return;
|
||||
if (directions[game.direction].opposite === direction) return;
|
||||
if (direction !== game.direction) pendingDirection = direction;
|
||||
}
|
||||
|
||||
async function finish(won) {
|
||||
if (!game || game.ended) return;
|
||||
game.ended = true;
|
||||
clearInterval(timer);
|
||||
timer = null;
|
||||
setStatus("Validating run with the server…");
|
||||
try {
|
||||
const result = await rpc("submit", { gameId: game.id, ticks: game.tick, moves: game.moves });
|
||||
renderLeaderboard(result.entries);
|
||||
setOverlay(won ? "Board cleared" : "Game over", `Validated score: ${result.score}`, "Play again");
|
||||
setStatus("Run validated and leaderboard updated.");
|
||||
} catch (error) {
|
||||
setOverlay("Run rejected", error instanceof Error ? error.message : String(error), "Try again");
|
||||
setStatus("The server could not validate this run.");
|
||||
}
|
||||
}
|
||||
|
||||
function tick() {
|
||||
if (!game || game.ended || paused) return;
|
||||
game.tick += 1;
|
||||
if (pendingDirection) {
|
||||
game.direction = pendingDirection;
|
||||
game.moves.push({ tick: game.tick, direction: pendingDirection });
|
||||
pendingDirection = null;
|
||||
}
|
||||
|
||||
const vector = directions[game.direction];
|
||||
const head = { x: game.snake[0].x + vector.x, y: game.snake[0].y + vector.y };
|
||||
const ate = game.food && head.x === game.food.x && head.y === game.food.y;
|
||||
const collisionBody = ate ? game.snake : game.snake.slice(0, -1);
|
||||
const collided = head.x < 0 || head.y < 0 || head.x >= game.size || head.y >= game.size ||
|
||||
collisionBody.some((cell) => cell.x === head.x && cell.y === head.y);
|
||||
if (collided) {
|
||||
draw();
|
||||
void finish(false);
|
||||
return;
|
||||
}
|
||||
|
||||
game.snake.unshift(head);
|
||||
if (ate) {
|
||||
game.score += 1;
|
||||
game.food = chooseFood(game.snake, game.rng, game.size);
|
||||
if (scoreElement) scoreElement.textContent = String(game.score);
|
||||
if (!game.food) {
|
||||
draw();
|
||||
void finish(true);
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
game.snake.pop();
|
||||
}
|
||||
draw();
|
||||
}
|
||||
|
||||
async function startGame() {
|
||||
if (startButton) startButton.disabled = true;
|
||||
setStatus("Opening a validated game session…");
|
||||
try {
|
||||
const session = await rpc("start");
|
||||
const rng = makeRng(Number(session.seed));
|
||||
const snake = session.snake.map((cell) => ({ x: Number(cell.x), y: Number(cell.y) }));
|
||||
chooseFood(snake, rng, Number(session.boardSize));
|
||||
game = {
|
||||
id: session.gameId,
|
||||
size: Number(session.boardSize),
|
||||
snake,
|
||||
food: session.food,
|
||||
direction: session.direction,
|
||||
score: 0,
|
||||
tick: 0,
|
||||
moves: [],
|
||||
rng,
|
||||
ended: false,
|
||||
};
|
||||
paused = false;
|
||||
pendingDirection = null;
|
||||
if (scoreElement) scoreElement.textContent = "0";
|
||||
if (overlay) overlay.hidden = true;
|
||||
clearInterval(timer);
|
||||
timer = setInterval(tick, Number(session.tickMs) || 115);
|
||||
setStatus("Game active.");
|
||||
draw();
|
||||
} catch (error) {
|
||||
setOverlay("Could not start", error instanceof Error ? error.message : String(error), "Retry");
|
||||
setStatus("Could not open a game session.");
|
||||
} finally {
|
||||
if (startButton) startButton.disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
function togglePause() {
|
||||
if (!game || game.ended) return;
|
||||
paused = !paused;
|
||||
if (paused) {
|
||||
setOverlay("Paused", "Press Space to continue.", "Resume");
|
||||
setStatus("Game paused.");
|
||||
} else {
|
||||
if (overlay) overlay.hidden = true;
|
||||
setStatus("Game active.");
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener("keydown", (event) => {
|
||||
const direction = keyDirections[event.code];
|
||||
if (direction) {
|
||||
event.preventDefault();
|
||||
queueDirection(direction);
|
||||
} else if (event.code === "Space" && game && !game.ended) {
|
||||
event.preventDefault();
|
||||
togglePause();
|
||||
}
|
||||
});
|
||||
document.querySelectorAll(".snake-controls [data-direction]").forEach((button) => {
|
||||
button.addEventListener("click", () => queueDirection(button.dataset.direction));
|
||||
});
|
||||
startButton?.addEventListener("click", () => {
|
||||
if (paused && game && !game.ended) togglePause();
|
||||
else void startGame();
|
||||
});
|
||||
refreshButton?.addEventListener("click", () => void loadLeaderboard());
|
||||
|
||||
try {
|
||||
const stream = new EventSource("/api/plugins/snake/stream");
|
||||
stream.addEventListener("leaderboard", (event) => {
|
||||
try { renderLeaderboard(JSON.parse(event.data).entries); } catch {}
|
||||
});
|
||||
} catch {}
|
||||
|
||||
draw();
|
||||
void loadLeaderboard();
|
||||
})();
|
||||
Reference in New Issue
Block a user