/** * 扑克牌 Canvas 渲染器 * * 用原生 Canvas2D API 在前端渲染牌面,与后端 PIL 渲染保持一致。 * 用户在编辑时实时看到的就是这个画面。 */ import { SUIT_TEXT, SUIT_COLORS, isRedSuit, isFace, isJoker, computeNumberPipPositions, getEffectiveDesign, getEffectiveBackDesign, DEFAULT_BACK_DESIGN, } from './cardLayout.js' const CARD_W = 750 const CARD_H = 1050 // 图片缓存(url -> HTMLImageElement) const imageCache = new Map() function loadImage(url) { return new Promise((resolve, reject) => { if (!url) return resolve(null) if (imageCache.has(url)) { const cached = imageCache.get(url) if (cached.complete && cached.naturalWidth) return resolve(cached) // 还在加载中 cached.addEventListener('load', () => resolve(cached), { once: true }) cached.addEventListener('error', () => resolve(null), { once: true }) return } const img = new Image() img.crossOrigin = 'anonymous' imageCache.set(url, img) img.addEventListener('load', () => resolve(img), { once: true }) img.addEventListener('error', () => resolve(null), { once: true }) img.src = url }) } async function preloadAll(project) { const urls = new Set() if (project.design?.background_image) urls.add(project.design.background_image) if (project.back_design?.image) urls.add(project.back_design.image) for (const s of Object.keys(project.design?.suit_symbols || {})) { const sym = project.design.suit_symbols[s] if (sym?.type === 'image' && sym.asset_id) { // 由后端 /media 提供 urls.add(`/media/${sym.value}`) } } for (const a of project.assets || []) { if (a.file_url) urls.add(a.file_url) } await Promise.all(Array.from(urls).map(u => loadImage(u))) } function assetByType(project, type, key) { // 取最新一张匹配 (type, key) 的素材 if (!project.assets) return null for (const a of project.assets) { if (a.asset_type === type && a.asset_key === key) return a } return null } /* ---------- 绘图原语 ---------- */ function drawRoundedRect(ctx, x, y, w, h, r) { if (r > w / 2) r = w / 2 if (r > h / 2) r = h / 2 ctx.beginPath() ctx.moveTo(x + r, y) ctx.arcTo(x + w, y, x + w, y + h, r) ctx.arcTo(x + w, y + h, x, y + h, r) ctx.arcTo(x, y + h, x, y, r) ctx.arcTo(x, y, x + w, y, r) ctx.closePath() } function drawBackground(ctx, w, h, design) { // 1. 底色 ctx.fillStyle = design.background_color || '#FFFFFF' ctx.fillRect(0, 0, w, h) // 2. 背景图(保持比例铺满) if (design.background_image) { const img = imageCache.get(design.background_image) if (img && img.complete && img.naturalWidth) { const ratio = Math.max(w / img.naturalWidth, h / img.naturalHeight) const dw = img.naturalWidth * ratio const dh = img.naturalHeight * ratio ctx.drawImage(img, (w - dw) / 2, (h - dh) / 2, dw, dh) } } } function drawBorder(ctx, w, h, design) { const width = Number(design.border_width) || 0 if (width <= 0) return ctx.save() ctx.strokeStyle = design.border_color || '#333333' ctx.lineWidth = width const half = width / 2 drawRoundedRect(ctx, half, half, w - width, h - width, 16) ctx.stroke() ctx.restore() } function drawCornerIndex(ctx, w, h, suit, rank, design) { const cornerRatio = Number(design.corner_size_ratio) || 0.13 const pad = Math.max(10, w * 0.045) const color = (design.suit_symbols?.[suit]?.color) || (isRedSuit(suit) ? SUIT_COLORS.heart : SUIT_COLORS.spade) // rank 在上、suit 在下 const rankSize = Math.round(w * cornerRatio * 1.05) const suitSize = Math.round(w * cornerRatio * 0.9) const fontFamily = design.font_family || 'Times New Roman' ctx.save() ctx.fillStyle = color ctx.textBaseline = 'top' ctx.textAlign = 'left' // 左上 ctx.font = `bold ${rankSize}px ${fontFamily}, serif` ctx.fillText(String(rank), pad, pad) ctx.font = `${suitSize}px Arial, sans-serif` const rankHeight = rankSize ctx.fillText(SUIT_TEXT[suit], pad, pad + rankHeight + 2) // 估算左上班块高度 const rankW = ctx.measureText(String(rank)).width const suitW = ctx.measureText(SUIT_TEXT[suit]).width const blockW = Math.max(rankW, suitW) + 8 const blockH = rankHeight + 2 + suitSize + 8 // 右下:把左上班块平移 + 旋转 180°,再贴到右下 ctx.save() ctx.translate(w - pad, h - pad) ctx.rotate(Math.PI) // 在新坐标系中绘制(左上) ctx.fillStyle = color ctx.textBaseline = 'top' ctx.textAlign = 'left' ctx.font = `bold ${rankSize}px ${fontFamily}, serif` ctx.fillText(String(rank), 0, 0) ctx.font = `${suitSize}px Arial, sans-serif` ctx.fillText(SUIT_TEXT[suit], 0, rankHeight + 2) ctx.restore() ctx.restore() } async function drawSuitSymbol(ctx, x, y, size, suit, design) { const sym = design.suit_symbols?.[suit] || {} if (sym.type === 'image' && sym.asset_id) { const url = `/media/${sym.value}` const img = imageCache.get(url) if (img && img.complete && img.naturalWidth) { ctx.drawImage(img, x - size / 2, y - size / 2, size, size) return } } // 文本符号 ctx.save() ctx.fillStyle = sym.color || (isRedSuit(suit) ? SUIT_COLORS.heart : SUIT_COLORS.spade) ctx.font = `${Math.round(size)}px Arial, "Segoe UI Symbol", sans-serif` ctx.textAlign = 'center' ctx.textBaseline = 'middle' ctx.fillText(SUIT_TEXT[suit], x, y) ctx.restore() } async function drawNumberCardBody(ctx, w, h, suit, rank, design, project) { const pipRatio = Number(design.pip_size_ratio) || 0.16 const pipSize = Math.max(40, w * pipRatio) const positions = computeNumberPipPositions(rank, w, h, pipSize, project.number_layout) for (const p of positions) { await drawSuitSymbol(ctx, p.x, p.y, p.size, suit, design) } } async function drawFaceCardBody(ctx, w, h, suit, rank, design, project) { // 主体区域 const padX = w * 0.15 const padTop = h * 0.13 const padBot = h * 0.13 const bodyW = w - 2 * padX const bodyH = h - padTop - padBot const cardKey = `${suit}-${rank}` const asset = assetByType(project, 'face_card', cardKey) let img = null if (asset?.file_url) { img = imageCache.get(asset.file_url) || null if (img) await loadImage(asset.file_url) } if (img && img.complete && img.naturalWidth) { // 等比缩放 fill body const ratio = img.naturalWidth / img.naturalHeight const target = bodyW / bodyH let drawW, drawH if (ratio > target) { drawW = bodyW drawH = bodyW / ratio } else { drawH = bodyH drawW = bodyH * ratio } const drawX = padX + (bodyW - drawW) / 2 const drawY = padTop + (bodyH - drawH) / 2 // 上下对称:先画上半,再把下半用指定方式生成 const halfH = drawH / 2 // 上半:用 clip 限定为上半 ctx.save() ctx.beginPath() ctx.rect(drawX, drawY, drawW, halfH) ctx.clip() ctx.drawImage(img, drawX, drawY, drawW, drawH) ctx.restore() // 下半:symmetry_mode = 'flip' 垂直翻转,'rotate' 180° 旋转 const symMode = design.symmetry_mode || 'flip' if (symMode === 'rotate') { // 180° 旋转:translate 到 (x+w, y+h) + 旋转 PI ctx.save() ctx.translate(drawX + drawW, drawY + drawH) ctx.rotate(Math.PI) ctx.drawImage(img, 0, 0, drawW, drawH) // 旋转后图片左上角在 (0,0),覆盖 (-drawW,-drawH)~(0,0) ctx.restore() } else { // 垂直翻转:translate 到 (x, y+halfH) + scale(1, -1) ctx.save() ctx.translate(drawX, drawY + halfH) ctx.scale(1, -1) ctx.drawImage(img, 0, 0, drawW, halfH) ctx.restore() } } else { // 退化:绘制大花色 + 字母 const big = Math.round(h * 0.30) const color = (design.suit_symbols?.[suit]?.color) || (isRedSuit(suit) ? SUIT_COLORS.heart : SUIT_COLORS.spade) ctx.save() ctx.fillStyle = color ctx.textAlign = 'center' ctx.textBaseline = 'middle' ctx.font = `bold ${big}px ${design.font_family || 'Times New Roman'}, serif` ctx.fillText(rank, w / 2, padTop + bodyH * 0.35) ctx.font = `${big}px Arial, sans-serif` ctx.fillText(SUIT_TEXT[suit], w / 2, padTop + bodyH * 0.65) ctx.restore() } } async function drawJokerBody(ctx, w, h, which, design, project) { // 背景色 const bg = design.background_color || (which === 'big' ? '#1B5E20' : '#B71C1C') ctx.fillStyle = bg ctx.fillRect(0, 0, w, h) const padX = w * 0.15 const padTop = h * 0.18 const padBot = h * 0.22 const bodyW = w - 2 * padX const bodyH = h - padTop - padBot const asset = assetByType(project, 'joker', which) let img = null if (asset?.file_url) { img = imageCache.get(asset.file_url) || null if (img) await loadImage(asset.file_url) } if (img && img.complete && img.naturalWidth) { const halfH = bodyH / 2 const imgRatio = img.naturalWidth / img.naturalHeight const target = bodyW / halfH let drawW, drawH if (imgRatio > target) { drawW = bodyW; drawH = bodyW / imgRatio } else { drawH = halfH; drawW = halfH * imgRatio } const imageDx = Number(design.image_dx) || 0 const imageDy = Number(design.image_dy) || 0 const imageScale = Number(design.image_scale) || 1 const finalW = Math.max(1, drawW * imageScale) const finalH = Math.max(1, drawH * imageScale) const offsetX = bodyW * imageDx const offsetY = bodyH * imageDy const topX = padX + offsetX + (bodyW - finalW) / 2 const topY = padTop + offsetY + (halfH - finalH) / 2 ctx.drawImage(img, topX, topY, finalW, finalH) // symmetry_mode: 'flip' 垂直翻转,'rotate' 180° 旋转 const symMode = design.symmetry_mode || 'flip' if (symMode === 'rotate') { ctx.save() ctx.translate(topX + finalW, topY + finalH) ctx.rotate(Math.PI) ctx.drawImage(img, 0, 0, finalW, finalH) ctx.restore() } else { ctx.save() ctx.translate(topX, topY + finalH) ctx.scale(1, -1) ctx.drawImage(img, 0, 0, finalW, finalH) ctx.restore() } } else { // 退化 const big = Math.round(h * 0.25) ctx.save() ctx.fillStyle = '#FFFFFF' ctx.textAlign = 'center' ctx.textBaseline = 'middle' ctx.font = `bold ${big}px ${design.font_family || 'Times New Roman'}, serif` ctx.fillText('JOKER', w / 2, h / 2) ctx.restore() } // 角标 const label = which === 'big' ? 'BIG' : 'SMALL' ctx.save() ctx.fillStyle = '#FFFFFF' ctx.textAlign = 'left' ctx.textBaseline = 'top' const labelSize = Math.max(20, Math.round(w * 0.06)) const textSize = Math.max(16, Math.round(w * 0.045)) const pad = Math.max(10, w * 0.04) // 左上角标 ctx.font = `${textSize}px ${design.font_family || 'Times New Roman'}, serif` ctx.fillText('JOKER', pad, pad) ctx.font = `bold ${labelSize}px ${design.font_family || 'Times New Roman'}, serif` ctx.fillText(label, pad, pad + textSize + 4) // 右下角标 ctx.save() ctx.translate(w - pad, h - pad) ctx.rotate(Math.PI) ctx.fillStyle = '#FFFFFF' ctx.textAlign = 'left' ctx.textBaseline = 'top' ctx.font = `${textSize}px ${design.font_family || 'Times New Roman'}, serif` ctx.fillText('JOKER', 0, 0) ctx.font = `bold ${labelSize}px ${design.font_family || 'Times New Roman'}, serif` ctx.fillText(label, 0, textSize + 4) ctx.restore() ctx.restore() } async function drawBackSide(ctx, w, h, backDesign, project) { ctx.fillStyle = backDesign.background_color || '#1A237E' ctx.fillRect(0, 0, w, h) const bw = Number(backDesign.border_width) || 0 if (bw > 0) { ctx.save() ctx.strokeStyle = backDesign.border_color || '#C0A050' ctx.lineWidth = bw const half = bw / 2 drawRoundedRect(ctx, half, half, w - bw, h - bw, 16) ctx.stroke() ctx.restore() } const padX = Math.round(w * 0.15) const padTop = Math.round(h * 0.18) const padBot = Math.round(h * 0.22) const bodyW = w - 2 * padX const bodyH = h - padTop - padBot const imageDx = Number(backDesign.image_dx) || 0 const imageDy = Number(backDesign.image_dy) || 0 const imageScale = Number(backDesign.image_scale) || 1 const offsetX = bodyW * imageDx const offsetY = bodyH * imageDy const asset = assetByType(project, 'back', 'back') let img = null if (asset?.file_url) { img = imageCache.get(asset.file_url) || null if (img) await loadImage(asset.file_url) } if (img && img.complete && img.naturalWidth) { const ratio = img.naturalWidth / img.naturalHeight const target = bodyW / bodyH let drawW, drawH if (ratio > target) { drawW = bodyW; drawH = bodyW / ratio } else { drawH = bodyH; drawW = bodyH * ratio } const finalW = Math.max(1, drawW * imageScale) const finalH = Math.max(1, drawH * imageScale) const drawX = padX + offsetX + (bodyW - finalW) / 2 const drawY = padTop + offsetY + (bodyH - finalH) / 2 ctx.drawImage(img, drawX, drawY, finalW, finalH) if (backDesign.pattern_color) { ctx.save() ctx.globalCompositeOperation = 'source-atop' ctx.fillStyle = backDesign.pattern_color ctx.globalAlpha = 0.3 ctx.fillRect(0, 0, w, h) ctx.restore() } } else { ctx.save() ctx.fillStyle = backDesign.border_color || '#C0A050' ctx.textAlign = 'center' ctx.textBaseline = 'middle' ctx.font = `bold ${Math.round(w * 0.1)}px Times New Roman, serif` ctx.fillText('CARD BACK', w / 2, h / 2) ctx.restore() } } /* ---------- 公开 API ---------- */ /** * 渲染单张牌到 canvas * @param {HTMLCanvasElement} canvas * @param {object} project * @param {string} cardKey e.g. 'spade-A', 'joker-big', 'back' * @returns {Promise} */ export async function renderCard(canvas, project, cardKey) { if (!canvas) return // 适配高 DPI const dpr = window.devicePixelRatio || 1 const w = CARD_W const h = CARD_H if (canvas.width !== w * dpr) { canvas.width = w * dpr canvas.height = h * dpr canvas.style.width = '300px' canvas.style.height = '420px' } const ctx = canvas.getContext('2d') ctx.setTransform(dpr, 0, 0, dpr, 0, 0) ctx.clearRect(0, 0, w, h) // 预加载可能用到的图片 await preloadAll(project) const design = getEffectiveDesign(project, cardKey) if (isJoker(cardKey)) { const which = cardKey.split('-', 2)[1] await drawJokerBody(ctx, w, h, which, design, project) drawBorder(ctx, w, h, design) } else if (cardKey === 'back') { const backDesign = getEffectiveBackDesign(project) await drawBackSide(ctx, w, h, backDesign, project) } else { const [suit, rank] = cardKey.split('-') drawBackground(ctx, w, h, design) drawBorder(ctx, w, h, design) if (isFace(rank)) { await drawFaceCardBody(ctx, w, h, suit, rank, design, project) } else { await drawNumberCardBody(ctx, w, h, suit, rank, design, project) } drawCornerIndex(ctx, w, h, suit, rank, design) } } /** * 渲染缩略图(小尺寸)到底部卡片列表 * 简化版:缩放渲染,不画边框等细节 */ export async function renderThumbnail(canvas, project, cardKey, thumbW = 80, thumbH = 112) { if (!canvas) return if (canvas.width !== thumbW) { canvas.width = thumbW canvas.height = thumbH } const ctx = canvas.getContext('2d') ctx.clearRect(0, 0, thumbW, thumbH) // 用单独的小画布渲染再缩放 const tmp = document.createElement('canvas') tmp.width = CARD_W tmp.height = CARD_H await renderCard(tmp, project, cardKey) ctx.drawImage(tmp, 0, 0, thumbW, thumbH) } export { CARD_W, CARD_H }