Files
game-cards-poker-design/frontend/src/utils/cardRenderer.js

499 lines
15 KiB
JavaScript
Raw Normal View History

/**
* 扑克牌 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 }