mirror of
https://github.com/imgfloat/server.git
synced 2026-02-05 03:39:26 +00:00
Add lichess marketplace script
This commit is contained in:
12
.gitattributes
vendored
12
.gitattributes
vendored
@@ -26,3 +26,15 @@ doc/marketplace-scripts/kirov-reporting/logo.png filter=lfs diff=lfs merge=lfs -
|
||||
doc/marketplace-scripts/teapot-viewer/logo.png filter=lfs diff=lfs merge=lfs -text
|
||||
doc/marketplace-scripts/kirov-reporting/attachments/kirov_reporting.mp3 filter=lfs diff=lfs merge=lfs -text
|
||||
doc/marketplace-scripts/emote-combo/logo.png filter=lfs diff=lfs merge=lfs -text
|
||||
doc/marketplace-scripts/lichess-tv-chess/attachments/Chess_kdt45.png filter=lfs diff=lfs merge=lfs -text
|
||||
doc/marketplace-scripts/lichess-tv-chess/attachments/Chess_klt45.png filter=lfs diff=lfs merge=lfs -text
|
||||
doc/marketplace-scripts/lichess-tv-chess/attachments/Chess_ndt45.png filter=lfs diff=lfs merge=lfs -text
|
||||
doc/marketplace-scripts/lichess-tv-chess/attachments/Chess_pdt45.png filter=lfs diff=lfs merge=lfs -text
|
||||
doc/marketplace-scripts/lichess-tv-chess/attachments/Chess_plt45.png filter=lfs diff=lfs merge=lfs -text
|
||||
doc/marketplace-scripts/lichess-tv-chess/attachments/Chess_bdt45.png filter=lfs diff=lfs merge=lfs -text
|
||||
doc/marketplace-scripts/lichess-tv-chess/attachments/Chess_nlt45.png filter=lfs diff=lfs merge=lfs -text
|
||||
doc/marketplace-scripts/lichess-tv-chess/attachments/Chess_qdt45.png filter=lfs diff=lfs merge=lfs -text
|
||||
doc/marketplace-scripts/lichess-tv-chess/attachments/Chess_qlt45.png filter=lfs diff=lfs merge=lfs -text
|
||||
doc/marketplace-scripts/lichess-tv-chess/attachments/Chess_rdt45.png filter=lfs diff=lfs merge=lfs -text
|
||||
doc/marketplace-scripts/lichess-tv-chess/attachments/Chess_rlt45.png filter=lfs diff=lfs merge=lfs -text
|
||||
doc/marketplace-scripts/lichess-tv-chess/attachments/Chess_blt45.png filter=lfs diff=lfs merge=lfs -text
|
||||
|
||||
BIN
doc/marketplace-scripts/lichess-tv-chess/attachments/Chess_bdt45.png
LFS
Normal file
BIN
doc/marketplace-scripts/lichess-tv-chess/attachments/Chess_bdt45.png
LFS
Normal file
Binary file not shown.
BIN
doc/marketplace-scripts/lichess-tv-chess/attachments/Chess_blt45.png
LFS
Normal file
BIN
doc/marketplace-scripts/lichess-tv-chess/attachments/Chess_blt45.png
LFS
Normal file
Binary file not shown.
BIN
doc/marketplace-scripts/lichess-tv-chess/attachments/Chess_kdt45.png
LFS
Normal file
BIN
doc/marketplace-scripts/lichess-tv-chess/attachments/Chess_kdt45.png
LFS
Normal file
Binary file not shown.
BIN
doc/marketplace-scripts/lichess-tv-chess/attachments/Chess_klt45.png
LFS
Normal file
BIN
doc/marketplace-scripts/lichess-tv-chess/attachments/Chess_klt45.png
LFS
Normal file
Binary file not shown.
BIN
doc/marketplace-scripts/lichess-tv-chess/attachments/Chess_ndt45.png
LFS
Normal file
BIN
doc/marketplace-scripts/lichess-tv-chess/attachments/Chess_ndt45.png
LFS
Normal file
Binary file not shown.
BIN
doc/marketplace-scripts/lichess-tv-chess/attachments/Chess_nlt45.png
LFS
Normal file
BIN
doc/marketplace-scripts/lichess-tv-chess/attachments/Chess_nlt45.png
LFS
Normal file
Binary file not shown.
BIN
doc/marketplace-scripts/lichess-tv-chess/attachments/Chess_pdt45.png
LFS
Normal file
BIN
doc/marketplace-scripts/lichess-tv-chess/attachments/Chess_pdt45.png
LFS
Normal file
Binary file not shown.
BIN
doc/marketplace-scripts/lichess-tv-chess/attachments/Chess_plt45.png
LFS
Normal file
BIN
doc/marketplace-scripts/lichess-tv-chess/attachments/Chess_plt45.png
LFS
Normal file
Binary file not shown.
BIN
doc/marketplace-scripts/lichess-tv-chess/attachments/Chess_qdt45.png
LFS
Normal file
BIN
doc/marketplace-scripts/lichess-tv-chess/attachments/Chess_qdt45.png
LFS
Normal file
Binary file not shown.
BIN
doc/marketplace-scripts/lichess-tv-chess/attachments/Chess_qlt45.png
LFS
Normal file
BIN
doc/marketplace-scripts/lichess-tv-chess/attachments/Chess_qlt45.png
LFS
Normal file
Binary file not shown.
BIN
doc/marketplace-scripts/lichess-tv-chess/attachments/Chess_rdt45.png
LFS
Normal file
BIN
doc/marketplace-scripts/lichess-tv-chess/attachments/Chess_rdt45.png
LFS
Normal file
Binary file not shown.
BIN
doc/marketplace-scripts/lichess-tv-chess/attachments/Chess_rlt45.png
LFS
Normal file
BIN
doc/marketplace-scripts/lichess-tv-chess/attachments/Chess_rlt45.png
LFS
Normal file
Binary file not shown.
4
doc/marketplace-scripts/lichess-tv-chess/metadata.json
Normal file
4
doc/marketplace-scripts/lichess-tv-chess/metadata.json
Normal file
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"name": "Lichess TV Chess",
|
||||
"description": "Listen for !chess in chat, then render the current Lichess TV game live until it ends."
|
||||
}
|
||||
553
doc/marketplace-scripts/lichess-tv-chess/source.js
Normal file
553
doc/marketplace-scripts/lichess-tv-chess/source.js
Normal file
@@ -0,0 +1,553 @@
|
||||
const COMMAND = "!chess";
|
||||
const FEED_URL = "https://lichess.org/api/tv/feed";
|
||||
const HEADER_HEIGHT = 40;
|
||||
const PADDING = 16;
|
||||
const BOARD_MARGIN = 12;
|
||||
const BORDER_RADIUS = 14;
|
||||
const FOOTER_HEIGHT = 30;
|
||||
const LIGHT_SQUARE = "#f0d9b5";
|
||||
const DARK_SQUARE = "#b58863";
|
||||
const BOARD_OUTLINE = "#111827";
|
||||
const PANEL_BG = "rgba(15, 23, 42, 0.72)";
|
||||
const PANEL_BORDER = "rgba(255, 255, 255, 0.1)";
|
||||
const TEXT_PRIMARY = "#f8fafc";
|
||||
const TEXT_MUTED = "#cbd5f5";
|
||||
|
||||
const PIECE_ASSETS = {
|
||||
P: "Chess_plt45.png",
|
||||
R: "Chess_rlt45.png",
|
||||
N: "Chess_nlt45.png",
|
||||
B: "Chess_blt45.png",
|
||||
Q: "Chess_qlt45.png",
|
||||
K: "Chess_klt45.png",
|
||||
p: "Chess_pdt45.png",
|
||||
r: "Chess_rdt45.png",
|
||||
n: "Chess_ndt45.png",
|
||||
b: "Chess_bdt45.png",
|
||||
q: "Chess_qdt45.png",
|
||||
k: "Chess_kdt45.png",
|
||||
};
|
||||
|
||||
const ACTIVE_STATUSES = new Set(["started", "playing", "created"]);
|
||||
|
||||
function normalizeMessage(message) {
|
||||
return (message || "").trim().toLowerCase();
|
||||
}
|
||||
|
||||
function isCommandMatch(message) {
|
||||
const normalized = normalizeMessage(message);
|
||||
if (!normalized.startsWith(COMMAND)) {
|
||||
return false;
|
||||
}
|
||||
return normalized.length === COMMAND.length || normalized.startsWith(`${COMMAND} `);
|
||||
}
|
||||
|
||||
function parseFen(fen) {
|
||||
if (!fen || typeof fen !== "string") {
|
||||
return null;
|
||||
}
|
||||
const parts = fen.trim().split(" ");
|
||||
if (parts.length < 2) {
|
||||
return null;
|
||||
}
|
||||
const rows = parts[0].split("/");
|
||||
if (rows.length !== 8) {
|
||||
return null;
|
||||
}
|
||||
const board = rows.map((row) => {
|
||||
const squares = [];
|
||||
for (const char of row) {
|
||||
if (Number.isInteger(Number(char))) {
|
||||
const emptyCount = Number(char);
|
||||
for (let i = 0; i < emptyCount; i += 1) {
|
||||
squares.push(null);
|
||||
}
|
||||
} else {
|
||||
squares.push(char);
|
||||
}
|
||||
}
|
||||
return squares.length === 8 ? squares : null;
|
||||
});
|
||||
if (board.some((row) => !row || row.length !== 8)) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
board,
|
||||
turn: parts[1],
|
||||
};
|
||||
}
|
||||
|
||||
function parseUciMove(move) {
|
||||
if (!move || typeof move !== "string" || move.length < 4) {
|
||||
return null;
|
||||
}
|
||||
const files = "abcdefgh";
|
||||
const fromFile = files.indexOf(move[0]);
|
||||
const fromRank = Number(move[1]);
|
||||
const toFile = files.indexOf(move[2]);
|
||||
const toRank = Number(move[3]);
|
||||
if (fromFile < 0 || toFile < 0 || !fromRank || !toRank) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
from: { row: 8 - fromRank, col: fromFile },
|
||||
to: { row: 8 - toRank, col: toFile },
|
||||
};
|
||||
}
|
||||
|
||||
function clamp(value, min, max) {
|
||||
return Math.max(min, Math.min(max, value));
|
||||
}
|
||||
|
||||
function ensurePieceCache(state) {
|
||||
if (!state.pieceCache) {
|
||||
state.pieceCache = new Map();
|
||||
}
|
||||
return state.pieceCache;
|
||||
}
|
||||
|
||||
function resolvePieceAsset(pieceKey, assets) {
|
||||
const assetName = PIECE_ASSETS[pieceKey];
|
||||
if (!assetName || !Array.isArray(assets)) {
|
||||
return null;
|
||||
}
|
||||
return assets.find((asset) => asset?.name === assetName) || null;
|
||||
}
|
||||
|
||||
function loadPieceBitmap(pieceKey, assets, state) {
|
||||
const cache = ensurePieceCache(state);
|
||||
const existing = cache.get(pieceKey);
|
||||
if (existing?.bitmap) {
|
||||
return existing.bitmap;
|
||||
}
|
||||
if (existing?.loading) {
|
||||
return null;
|
||||
}
|
||||
const asset = resolvePieceAsset(pieceKey, assets);
|
||||
if (!asset?.url && !asset?.blob) {
|
||||
return null;
|
||||
}
|
||||
const entry = { loading: true, bitmap: null };
|
||||
cache.set(pieceKey, entry);
|
||||
const blobPromise = asset.blob
|
||||
? Promise.resolve(asset.blob)
|
||||
: fetch(asset.url)
|
||||
.then((response) => {
|
||||
if (!response.ok) {
|
||||
throw new Error("Failed to load piece asset");
|
||||
}
|
||||
return response.blob();
|
||||
});
|
||||
blobPromise
|
||||
.then((blob) => createImageBitmap(blob))
|
||||
.then((bitmap) => {
|
||||
entry.bitmap = bitmap;
|
||||
entry.loading = false;
|
||||
})
|
||||
.catch(() => {
|
||||
cache.delete(pieceKey);
|
||||
});
|
||||
return null;
|
||||
}
|
||||
|
||||
function drawRoundedRect(ctx, x, y, width, height, radius) {
|
||||
const r = clamp(radius, 0, Math.min(width, height) / 2);
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(x + r, y);
|
||||
ctx.arcTo(x + width, y, x + width, y + height, r);
|
||||
ctx.arcTo(x + width, y + height, x, y + height, r);
|
||||
ctx.arcTo(x, y + height, x, y, r);
|
||||
ctx.arcTo(x, y, x + width, y, r);
|
||||
ctx.closePath();
|
||||
}
|
||||
|
||||
function resolvePayload(event) {
|
||||
if (!event || typeof event !== "object") {
|
||||
return {};
|
||||
}
|
||||
if (event.d && typeof event.d === "object") {
|
||||
return event.d;
|
||||
}
|
||||
return event;
|
||||
}
|
||||
|
||||
function resolveGameId(payload) {
|
||||
return payload?.id || payload?.gameId || payload?.game?.id || payload?.game?.gameId || null;
|
||||
}
|
||||
|
||||
function resolveStatus(payload) {
|
||||
return payload?.status || payload?.game?.status || payload?.status?.name || null;
|
||||
}
|
||||
|
||||
function resolvePlayerFromList(players, color) {
|
||||
if (!Array.isArray(players)) {
|
||||
return null;
|
||||
}
|
||||
return players.find((player) => player?.color === color) || null;
|
||||
}
|
||||
|
||||
function resolvePlayers(payload) {
|
||||
const list = payload?.players || payload?.game?.players;
|
||||
const white =
|
||||
resolvePlayerFromList(list, "white") ||
|
||||
payload?.players?.white ||
|
||||
payload?.white ||
|
||||
payload?.game?.players?.white ||
|
||||
payload?.game?.white;
|
||||
const black =
|
||||
resolvePlayerFromList(list, "black") ||
|
||||
payload?.players?.black ||
|
||||
payload?.black ||
|
||||
payload?.game?.players?.black ||
|
||||
payload?.game?.black;
|
||||
const whiteName = white?.name || white?.user?.name || white?.username || "White";
|
||||
const blackName = black?.name || black?.user?.name || black?.username || "Black";
|
||||
return { white, black, whiteName, blackName };
|
||||
}
|
||||
|
||||
function resolveRating(player) {
|
||||
if (!player) {
|
||||
return null;
|
||||
}
|
||||
return player?.rating || player?.ratingDiff || player?.user?.rating || null;
|
||||
}
|
||||
|
||||
function resolveClockValue(payload, color) {
|
||||
const fromList = resolvePlayerFromList(payload?.players || payload?.game?.players, color);
|
||||
if (Number.isFinite(fromList?.seconds)) {
|
||||
return fromList.seconds;
|
||||
}
|
||||
const direct = payload?.clocks?.[color];
|
||||
if (Number.isFinite(direct)) {
|
||||
return direct;
|
||||
}
|
||||
const nested = payload?.game?.clocks?.[color];
|
||||
if (Number.isFinite(nested)) {
|
||||
return nested;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function formatClock(value) {
|
||||
if (!Number.isFinite(value)) {
|
||||
return "--:--";
|
||||
}
|
||||
const seconds = value > 1000 ? Math.floor(value / 100) : Math.floor(value);
|
||||
const minutes = Math.floor(seconds / 60);
|
||||
const remaining = Math.max(seconds % 60, 0);
|
||||
return `${minutes}:${String(remaining).padStart(2, "0")}`;
|
||||
}
|
||||
|
||||
function updateFromEvent(event, state) {
|
||||
const payload = resolvePayload(event);
|
||||
const gameId = resolveGameId(payload);
|
||||
if (gameId && !state.gameId) {
|
||||
state.gameId = gameId;
|
||||
}
|
||||
if (gameId && state.gameId && gameId !== state.gameId) {
|
||||
state.shouldStop = true;
|
||||
return;
|
||||
}
|
||||
|
||||
const fen = payload?.fen || payload?.game?.fen || payload?.position?.fen;
|
||||
const parsedFen = parseFen(fen);
|
||||
if (parsedFen) {
|
||||
state.fen = fen;
|
||||
state.board = parsedFen.board;
|
||||
state.turn = parsedFen.turn;
|
||||
state.boardVisible = true;
|
||||
}
|
||||
|
||||
const lastMove = payload?.lm || payload?.lastMove || payload?.move || payload?.moves?.split(" ")?.slice(-1)[0];
|
||||
if (lastMove) {
|
||||
state.lastMove = lastMove;
|
||||
}
|
||||
|
||||
const status = resolveStatus(payload);
|
||||
if (status) {
|
||||
state.status = status;
|
||||
}
|
||||
|
||||
const players = resolvePlayers(payload);
|
||||
if (players.whiteName || players.blackName) {
|
||||
state.whiteName = players.whiteName;
|
||||
state.blackName = players.blackName;
|
||||
}
|
||||
|
||||
const whiteRating = resolveRating(players.white);
|
||||
const blackRating = resolveRating(players.black);
|
||||
if (whiteRating) {
|
||||
state.whiteRating = whiteRating;
|
||||
}
|
||||
if (blackRating) {
|
||||
state.blackRating = blackRating;
|
||||
}
|
||||
|
||||
const whiteClock = resolveClockValue(payload, "white");
|
||||
const blackClock = resolveClockValue(payload, "black");
|
||||
if (whiteClock !== null) {
|
||||
state.whiteClock = whiteClock;
|
||||
}
|
||||
if (blackClock !== null) {
|
||||
state.blackClock = blackClock;
|
||||
}
|
||||
|
||||
if (status && !ACTIVE_STATUSES.has(String(status).toLowerCase())) {
|
||||
state.gameOver = true;
|
||||
}
|
||||
}
|
||||
|
||||
async function streamFeed(state) {
|
||||
state.feedActive = true;
|
||||
state.error = null;
|
||||
state.shouldStop = false;
|
||||
state.gameOver = false;
|
||||
state.gameId = null;
|
||||
state.status = "connecting";
|
||||
state.lastMove = null;
|
||||
state.boardVisible = false;
|
||||
state.fen = null;
|
||||
state.whiteClock = null;
|
||||
state.blackClock = null;
|
||||
|
||||
const controller = new AbortController();
|
||||
state.abortController = controller;
|
||||
|
||||
try {
|
||||
const response = await fetch(FEED_URL, {
|
||||
method: "GET",
|
||||
headers: {
|
||||
Accept: "application/x-ndjson",
|
||||
},
|
||||
signal: controller.signal,
|
||||
});
|
||||
if (!response.ok || !response.body) {
|
||||
throw new Error("Unable to load Lichess TV feed");
|
||||
}
|
||||
|
||||
const reader = response.body.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
let buffer = "";
|
||||
|
||||
while (true) {
|
||||
if (state.shouldStop) {
|
||||
break;
|
||||
}
|
||||
const { value, done } = await reader.read();
|
||||
if (done) {
|
||||
break;
|
||||
}
|
||||
buffer += decoder.decode(value, { stream: true });
|
||||
const lines = buffer.split("\n");
|
||||
buffer = lines.pop() || "";
|
||||
lines.forEach((line) => {
|
||||
if (!line.trim()) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const event = JSON.parse(line);
|
||||
updateFromEvent(event, state);
|
||||
} catch (_error) {
|
||||
// Ignore malformed events.
|
||||
}
|
||||
});
|
||||
|
||||
if (state.gameOver) {
|
||||
state.shouldStop = true;
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
if (error?.name !== "AbortError") {
|
||||
state.error = error?.message || String(error);
|
||||
}
|
||||
} finally {
|
||||
if (state.abortController === controller) {
|
||||
state.abortController = null;
|
||||
}
|
||||
state.feedActive = false;
|
||||
if (state.shouldStop) {
|
||||
state.boardVisible = false;
|
||||
state.board = null;
|
||||
state.fen = null;
|
||||
state.status = null;
|
||||
state.gameId = null;
|
||||
state.lastMove = null;
|
||||
state.turn = null;
|
||||
state.gameOver = false;
|
||||
state.whiteClock = null;
|
||||
state.blackClock = null;
|
||||
state.needsClear = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function stopFeed(state) {
|
||||
state.shouldStop = true;
|
||||
if (state.abortController) {
|
||||
state.abortController.abort();
|
||||
}
|
||||
state.needsClear = true;
|
||||
}
|
||||
|
||||
function processChatCommands(chatMessages, state) {
|
||||
if (!Array.isArray(chatMessages) || chatMessages.length === 0) {
|
||||
return;
|
||||
}
|
||||
const lastSeen = state.lastChatTimestamp ?? 0;
|
||||
let latestSeen = lastSeen;
|
||||
let shouldStart = false;
|
||||
|
||||
chatMessages.forEach((message) => {
|
||||
const timestamp = message?.timestamp ?? 0;
|
||||
if (timestamp <= lastSeen) {
|
||||
return;
|
||||
}
|
||||
latestSeen = Math.max(latestSeen, timestamp);
|
||||
if (!shouldStart && isCommandMatch(message?.message)) {
|
||||
shouldStart = true;
|
||||
}
|
||||
});
|
||||
|
||||
state.lastChatTimestamp = latestSeen;
|
||||
|
||||
if (shouldStart && !state.feedActive && !state.boardVisible) {
|
||||
streamFeed(state);
|
||||
}
|
||||
}
|
||||
|
||||
function drawBoard(context, state) {
|
||||
const { ctx, width, height, assets } = context;
|
||||
const board = state.board;
|
||||
if (!ctx || !board) {
|
||||
return;
|
||||
}
|
||||
|
||||
ctx.clearRect(0, 0, width, height);
|
||||
const maxBoardSize = Math.min(width, height) * 0.55;
|
||||
const boardSize = clamp(maxBoardSize, 180, Math.min(width, height));
|
||||
if (boardSize <= 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const panelWidth = boardSize + BOARD_MARGIN * 2;
|
||||
const panelHeight = boardSize + HEADER_HEIGHT + FOOTER_HEIGHT + BOARD_MARGIN;
|
||||
const panelX = clamp(width - panelWidth - PADDING, PADDING, width - panelWidth);
|
||||
const panelY = clamp(height - panelHeight - PADDING, PADDING, height - panelHeight);
|
||||
ctx.fillStyle = PANEL_BG;
|
||||
drawRoundedRect(ctx, panelX, panelY, panelWidth, panelHeight, BORDER_RADIUS);
|
||||
ctx.fill();
|
||||
ctx.strokeStyle = PANEL_BORDER;
|
||||
ctx.lineWidth = 2;
|
||||
ctx.stroke();
|
||||
|
||||
const headerX = panelX + BOARD_MARGIN;
|
||||
const headerY = panelY + BOARD_MARGIN * 0.6;
|
||||
|
||||
ctx.fillStyle = TEXT_PRIMARY;
|
||||
ctx.font = "600 20px 'Inter', 'Segoe UI', sans-serif";
|
||||
ctx.textAlign = "left";
|
||||
ctx.textBaseline = "top";
|
||||
|
||||
const whiteLabel = state.whiteRating ? `${state.whiteName} (${state.whiteRating})` : state.whiteName || "White";
|
||||
const blackLabel = state.blackRating ? `${state.blackName} (${state.blackRating})` : state.blackName || "Black";
|
||||
const whiteClock = formatClock(state.whiteClock);
|
||||
const blackClock = formatClock(state.blackClock);
|
||||
|
||||
ctx.fillText(blackLabel, headerX, headerY);
|
||||
ctx.textAlign = "right";
|
||||
ctx.fillText(blackClock, panelX + panelWidth - BOARD_MARGIN, headerY);
|
||||
ctx.textAlign = "left";
|
||||
|
||||
const statusText = state.status ? String(state.status).toUpperCase() : "LIVE";
|
||||
ctx.fillStyle = TEXT_MUTED;
|
||||
ctx.font = "600 13px 'Inter', 'Segoe UI', sans-serif";
|
||||
ctx.fillText(statusText, headerX, headerY + 22);
|
||||
|
||||
const boardX = panelX + BOARD_MARGIN;
|
||||
const boardY = panelY + HEADER_HEIGHT;
|
||||
const squareSize = boardSize / 8;
|
||||
|
||||
ctx.strokeStyle = BOARD_OUTLINE;
|
||||
ctx.lineWidth = 3;
|
||||
ctx.strokeRect(boardX - 1.5, boardY - 1.5, boardSize + 3, boardSize + 3);
|
||||
|
||||
const lastMove = parseUciMove(state.lastMove);
|
||||
|
||||
for (let row = 0; row < 8; row += 1) {
|
||||
for (let col = 0; col < 8; col += 1) {
|
||||
const isLight = (row + col) % 2 === 0;
|
||||
ctx.fillStyle = isLight ? LIGHT_SQUARE : DARK_SQUARE;
|
||||
ctx.fillRect(boardX + col * squareSize, boardY + row * squareSize, squareSize, squareSize);
|
||||
|
||||
if (
|
||||
lastMove &&
|
||||
((lastMove.from.row === row && lastMove.from.col === col) ||
|
||||
(lastMove.to.row === row && lastMove.to.col === col))
|
||||
) {
|
||||
ctx.fillStyle = "rgba(59, 130, 246, 0.35)";
|
||||
ctx.fillRect(boardX + col * squareSize, boardY + row * squareSize, squareSize, squareSize);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (let row = 0; row < 8; row += 1) {
|
||||
for (let col = 0; col < 8; col += 1) {
|
||||
const piece = board[row]?.[col];
|
||||
if (!piece) {
|
||||
continue;
|
||||
}
|
||||
const bitmap = loadPieceBitmap(piece, assets, state);
|
||||
if (!bitmap) {
|
||||
continue;
|
||||
}
|
||||
const size = squareSize * 0.85;
|
||||
const offset = (squareSize - size) / 2;
|
||||
ctx.drawImage(
|
||||
bitmap,
|
||||
boardX + col * squareSize + offset,
|
||||
boardY + row * squareSize + offset,
|
||||
size,
|
||||
size
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const turnIndicator = state.turn === "b" ? "Black to move" : "White to move";
|
||||
ctx.fillStyle = TEXT_MUTED;
|
||||
ctx.font = "500 13px 'Inter', 'Segoe UI', sans-serif";
|
||||
ctx.textAlign = "left";
|
||||
ctx.textBaseline = "bottom";
|
||||
ctx.fillText(whiteLabel, panelX + BOARD_MARGIN, panelY + panelHeight - BOARD_MARGIN / 2);
|
||||
ctx.textAlign = "right";
|
||||
ctx.fillText(whiteClock, panelX + panelWidth - BOARD_MARGIN, panelY + panelHeight - BOARD_MARGIN / 2);
|
||||
ctx.textAlign = "left";
|
||||
ctx.textBaseline = "alphabetic";
|
||||
ctx.font = "500 12px 'Inter', 'Segoe UI', sans-serif";
|
||||
ctx.fillText(turnIndicator, panelX + BOARD_MARGIN, panelY + panelHeight - FOOTER_HEIGHT + 12);
|
||||
}
|
||||
|
||||
function init() { }
|
||||
|
||||
function tick(context, state) {
|
||||
const { ctx, width, height, chatMessages } = context;
|
||||
if (!ctx) {
|
||||
return;
|
||||
}
|
||||
|
||||
processChatCommands(chatMessages, state);
|
||||
|
||||
if (state.needsClear) {
|
||||
ctx.clearRect(0, 0, width, height);
|
||||
state.needsClear = false;
|
||||
}
|
||||
|
||||
if (!state.boardVisible || !state.board) {
|
||||
return;
|
||||
}
|
||||
|
||||
drawBoard(context, state);
|
||||
|
||||
if (state.shouldStop) {
|
||||
stopFeed(state);
|
||||
}
|
||||
}
|
||||
@@ -34,6 +34,7 @@ public class MarketplaceScriptSeedLoader {
|
||||
java.util.Map.entry("jpeg", "image/jpeg"),
|
||||
java.util.Map.entry("gif", "image/gif"),
|
||||
java.util.Map.entry("webp", "image/webp"),
|
||||
java.util.Map.entry("svg", "image/svg+xml"),
|
||||
java.util.Map.entry("mp4", "video/mp4"),
|
||||
java.util.Map.entry("webm", "video/webm"),
|
||||
java.util.Map.entry("mov", "video/quicktime"),
|
||||
|
||||
@@ -64,6 +64,7 @@ public final class MediaTypeRegistry {
|
||||
map.put("jpeg", "image/jpeg");
|
||||
map.put("gif", "image/gif");
|
||||
map.put("webp", "image/webp");
|
||||
map.put("svg", "image/svg+xml");
|
||||
map.put("bmp", "image/bmp");
|
||||
map.put("tiff", "image/tiff");
|
||||
map.put("mp4", "video/mp4");
|
||||
@@ -89,6 +90,7 @@ public final class MediaTypeRegistry {
|
||||
map.put("image/jpg", ".jpg");
|
||||
map.put("image/gif", ".gif");
|
||||
map.put("image/webp", ".webp");
|
||||
map.put("image/svg+xml", ".svg");
|
||||
map.put("image/bmp", ".bmp");
|
||||
map.put("image/tiff", ".tiff");
|
||||
map.put("video/mp4", ".mp4");
|
||||
|
||||
Reference in New Issue
Block a user