Files
game-cards-poker-design/frontend/src/utils/cardRenderer.js
2026-06-03 22:28:21 +08:00

499 lines
15 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 扑克牌 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<void>}
*/
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 }