UNPKG

tps-ninja

Version:

Generate images from Tak Positional System (TPS) strings

1,107 lines (1,036 loc) 30.4 kB
import fs from "fs"; import { createCanvas } from "canvas"; import GIFEncoder from "gif-encoder-2"; import { Board } from "./Board.js"; import { Ply } from "./Ply.js"; import themes from "./themes.js"; import { isArray, isBoolean, isFunction, isNumber, isString, last, } from "lodash-es"; export { parseTPS } from "./Board.js"; const pieceSizes = { xs: 12, sm: 24, md: 48, lg: 96, xl: 192, }; const textSizes = { xs: 0.1875, sm: 0.21875, md: 0.25, lg: 0.3, xl: 0.4, }; const defaults = { delay: 1000, imageSize: "md", textSize: "md", axisLabels: true, axisLabelsSmall: false, turnIndicator: true, flatCounts: true, stackCounts: true, komi: 0, moveNumber: true, evalText: true, opening: "swap", ply: "", tps: "", plies: [], showRoads: true, unplayedPieces: true, padding: true, bgAlpha: 1, transparent: false, hlSquares: true, highlighter: null, transform: [0, 0], plyIsDone: true, font: "sans", }; function sanitizeOptions(options) { for (const key in defaults) { if (options.hasOwnProperty(key)) { if (key === "highlighter" && isString(options[key])) { try { options[key] = JSON.parse(options[key]); } catch (err) { console.log(err); throw new Error("Invalid highlighter"); } } else if (key === "moveNumber" && !isBoolean(options[key])) { const number = parseInt(options[key], 10); if (isNaN(number)) { options[key] = options[key] !== "false"; } else { options[key] = number; } } else if (key === "transform") { if (isString(options[key])) { try { options[key] = eval(options[key]); } catch (error) { options[key] = defaults[key]; } } if (isArray(options[key])) { options[key] = options[key].slice(0, 2).map((n) => parseInt(n, 10)); if (options[key].some((n) => isNaN(n))) { options[key] = defaults[key]; } } else { options[key] = defaults[key]; } } else if (key === "plies") { if (isString(options[key])) { options[key] = options[key].split(/[\s,]+/); } } else if (isBoolean(defaults[key])) { options[key] = options[key] !== false && options[key] !== "false"; } else if (isNumber(defaults[key])) { options[key] = Number(options[key]); } } else { options[key] = defaults[key]; } } if (options.size) { options.size = Number(options.size); } if (isString(options.tps) && options.tps && options.tps.length === 1) { options.tps = Number(options.tps); } return options; } export const TPStoPNG = function (args, streamTo = null) { let options; if (isArray(args)) { options = { tps: args[0] || "" }; args.slice(1).forEach((arg) => { const [key, value] = arg.split("="); options[key] = value; }); } else { options = args; } sanitizeOptions(options); const canvas = TPStoCanvas(options); if (isFunction(canvas.pngStream)) { const stream = canvas.pngStream(); if (streamTo) { stream.pipe(streamTo); } else if (isFunction(fs.createWriteStream)) { let name = options.name || "takboard.png"; if (!name.endsWith(".png")) { name += ".png"; } const out = fs.createWriteStream("./" + name); stream.on("data", (chunk) => out.write(chunk)); } } return canvas; }; export const TPStoGIF = function (args, streamTo = null) { let options; if (isArray(args)) { options = { tps: args[0] || "" }; args.slice(1).forEach((arg) => { const [key, value] = arg.split("="); options[key] = value; }); } else { options = args; } sanitizeOptions(options); const plies = options.plies || []; if (plies.length) { delete options.plies; delete options.ply; delete options.hl; } let canvas = TPStoCanvas(options); let tps = canvas.tps; const encoder = new GIFEncoder( canvas.width, canvas.height, "neuquant", false, plies.length + 1 ); const stream = encoder.createReadStream(); if (streamTo) { stream.pipe(streamTo); } else if (isFunction(fs.createWriteStream)) { let name = options.name || "takboard.gif"; if (!name.endsWith(".gif")) { name += ".gif"; } const out = fs.createWriteStream("./" + name); stream.pipe(out); } if (isFunction(options.onProgress)) { encoder.on("progress", options.onProgress); } encoder.setRepeat(0); if (options.transparent) { encoder.setTransparent(); } encoder.setQuality(1); encoder.start(); encoder.setDelay(options.delay); encoder.addFrame(canvas.ctx); while (plies.length) { options.tps = tps; options.ply = plies.shift(); canvas = TPStoCanvas(options); tps = canvas.tps; encoder.setDelay(options.delay + options.delay * !plies.length); encoder.addFrame(canvas.ctx); } encoder.finish(); return stream; }; export const PTNtoTPS = function (args) { let options; let plies; if (isArray(args)) { plies = []; options = { tps: args[0] || "" }; args.slice(1).forEach((arg) => { const [key, value] = arg.split("="); if (value) { options[key] = value; } else { try { const ply = new Ply(key); if (ply) { plies.push(ply); } } catch (error) {} } }); } else { options = args; plies = options.plies; } sanitizeOptions(options); if (!plies.length) { throw new Error("No valid PTN provided"); } const board = new Board(options); plies.forEach((ply) => board.doPly(ply)); return board.getTPS(); }; export const parseTheme = function (theme) { if (!theme || !isString(theme)) { return theme || themes[0]; } if (theme[0] === "{") { // Custom theme try { const parsedTheme = JSON.parse(theme); if (!parsedTheme.colors) { throw new Error("Missing theme colors"); } const colors = Object.keys(parsedTheme.colors); if ( Object.keys(themes[0].colors).some((color) => !colors.includes(color)) ) { throw new Error("Missing theme colors"); } if (theme.rings > 0) { if (theme.rings > 4) { throw new Error("Rings must not exceed 4"); } for (let ring = 1; ring <= theme.rings; ring++) { if (!theme.colors[`ring${ring}`]) { throw new Error( `Expected ${theme.rings} ring(s) but found ${ring - 1}` ); } } } return parsedTheme; } catch (err) { console.log(err); throw new Error("Invalid theme"); } } else { // Built-in theme theme = themes.find((builtIn) => builtIn.id === theme); if (!theme) { throw new Error("Invalid theme ID"); } return theme; } }; export const TPStoCanvas = function (options = {}) { sanitizeOptions(options); const theme = parseTheme(options.theme); const board = new Board(options); if (!board || (board.errors && board.errors.length)) { throw new Error(board.errors[0]); } let hlSquares = []; let evalText = ""; if (options.plies && options.plies.length) { const plies = options.plies.map((ply) => board.doPly(ply)); let ply = last(plies); hlSquares = ply.squares; evalText = ply.evalText || ""; options.plyIsDone = true; } else if (options.ply) { const ply = board.doPly(options.ply); hlSquares = ply.squares; evalText = ply.evalText || ""; options.plyIsDone = true; } else if (options.hl) { let ply = new Ply(options.hl); ply = ply.transform(board.size, options.transform); hlSquares = ply.squares; } // Dimensions const pieceSize = Math.round( (pieceSizes[options.imageSize] * 5) / board.size ); const squareSize = pieceSize * 2; const roadSize = Math.round(squareSize * 0.3333); const pieceRadius = Math.round(squareSize * 0.05); const pieceSpacing = Math.round(squareSize * 0.07); const immovableSize = Math.round(squareSize * 0.15); const wallSize = Math.round(squareSize * 0.1875); const sideCoords = { N: [(squareSize - roadSize) / 2, 0], S: [(squareSize - roadSize) / 2, squareSize - roadSize], E: [squareSize - roadSize, (squareSize - roadSize) / 2], W: [0, (squareSize - roadSize) / 2], }; const strokeWidth = Math.round( theme.vars["piece-border-width"] * squareSize * 0.013 ); const shadowOffset = strokeWidth / 2 + Math.round(squareSize * 0.02); const shadowBlur = strokeWidth + Math.round(squareSize * 0.03); const fontSize = (squareSize * textSizes[options.textSize] * board.size) / 5; const stackCountFontSize = Math.min(squareSize * 0.18, fontSize); const padding = options.padding ? Math.round(fontSize * 0.5) : 0; const flatCounterHeight = options.turnIndicator ? Math.round(fontSize * 2) : 0; const turnIndicatorHeight = options.turnIndicator ? Math.round(fontSize * 0.5) : 0; const headerHeight = turnIndicatorHeight + flatCounterHeight; const axisSize = options.axisLabels && !options.axisLabelsSmall ? Math.round(fontSize * 1.5) : 0; const counterRadius = Math.round(flatCounterHeight / 4); const boardRadius = Math.round(squareSize / 10); const boardSize = squareSize * board.size; const unplayedWidth = options.unplayedPieces ? Math.round(squareSize * 1.75) : 0; const canvasWidth = unplayedWidth + axisSize + boardSize + padding * 2; const canvasHeight = headerHeight + axisSize + boardSize + padding * 2; if (options.transparent) { options.bgAlpha = 0; } // Start Drawing const canvas = createCanvas(canvasWidth, canvasHeight); const ctx = canvas.getContext("2d"); ctx.font = `${fontSize}px ${options.font}`; ctx.textDrawingMode = "path"; ctx.globalAlpha = options.bgAlpha; ctx.fillStyle = theme.colors.secondary; ctx.fillRect(0, 0, canvasWidth, canvasHeight); ctx.globalAlpha = 1; // Header const flats = board.flats.concat(); const komi = options.komi; if (options.turnIndicator) { const totalFlats = flats[0] + flats[1]; const flats1Width = Math.round( Math.min( boardSize - squareSize, Math.max( squareSize, (options.flatCounts && totalFlats ? flats[0] / totalFlats : 0.5) * boardSize ) ) ); const flats2Width = Math.round(boardSize - flats1Width); const komiWidth = options.flatCounts ? Math.round( komi < 0 ? flats1Width * (-komi / flats[0]) : flats2Width * (komi / flats[1]) ) : 0; if (options.flatCounts) { if (komi < 0) { flats[0] = flats[0] + komi + " +" + (-komi).toString().replace(/0?\.5/, "½"); } else if (komi > 0) { flats[1] = flats[1] - komi + " +" + komi.toString().replace(/0?\.5/, "½"); } } else { flats[0] = ""; flats[1] = ""; if (komi < 0) { flats[0] = "+" + (-komi).toString().replace(/0?\.5/, "½"); } else if (komi > 0) { flats[1] = "+" + komi.toString().replace(/0?\.5/, "½"); } } // Flat Bars ctx.fillStyle = theme.colors.player1; roundRect( ctx, padding + axisSize, padding, flats1Width, flatCounterHeight, { tl: counterRadius } ); ctx.fill(); ctx.fillStyle = theme.colors.player2; roundRect( ctx, padding + axisSize + flats1Width, padding, flats2Width, flatCounterHeight, { tr: counterRadius } ); ctx.fill(); if (komiWidth) { const flatWidth = komi < 0 ? flats1Width : flats2Width; const dark = komi < 0 ? theme.player1Dark : theme.player2Dark; ctx.fillStyle = dark ? "#fff" : "#000"; ctx.globalAlpha = 0.13; if (komiWidth >= flatWidth) { roundRect( ctx, padding + axisSize + (komi > 0) * flats1Width, padding, flatWidth, flatCounterHeight, { [komi < 0 ? "tl" : "tr"]: counterRadius } ); ctx.fill(); } else { ctx.fillRect( padding + axisSize + flats1Width - (komi < 0) * komiWidth, padding, komiWidth, flatCounterHeight ); } ctx.globalAlpha = 1; } // Flat Counts ctx.fillStyle = theme.player1Dark ? theme.colors.textLight : theme.colors.textDark; ctx.textBaseline = "middle"; // Player 1 Name if (options.player1) { ctx.textDrawingMode = "glyph"; const flatCount1Width = ctx.measureText(flats[0]).width; const player1 = limitText( ctx, options.player1, flats1Width - flatCount1Width - fontSize * 1.2 ); ctx.textAlign = "start"; ctx.fillText( player1, padding + axisSize + fontSize / 2, padding + flatCounterHeight / 2 ); ctx.textDrawingMode = "path"; } // Player 1 Flat Count if (flats[0] !== "") { ctx.textAlign = "end"; flats[0] = String(flats[0]).split(" "); ctx.fillText( flats[0][0], padding + axisSize + flats1Width - fontSize / 2, padding + flatCounterHeight / 2 ); if (flats[0][1]) { // Komi flats[0][1] = flats[0][1].substring(1) + "+"; ctx.globalAlpha = 0.5; ctx.fillText( flats[0][1], padding + axisSize + flats1Width - fontSize / 2 - ctx.measureText(flats[0][0] + " ").width, padding + flatCounterHeight / 2 ); ctx.globalAlpha = 1; } } ctx.fillStyle = theme.player2Dark ? theme.colors.textLight : theme.colors.textDark; // Player 2 Name if (options.player2) { ctx.textDrawingMode = "glyph"; const flatCount2Width = ctx.measureText(flats[1]).width; const player2 = limitText( ctx, options.player2, flats2Width - flatCount2Width - fontSize * 1.2 ); ctx.textAlign = "end"; ctx.fillText( player2, padding + axisSize + boardSize - fontSize / 2, padding + flatCounterHeight / 2 ); ctx.textDrawingMode = "path"; } // Player 2 Flat Count if (flats[1] !== "") { ctx.textAlign = "start"; flats[1] = String(flats[1]).split(" "); ctx.fillText( flats[1][0], padding + axisSize + flats1Width + fontSize / 2, padding + flatCounterHeight / 2 ); if (flats[1][1]) { // Komi ctx.globalAlpha = 0.5; ctx.fillText( flats[1][1], padding + axisSize + flats1Width + fontSize / 2 + ctx.measureText(flats[1][0] + " ").width, padding + flatCounterHeight / 2 ); ctx.globalAlpha = 1; } } // Turn Indicator if (!board.isGameEnd) { ctx.fillStyle = theme.colors.primary; ctx.fillRect( padding + axisSize + (board.player === 1 ? 0 : boardSize / 2), padding + flatCounterHeight, boardSize / 2, turnIndicatorHeight ); } // Move number let moveNumberWidth = 0; if (options.moveNumber && options.unplayedPieces) { let moveNumber; if (typeof options.moveNumber === "number") { moveNumber = options.moveNumber; } else { moveNumber = board.linenum; if (moveNumber > 1 && board.player === 1) { moveNumber -= 1; } } moveNumber += "."; ctx.save(); ctx.textBaseline = "middle"; ctx.textAlign = "center"; ctx.shadowOffsetX = 0; ctx.shadowOffsetY = fontSize * 0.05; ctx.shadowBlur = fontSize * 0.1; ctx.shadowColor = theme.secondaryDark || options.bgAlpha < 0.5 ? theme.colors.textDark : theme.colors.textLight; ctx.fillStyle = theme.secondaryDark || options.bgAlpha < 0.5 ? theme.colors.textLight : theme.colors.textDark; ctx.fillText( moveNumber, padding + axisSize + boardSize + unplayedWidth / 2, padding + flatCounterHeight / 2 ); let { width } = ctx.measureText(moveNumber); moveNumberWidth = width; ctx.restore(); } if (options.evalText && options.unplayedPieces && evalText) { if (moveNumberWidth) { evalText = " " + evalText; } ctx.save(); ctx.textBaseline = "middle"; ctx.textAlign = options.moveNumber ? "left" : "center"; ctx.shadowOffsetX = 0; ctx.shadowOffsetY = fontSize * 0.05; ctx.shadowBlur = fontSize * 0.1; ctx.shadowColor = theme.secondaryDark || options.bgAlpha < 0.5 ? theme.colors.textDark : theme.colors.textLight; ctx.fillStyle = theme.colors.primary; ctx.font = `bold ${fontSize}px ${options.font}`; ctx.fillText( evalText, padding + axisSize + boardSize + unplayedWidth / 2 + moveNumberWidth / 2, padding + flatCounterHeight / 2 ); ctx.restore(); } } // Axis Labels let xAxis, yAxis; if (options.axisLabels) { let cols = "abcdefgh".substring(0, board.size).split(""); let rows = "12345678".substring(0, board.size).split(""); yAxis = options.transform[0] % 2 ? cols : rows; if (options.transform[0] === 1 || options.transform[0] === 2) { yAxis.reverse(); } xAxis = options.transform[0] % 2 ? rows : cols; if ( options.transform[1] ? options.transform[0] === 0 || options.transform[0] === 1 : options.transform[0] === 2 || options.transform[0] === 3 ) { xAxis.reverse(); } // Draw large axis labels if (!options.axisLabelsSmall) { ctx.save(); ctx.shadowOffsetX = 0; ctx.shadowOffsetY = fontSize * 0.05; ctx.shadowBlur = fontSize * 0.1; ctx.shadowColor = theme.secondaryDark || options.bgAlpha < 0.5 ? theme.colors.textDark : theme.colors.textLight; ctx.fillStyle = theme.secondaryDark || options.bgAlpha < 0.5 ? theme.colors.textLight : theme.colors.textDark; for (let i = 0; i < board.size; i++) { const coord = [xAxis[i], yAxis[i]]; ctx.textBaseline = padding ? "middle" : "bottom"; ctx.textAlign = "center"; ctx.fillText( coord[0], padding + axisSize + squareSize * i + squareSize / 2, padding + headerHeight + boardSize + (padding ? (axisSize + padding) / 2 : axisSize) ); ctx.textBaseline = "middle"; ctx.textAlign = padding ? "center" : "left"; ctx.fillText( coord[1], padding ? (axisSize + padding) / 2 : 0, padding + headerHeight + squareSize * (board.size - i - 1) + squareSize / 2 ); } ctx.restore(); } } // Board let squareRadius = 0; let squareMargin = 0; switch (theme.boardStyle) { case "diamonds1": squareRadius = squareSize * 0.1; break; case "diamonds2": squareRadius = squareSize * 0.3; break; case "diamonds3": squareRadius = squareSize * 0.5; break; case "grid1": squareMargin = squareSize * 0.01; break; case "grid2": squareMargin = squareSize * 0.03; squareRadius = squareSize * 0.05; break; case "grid3": squareMargin = squareSize * 0.06; squareRadius = squareSize * 0.15; } // Square const drawSquareHighlight = () => { const half = squareSize / 2; if (squareRadius >= half) { ctx.beginPath(); ctx.arc(half, half, half, 0, 2 * Math.PI); ctx.closePath(); } else { roundRect( ctx, squareMargin, squareMargin, squareSize - squareMargin * 2, squareSize - squareMargin * 2, squareRadius ); } ctx.fill(); }; const drawSquareNumber = (square, text, corner = "br") => { const isDark = theme.boardChecker && !square.isLight; ctx.save(); ctx.font = `${stackCountFontSize}px ${options.font}`; let isTextLight = theme.board2Dark; ctx.fillStyle = theme.colors.board2; if (hlSquares.includes(square.coord)) { isTextLight = theme.primaryDark; ctx.fillStyle = theme.colors.primary; } else if (isDark) { isTextLight = theme.board1Dark; ctx.fillStyle = theme.colors.board1; } let radius = (stackCountFontSize * 1.5) / 2; ctx.beginPath(); ctx.arc( corner[1] === "r" ? squareSize - radius : radius, corner[0] === "b" ? squareSize - radius : radius, radius, 0, 2 * Math.PI ); ctx.closePath(); ctx.fill(); ctx.fillStyle = isTextLight ? theme.colors.textLight : theme.colors.textDark; ctx.textBaseline = "middle"; ctx.textAlign = "center"; ctx.fillText( text, corner[1] === "r" ? squareSize - radius : radius, corner[0] === "b" ? squareSize - radius * 0.95 : radius * 0.95 ); ctx.restore(); }; const drawSquare = (square) => { const isDark = theme.boardChecker && !square.isLight; ctx.save(); ctx.translate( padding + axisSize + square.x * squareSize, padding + headerHeight + (board.size - square.y - 1) * squareSize ); if (!theme.boardStyle || theme.boardStyle === "blank") { ctx.fillStyle = theme.colors["board" + (isDark ? 2 : 1)]; ctx.fillRect(0, 0, squareSize, squareSize); } else { ctx.fillStyle = theme.colors["board" + (isDark ? 1 : 2)]; ctx.fillRect(0, 0, squareSize, squareSize); ctx.fillStyle = theme.colors["board" + (isDark ? 2 : 1)]; drawSquareHighlight(); } if (theme.rings) { let ring = square.ring; if (theme.fromCenter) { ring = Math.round(board.size / 2) - ring + 1; } if (ring <= theme.rings) { ctx.fillStyle = theme.colors["ring" + ring]; ctx.globalAlpha = theme.vars["rings-opacity"]; drawSquareHighlight(); ctx.globalAlpha = 1; } } if (options.highlighter && square.coord in options.highlighter) { ctx.fillStyle = withAlpha(options.highlighter[square.coord], 0.75); drawSquareHighlight(); } else if (options.hlSquares && hlSquares.includes(square.coord)) { const alphas = [0.4, 0.75]; if (!options.plyIsDone) { alphas.reverse(); } ctx.fillStyle = withAlpha( theme.colors.primary, hlSquares.length > 1 && square.coord === hlSquares[0] ? alphas[0] : alphas[1] ); drawSquareHighlight(); } if (options.showRoads && square.connected.length && !board.isGameEndFlats) { square.connected.forEach((side) => { const coords = sideCoords[side]; ctx.fillStyle = withAlpha( theme.colors[`player${square.color}road`], square.roads[side] ? 0.8 : 0.2 ); ctx.fillRect(coords[0], coords[1], roadSize, roadSize); }); ctx.fillStyle = withAlpha( theme.colors[`player${square.color}road`], square.roads.length ? 0.8 : 0.2 ); ctx.fillRect( (squareSize - roadSize) / 2, (squareSize - roadSize) / 2, roadSize, roadSize ); } else if (square.roads.length) { ctx.fillStyle = withAlpha( theme.colors[`player${square.color}road`], 0.35 ); drawSquareHighlight(); } // Small Axis Label if ( options.axisLabels && options.axisLabelsSmall && (square.edges.W || square.edges.S) ) { let coord = [xAxis[square.x], yAxis[square.y]]; if (options.transform[0] % 2) { coord.reverse(); } drawSquareNumber(square, coord.join(""), "bl"); } if (square.piece) { if (board.isGameEndFlats && !square.piece.typeCode()) { ctx.fillStyle = withAlpha( theme.colors[`player${square.color}road`], 0.4 ); drawSquareHighlight(); } // Stack Count if (options.stackCounts && square.pieces.length > 1) { drawSquareNumber(square, square.pieces.length, "br"); } square.pieces.forEach(drawPiece); } ctx.restore(); }; // Piece const drawPiece = (piece) => { ctx.save(); const pieces = piece.square ? piece.square.pieces : null; const offset = squareSize / 2; ctx.translate(offset, offset); let y = 0; const z = piece.z(); const isOverLimit = pieces && pieces.length > board.size; const isImmovable = isOverLimit && z < pieces.length - board.size; if (piece.square) { // Played y -= pieceSpacing * z; if (isOverLimit && !isImmovable) { y += pieceSpacing * (pieces.length - board.size); } if (piece.isStanding && pieces.length > 1) { y += pieceSpacing; } const overflow = Math.max(0, pieces.length - 10 - board.size); if (isImmovable) { if (z < overflow) { ctx.restore(); return; } y += pieceSpacing * overflow; } } else { // Unplayed const stackColor = options.opening === "swap" && piece.index === 0 && !piece.isCapstone ? piece.color === 1 ? 2 : 1 : piece.color; const caps = board.pieceCounts[stackColor].cap; const total = board.pieceCounts[stackColor].total; y = board.size - 1; if (piece.isCapstone) { y *= total - piece.index - 1; } else { y *= total - piece.index - caps - 1; } y *= -squareSize / (total - 1); } y = Math.round(y); if (piece.isCapstone) { ctx.fillStyle = theme.colors[`player${piece.color}special`]; ctx.beginPath(); ctx.arc(0, y, pieceSize / 2, 0, 2 * Math.PI); } else if (piece.isStanding) { ctx.fillStyle = theme.colors[`player${piece.color}special`]; ctx.translate(0, y); ctx.rotate(((piece.color === 1 ? -45 : 45) * Math.PI) / 180); roundRect( ctx, Math.round(-wallSize / 2), Math.round(-pieceSize / 2), wallSize, pieceSize, pieceRadius ); } else { ctx.fillStyle = theme.colors[`player${piece.color}flat`]; if (isImmovable) { roundRect( ctx, Math.round(pieceSize / 2), Math.round(y + pieceSize / 2 - pieceSpacing), immovableSize, pieceSpacing, pieceRadius / 2 ); } else { roundRect( ctx, Math.round(-pieceSize / 2), Math.round(y - pieceSize / 2), pieceSize, pieceSize, pieceRadius ); } } // Fill ctx.save(); ctx.shadowBlur = shadowBlur; ctx.shadowOffsetY = shadowOffset; ctx.shadowColor = theme.colors.umbra; ctx.fill(); ctx.restore(); // Stroke if (theme.vars["piece-border-width"] > 0) { ctx.strokeStyle = theme.colors[`player${piece.color}border`]; ctx.lineWidth = strokeWidth; ctx.stroke(); } ctx.restore(); }; board.squares .concat() .reverse() .forEach((row) => row.forEach(drawSquare)); // Unplayed Pieces if (options.unplayedPieces) { ctx.fillStyle = theme.colors.board3; roundRect( ctx, axisSize + padding + boardSize, headerHeight + padding, unplayedWidth, boardSize, { tr: boardRadius, br: boardRadius } ); ctx.fill(); [1, 2].forEach((color) => { ctx.save(); ctx.translate( padding + axisSize + boardSize + (color === 2) * squareSize * 0.75, padding + headerHeight + boardSize - squareSize ); ["flat", "cap"].forEach((type) => { const total = board.pieceCounts[color][type]; const played = board.pieces.played[color][type].length; const remaining = total - played; const pieces = board.pieces.all[color][type].slice(total - remaining); if (type === "flat" && options.opening === "swap") { // Swap first pieces if (color === 1) { if (!board.pieces.played[2][type].length) { pieces[0] = board.pieces.all[2][type][0]; } else if (!played) { pieces.shift(); } } else if (!board.pieces.played[1][type].length) { if (!board.pieces.played[2][type].length) { pieces[0] = board.pieces.all[1][type][0]; } else { pieces.unshift(board.pieces.all[1][type][0]); } } } pieces.reverse().forEach(drawPiece); }); ctx.restore(); }); } canvas.ctx = ctx; canvas.isGameEnd = board.isGameEnd; canvas.linenum = board.linenum; canvas.player = board.player; canvas.tps = board.getTPS(); canvas.id = board.result || canvas.tps; return canvas; }; function withAlpha(color, alpha) { return color.substring(0, 7) + Math.round(256 * alpha).toString(16); } function limitText(ctx, text, width) { const suffix = "…"; if (width <= 0) { return ""; } if (width >= ctx.measureText(text).width) { return text; } do { text = text.substring(0, text.length - 1); } while (text.length && ctx.measureText(text + suffix).width >= width); return text + suffix; } function roundRect(ctx, x, y, width, height, radius) { const radii = { tl: 0, tr: 0, bl: 0, br: 0, }; if (typeof radius === "object") { for (const side in radius) { radii[side] = radius[side]; } } else { for (const side in radii) { radii[side] = radius; } } ctx.beginPath(); ctx.moveTo(x + radii.tl, y); ctx.lineTo(x + width - radii.tr, y); ctx.quadraticCurveTo(x + width, y, x + width, y + radii.tr); ctx.lineTo(x + width, y + height - radii.br); ctx.quadraticCurveTo(x + width, y + height, x + width - radii.br, y + height); ctx.lineTo(x + radii.bl, y + height); ctx.quadraticCurveTo(x, y + height, x, y + height - radii.bl); ctx.lineTo(x, y + radii.tl); ctx.quadraticCurveTo(x, y, x + radii.tl, y); ctx.closePath(); }