重构扑克牌设计系统:修复后端渲染bug,重写前端编辑器
This commit is contained in:
@@ -1,128 +1,138 @@
|
||||
const LAYOUT_POSITIONS = {
|
||||
1: [
|
||||
{ x: 0.5, y: 0.5 }
|
||||
],
|
||||
2: [
|
||||
{ x: 0.5, y: 0.25 },
|
||||
{ x: 0.5, y: 0.75 }
|
||||
],
|
||||
3: [
|
||||
{ x: 0.5, y: 0.2 },
|
||||
{ x: 0.5, y: 0.5 },
|
||||
{ x: 0.5, y: 0.8 }
|
||||
],
|
||||
4: [
|
||||
{ x: 0.3, y: 0.25 },
|
||||
{ x: 0.7, y: 0.25 },
|
||||
{ x: 0.3, y: 0.75 },
|
||||
{ x: 0.7, y: 0.75 }
|
||||
],
|
||||
5: [
|
||||
{ x: 0.3, y: 0.2 },
|
||||
{ x: 0.7, y: 0.2 },
|
||||
{ x: 0.5, y: 0.5 },
|
||||
{ x: 0.3, y: 0.8 },
|
||||
{ x: 0.7, y: 0.8 }
|
||||
],
|
||||
6: [
|
||||
{ x: 0.3, y: 0.2 },
|
||||
{ x: 0.7, y: 0.2 },
|
||||
{ x: 0.3, y: 0.5 },
|
||||
{ x: 0.7, y: 0.5 },
|
||||
{ x: 0.3, y: 0.8 },
|
||||
{ x: 0.7, y: 0.8 }
|
||||
],
|
||||
7: [
|
||||
{ x: 0.3, y: 0.15 },
|
||||
{ x: 0.7, y: 0.15 },
|
||||
{ x: 0.5, y: 0.35 },
|
||||
{ x: 0.3, y: 0.55 },
|
||||
{ x: 0.7, y: 0.55 },
|
||||
{ x: 0.3, y: 0.85 },
|
||||
{ x: 0.7, y: 0.85 }
|
||||
],
|
||||
8: [
|
||||
{ x: 0.3, y: 0.15 },
|
||||
{ x: 0.7, y: 0.15 },
|
||||
{ x: 0.5, y: 0.35 },
|
||||
{ x: 0.3, y: 0.55 },
|
||||
{ x: 0.7, y: 0.55 },
|
||||
{ x: 0.5, y: 0.65 },
|
||||
{ x: 0.3, y: 0.85 },
|
||||
{ x: 0.7, y: 0.85 }
|
||||
],
|
||||
9: [
|
||||
{ x: 0.3, y: 0.15 },
|
||||
{ x: 0.7, y: 0.15 },
|
||||
{ x: 0.5, y: 0.35 },
|
||||
{ x: 0.2, y: 0.5 },
|
||||
{ x: 0.5, y: 0.5 },
|
||||
{ x: 0.8, y: 0.5 },
|
||||
{ x: 0.5, y: 0.65 },
|
||||
{ x: 0.3, y: 0.85 },
|
||||
{ x: 0.7, y: 0.85 }
|
||||
],
|
||||
10: [
|
||||
{ x: 0.3, y: 0.15 },
|
||||
{ x: 0.7, y: 0.15 },
|
||||
{ x: 0.3, y: 0.35 },
|
||||
{ x: 0.7, y: 0.35 },
|
||||
{ x: 0.5, y: 0.5 },
|
||||
{ x: 0.3, y: 0.65 },
|
||||
{ x: 0.7, y: 0.65 },
|
||||
{ x: 0.3, y: 0.85 },
|
||||
{ x: 0.7, y: 0.85 }
|
||||
]
|
||||
/**
|
||||
* 扑克牌布局数据 & 通用工具函数
|
||||
*
|
||||
* 这里和后端 apps/exports/utils.py 保持一致。
|
||||
* 实际渲染在前端用 canvas(drawCard),后端用 PIL(generate_card_png)。
|
||||
*/
|
||||
|
||||
// 数字牌 1-10 的花色位置(相对坐标 0~1)
|
||||
export const LAYOUT_POSITIONS = {
|
||||
1: [{ x: 0.50, y: 0.50 }],
|
||||
2: [{ x: 0.50, y: 0.25 }, { x: 0.50, y: 0.75 }],
|
||||
3: [{ x: 0.50, y: 0.20 }, { x: 0.50, y: 0.50 }, { x: 0.50, y: 0.80 }],
|
||||
4: [{ x: 0.30, y: 0.25 }, { x: 0.70, y: 0.25 }, { x: 0.30, y: 0.75 }, { x: 0.70, y: 0.75 }],
|
||||
5: [{ x: 0.30, y: 0.20 }, { x: 0.70, y: 0.20 }, { x: 0.50, y: 0.50 }, { x: 0.30, y: 0.80 }, { x: 0.70, y: 0.80 }],
|
||||
6: [{ x: 0.30, y: 0.20 }, { x: 0.70, y: 0.20 }, { x: 0.30, y: 0.50 }, { x: 0.70, y: 0.50 }, { x: 0.30, y: 0.80 }, { x: 0.70, y: 0.80 }],
|
||||
7: [{ x: 0.30, y: 0.15 }, { x: 0.70, y: 0.15 }, { x: 0.50, y: 0.35 }, { x: 0.30, y: 0.55 }, { x: 0.70, y: 0.55 }, { x: 0.30, y: 0.85 }, { x: 0.70, y: 0.85 }],
|
||||
8: [{ x: 0.30, y: 0.15 }, { x: 0.70, y: 0.15 }, { x: 0.50, y: 0.32 }, { x: 0.30, y: 0.50 }, { x: 0.70, y: 0.50 }, { x: 0.50, y: 0.68 }, { x: 0.30, y: 0.85 }, { x: 0.70, y: 0.85 }],
|
||||
9: [{ x: 0.30, y: 0.15 }, { x: 0.70, y: 0.15 }, { x: 0.50, y: 0.30 }, { x: 0.22, y: 0.50 }, { x: 0.50, y: 0.50 }, { x: 0.78, y: 0.50 }, { x: 0.50, y: 0.70 }, { x: 0.30, y: 0.85 }, { x: 0.70, y: 0.85 }],
|
||||
10: [{ x: 0.30, y: 0.15 }, { x: 0.70, y: 0.15 }, { x: 0.30, y: 0.35 }, { x: 0.70, y: 0.35 }, { x: 0.50, y: 0.50 }, { x: 0.30, y: 0.65 }, { x: 0.70, y: 0.65 }, { x: 0.30, y: 0.85 }, { x: 0.70, y: 0.85 }],
|
||||
}
|
||||
|
||||
export function calculateSuitPositions(rank, cardWidth, cardHeight, symbolSize = 60) {
|
||||
const positions = LAYOUT_POSITIONS[rank] || LAYOUT_POSITIONS[1]
|
||||
|
||||
return positions.map(pos => ({
|
||||
x: pos.x * cardWidth - symbolSize / 2,
|
||||
y: pos.y * cardHeight - symbolSize / 2,
|
||||
width: symbolSize,
|
||||
height: symbolSize
|
||||
}))
|
||||
export const SUIT_TEXT = {
|
||||
spade: '♠',
|
||||
heart: '♥',
|
||||
club: '♣',
|
||||
diamond: '♦',
|
||||
}
|
||||
|
||||
export function getCornerPositions(cardWidth, cardHeight) {
|
||||
return {
|
||||
topLeft: { x: 50, y: 50 },
|
||||
topRight: { x: cardWidth - 100, y: 50 },
|
||||
bottomLeft: { x: 50, y: cardHeight - 100 },
|
||||
bottomRight: { x: cardWidth - 100, y: cardHeight - 100 }
|
||||
}
|
||||
export const SUIT_LABELS = {
|
||||
spade: '♠ 黑桃',
|
||||
heart: '♥ 红桃',
|
||||
club: '♣ 梅花',
|
||||
diamond: '♦ 方块',
|
||||
}
|
||||
|
||||
export function getSuitSymbol(suit) {
|
||||
const symbols = {
|
||||
spade: '♠',
|
||||
heart: '♥',
|
||||
club: '♣',
|
||||
diamond: '♦'
|
||||
}
|
||||
return symbols[suit] || '♠'
|
||||
export const SUIT_COLORS = {
|
||||
spade: '#000000',
|
||||
heart: '#E53935',
|
||||
club: '#000000',
|
||||
diamond: '#E53935',
|
||||
}
|
||||
|
||||
export function getSuitColor(suit, templateColors) {
|
||||
if (templateColors && templateColors[suit]) {
|
||||
return templateColors[suit]
|
||||
}
|
||||
export const RANKS = ['A', '2', '3', '4', '5', '6', '7', '8', '9', '10', 'J', 'Q', 'K']
|
||||
|
||||
const colors = {
|
||||
spade: '#000000',
|
||||
heart: '#FF0000',
|
||||
club: '#000000',
|
||||
diamond: '#FF0000'
|
||||
}
|
||||
return colors[suit] || '#000000'
|
||||
export const SUITS = ['spade', 'heart', 'club', 'diamond']
|
||||
|
||||
export const JOKERS = [
|
||||
{ key: 'joker-big', label: '大王', defaultColor: '#1B5E20' },
|
||||
{ key: 'joker-small', label: '小王', defaultColor: '#B71C1C' },
|
||||
]
|
||||
|
||||
/**
|
||||
* 合并项目级 design 与单牌覆盖
|
||||
*/
|
||||
export function getEffectiveDesign(project, cardKey) {
|
||||
const base = JSON.parse(JSON.stringify(project?.design || {}))
|
||||
const overrides = (project?.card_overrides || {})[cardKey] || {}
|
||||
return { ...base, ...overrides }
|
||||
}
|
||||
|
||||
/**
|
||||
* 计算数字牌 (1-10) 实际的花色位置(绝对像素)
|
||||
* - 默认按 LAYOUT_POSITIONS
|
||||
* - 项目可保存 number_layout 做微调
|
||||
*/
|
||||
export function computeNumberPipPositions(rank, cardW, cardH, pipSize, numberLayout) {
|
||||
const rankInt = parseInt(rank, 10) || 1
|
||||
const defaults = LAYOUT_POSITIONS[rankInt] || LAYOUT_POSITIONS[1]
|
||||
const userOverrides = (numberLayout || {})[String(rankInt)] || []
|
||||
return defaults.map((p, i) => {
|
||||
const o = userOverrides[i] || {}
|
||||
const dx = Number(o.dx) || 0
|
||||
const dy = Number(o.dy) || 0
|
||||
const scale = Number(o.scale) || 1
|
||||
return {
|
||||
x: (p.x + dx) * cardW,
|
||||
y: (p.y + dy) * cardH,
|
||||
size: Math.max(20, pipSize * scale),
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析一个花色 key 是否为红色系
|
||||
*/
|
||||
export function isRedSuit(suit) {
|
||||
return suit === 'heart' || suit === 'diamond'
|
||||
}
|
||||
|
||||
export function isBlackSuit(suit) {
|
||||
return suit === 'spade' || suit === 'club'
|
||||
}
|
||||
}
|
||||
|
||||
export function isFace(rank) {
|
||||
return rank === 'J' || rank === 'Q' || rank === 'K'
|
||||
}
|
||||
|
||||
export function isJoker(cardKey) {
|
||||
return typeof cardKey === 'string' && cardKey.startsWith('joker-')
|
||||
}
|
||||
|
||||
export function isNumber(rank) {
|
||||
return RANKS.indexOf(rank) >= 0 && !isFace(rank)
|
||||
}
|
||||
|
||||
/**
|
||||
* 列出全部 54 张牌 + 背面
|
||||
*/
|
||||
export function listAllCards() {
|
||||
const out = []
|
||||
for (const s of SUITS) {
|
||||
for (const r of RANKS) {
|
||||
out.push({ key: `${s}-${r}`, suit: s, rank: r, type: isFace(r) ? 'face' : 'number' })
|
||||
}
|
||||
}
|
||||
out.push({ key: 'joker-big', suit: null, rank: null, type: 'joker' })
|
||||
out.push({ key: 'joker-small', suit: null, rank: null, type: 'joker' })
|
||||
out.push({ key: 'back', suit: null, rank: null, type: 'back' })
|
||||
return out
|
||||
}
|
||||
|
||||
export const DEFAULT_DESIGN = {
|
||||
background_color: '#FFFFFF',
|
||||
background_image: null,
|
||||
border_color: '#333333',
|
||||
border_width: 2,
|
||||
suit_symbols: {
|
||||
spade: { type: 'text', value: '♠', asset_id: null, color: '#000000' },
|
||||
heart: { type: 'text', value: '♥', asset_id: null, color: '#E53935' },
|
||||
club: { type: 'text', value: '♣', asset_id: null, color: '#000000' },
|
||||
diamond: { type: 'text', value: '♦', asset_id: null, color: '#E53935' },
|
||||
},
|
||||
corner_size_ratio: 0.13,
|
||||
pip_size_ratio: 0.16,
|
||||
font_family: 'Times New Roman',
|
||||
font_color: '#000000',
|
||||
corner_offset: { x: 0, y: 0 },
|
||||
}
|
||||
|
||||
402
frontend/src/utils/cardRenderer.js
Normal file
402
frontend/src/utils/cardRenderer.js
Normal file
@@ -0,0 +1,402 @@
|
||||
/**
|
||||
* 扑克牌 Canvas 渲染器
|
||||
*
|
||||
* 用原生 Canvas2D API 在前端渲染牌面,与后端 PIL 渲染保持一致。
|
||||
* 用户在编辑时实时看到的就是这个画面。
|
||||
*/
|
||||
import {
|
||||
SUIT_TEXT,
|
||||
SUIT_COLORS,
|
||||
isRedSuit,
|
||||
isFace,
|
||||
isJoker,
|
||||
computeNumberPipPositions,
|
||||
getEffectiveDesign,
|
||||
} 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)
|
||||
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
|
||||
ctx.save()
|
||||
// 上半(先画完整图,用 clip 限定为上半)
|
||||
ctx.beginPath()
|
||||
ctx.rect(drawX, drawY, drawW, halfH)
|
||||
ctx.clip()
|
||||
ctx.drawImage(img, drawX, drawY, drawW, drawH)
|
||||
ctx.restore()
|
||||
// 下半:把原图翻转 180° 贴到下半区域
|
||||
ctx.save()
|
||||
ctx.translate(drawX + drawW, drawY + drawH)
|
||||
ctx.rotate(Math.PI)
|
||||
// 现在画完整图(经过旋转坐标系),让它 fill bodyW x halfH
|
||||
const tmpW = drawW
|
||||
const tmpH = halfH
|
||||
// 由于旋转,drawImage 时 x/y 是反向的;目标 = (0,0) 到 (drawW, halfH)
|
||||
// 经过 180° 旋转,相当于在 (drawX, drawY) + (drawW, drawH) 处映射为 (-drawW, -drawH) 到 (0, 0)
|
||||
// 所以让图片左上角对应 (0,0) 即可
|
||||
ctx.drawImage(img, 0, 0, tmpW, tmpH * 2) // 0,0 -> drawW,halfH (after 180 rotation)
|
||||
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 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
|
||||
}
|
||||
ctx.drawImage(img, padX + (bodyW - drawW) / 2, padTop + (bodyH - drawH) / 2, drawW, drawH)
|
||||
} 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 = `bold ${labelSize}px ${design.font_family || 'Times New Roman'}, serif`
|
||||
ctx.fillText('JOKER', pad, pad)
|
||||
ctx.fillText(label, pad, pad + textSize + 4)
|
||||
// 右下:旋转 180° 平移
|
||||
ctx.translate(w - pad, h - pad)
|
||||
ctx.rotate(Math.PI)
|
||||
ctx.font = `bold ${labelSize}px ${design.font_family || 'Times New Roman'}, serif`
|
||||
ctx.fillText('JOKER', 0, 0)
|
||||
ctx.fillText(label, 0, textSize + 4)
|
||||
ctx.restore()
|
||||
}
|
||||
|
||||
function drawBackSide(ctx, w, h, design) {
|
||||
ctx.fillStyle = design.background_color || '#1A237E'
|
||||
ctx.fillRect(0, 0, w, h)
|
||||
ctx.save()
|
||||
ctx.strokeStyle = design.border_color || '#FFFFFF'
|
||||
ctx.lineWidth = 6
|
||||
const m = w * 0.06
|
||||
drawRoundedRect(ctx, m, m, w - 2 * m, h - 2 * m, 16)
|
||||
ctx.stroke()
|
||||
ctx.fillStyle = design.border_color || '#FFFFFF'
|
||||
ctx.textAlign = 'center'
|
||||
ctx.textBaseline = 'middle'
|
||||
ctx.font = `bold ${Math.round(w * 0.1)}px ${design.font_family || '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') {
|
||||
drawBackSide(ctx, w, h, design)
|
||||
} 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 }
|
||||
Reference in New Issue
Block a user