From b0cdd8c3adce96715ffbed4cb9c8a3ede2b0743e Mon Sep 17 00:00:00 2001 From: Poker Design Developer Date: Mon, 1 Jun 2026 21:48:51 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E8=83=8C=E9=9D=A2=E5=9B=BE=E6=A1=88?= =?UTF-8?q?=E6=94=AF=E6=8C=81=20+=20=E5=A4=A7=E5=B0=8F=E7=8E=8B=E7=B4=A0?= =?UTF-8?q?=E6=9D=90=E4=BD=8D=E7=BD=AE=E5=BE=AE=E8=B0=83=20+=2010=E5=8F=B7?= =?UTF-8?q?=E7=89=8Cpip=E4=BF=AE=E5=A4=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/apps/exports/utils.py | 77 +++++++++++++--- .../2026-06-01-back-image-joker-offset.md | 75 +++++++++++++++ .../2026-06-01-joker-symmetry-rendering.md | 75 +++++++++++++++ frontend/src/components/DesignPanel.vue | 41 ++++++++- frontend/src/utils/cardLayout.js | 2 +- frontend/src/utils/cardRenderer.js | 91 +++++++++++++++---- 6 files changed, 324 insertions(+), 37 deletions(-) create mode 100644 docs/superpowers/specs/2026-06-01-back-image-joker-offset.md create mode 100644 docs/superpowers/specs/2026-06-01-joker-symmetry-rendering.md diff --git a/backend/apps/exports/utils.py b/backend/apps/exports/utils.py index eed68d2..471a534 100644 --- a/backend/apps/exports/utils.py +++ b/backend/apps/exports/utils.py @@ -32,7 +32,7 @@ LAYOUT_POSITIONS = { 7: [(0.30, 0.15), (0.70, 0.15), (0.50, 0.35), (0.30, 0.55), (0.70, 0.55), (0.30, 0.85), (0.70, 0.85)], 8: [(0.30, 0.15), (0.70, 0.15), (0.50, 0.32), (0.30, 0.50), (0.70, 0.50), (0.50, 0.68), (0.30, 0.85), (0.70, 0.85)], 9: [(0.30, 0.15), (0.70, 0.15), (0.50, 0.30), (0.22, 0.50), (0.50, 0.50), (0.78, 0.50), (0.50, 0.70), (0.30, 0.85), (0.70, 0.85)], - 10: [(0.30, 0.15), (0.70, 0.15), (0.30, 0.35), (0.70, 0.35), (0.50, 0.50), (0.30, 0.65), (0.70, 0.65), (0.30, 0.85), (0.70, 0.85)], + 10: [(0.30, 0.15), (0.70, 0.15), (0.30, 0.35), (0.70, 0.35), (0.50, 0.45), (0.50, 0.55), (0.30, 0.65), (0.70, 0.65), (0.30, 0.85), (0.70, 0.85)], } @@ -300,10 +300,25 @@ def draw_joker(canvas, design, which, project, card_key, asset): body_h = h - body_pad_y_top - body_pad_y_bot if asset: try: - img = asset.copy() - img.thumbnail((body_w, body_h), Image.LANCZOS) - canvas.alpha_composite(img, (body_pad_x + (body_w - img.width) // 2, - body_pad_y_top + (body_h - img.height) // 2)) + half_h = body_h // 2 + image_dx = float(design.get('image_dx', 0)) + image_dy = float(design.get('image_dy', 0)) + image_scale = float(design.get('image_scale', 1)) + offset_x = int(body_w * image_dx) + offset_y = int(body_h * image_dy) + + img_copy = asset.copy() + if image_scale != 1: + sw = max(1, int(img_copy.width * image_scale)) + sh = max(1, int(img_copy.height * image_scale)) + img_copy = img_copy.resize((sw, sh), Image.LANCZOS) + img_copy.thumbnail((body_w, half_h), Image.LANCZOS) + x = body_pad_x + offset_x + (body_w - img_copy.width) // 2 + y_top = body_pad_y_top + offset_y + (half_h - img_copy.height) // 2 + canvas.alpha_composite(img_copy, (x, y_top)) + img_flipped = img_copy.transpose(Image.FLIP_TOP_BOTTOM) + y_bot = body_pad_y_top + half_h + offset_y + (half_h - img_flipped.height) // 2 + canvas.alpha_composite(img_flipped, (x, y_bot)) except Exception: pass else: @@ -339,6 +354,44 @@ def draw_joker(canvas, design, which, project, card_key, asset): canvas.alpha_composite(block, (w - tw - 4 - pad, h - 2 * th - 6 - pad)) +def draw_back(canvas, design, asset): + """绘制背面:素材图 + 位置微调,无素材时退化为文字""" + w, h = canvas.size + body_pad_x = int(w * 0.15) + body_pad_y_top = int(h * 0.18) + body_pad_y_bot = int(h * 0.22) + body_w = w - 2 * body_pad_x + body_h = h - body_pad_y_top - body_pad_y_bot + + image_dx = float(design.get('image_dx', 0)) + image_dy = float(design.get('image_dy', 0)) + image_scale = float(design.get('image_scale', 1)) + offset_x = int(body_w * image_dx) + offset_y = int(body_h * image_dy) + + if asset: + try: + img = asset.copy() + if image_scale != 1: + sw = max(1, int(img.width * image_scale)) + sh = max(1, int(img.height * image_scale)) + img = img.resize((sw, sh), Image.LANCZOS) + img.thumbnail((body_w, body_h), Image.LANCZOS) + x = body_pad_x + offset_x + (body_w - img.width) // 2 + y = body_pad_y_top + offset_y + (body_h - img.height) // 2 + canvas.alpha_composite(img, (x, y)) + except Exception: + pass + else: + draw = ImageDraw.Draw(canvas) + fnt = make_text_font('Times New Roman', max(40, int(h * 0.08)), bold=True) + text = 'CARD BACK' + color = hex_to_rgba(design.get('border_color', '#333333'), 255) + bb = draw.textbbox((0, 0), text, font=fnt) + tw, th = bb[2] - bb[0], bb[3] - bb[1] + draw.text(((w - tw) // 2, (h - th) // 2), text, font=fnt, fill=color) + + def generate_card_png(project, card_key, resolution='standard'): """根据项目配置生成单张牌 PNG""" scale_map = {'standard': 1, 'hd': 2, 'ultra-hd': 4} @@ -366,14 +419,12 @@ def generate_card_png(project, card_key, resolution='standard'): break draw_joker(canvas, design, which, project, card_key, asset) elif card_key in ('back', 'card-back'): - # 简化:背面同整体背景 + 一行文字 - draw = ImageDraw.Draw(canvas) - fnt = make_text_font('Times New Roman', max(40, int(h * 0.08)), bold=True) - text = 'CARD BACK' - color = hex_to_rgba(design.get('border_color', '#333333'), 255) - bb = draw.textbbox((0, 0), text, font=fnt) - tw, th = bb[2] - bb[0], bb[3] - bb[1] - draw.text(((w - tw) // 2, (h - th) // 2), text, font=fnt, fill=color) + back_asset = None + for a in project.assets.filter(asset_type='back'): + p = os.path.join('media', a.file_path) if a.file_path else None + back_asset = load_image_safe(p) if p else None + break + draw_back(canvas, design, back_asset) else: # 'suit-rank' parts = card_key.split('-') diff --git a/docs/superpowers/specs/2026-06-01-back-image-joker-offset.md b/docs/superpowers/specs/2026-06-01-back-image-joker-offset.md new file mode 100644 index 0000000..450a6df --- /dev/null +++ b/docs/superpowers/specs/2026-06-01-back-image-joker-offset.md @@ -0,0 +1,75 @@ +# 背面图案渲染 + 大小王素材位置微调 + +## 背景 + +当前背面牌(back)虽有 `'back'` 素材类型的上传 UI,但渲染时完全不使用素材,仅显示纯色 + "CARD BACK" 文字。大小王素材已渲染但无位置微调功能。 + +## 需求 + +### 1. 背面图案渲染 +- 优先从 `project.assets` 中读取 `asset_type='back'` 的素材图片 +- 有图时:在躯干区域居中绘制(contain 模式等比适配) +- 无图时:保持现有回退(纯色背景 + "CARD BACK" 文字 + 边框) +- 支持位置微调 + +### 2. 大小王素材位置微调 +- 在 DesignPanel 选中大小王时,显示 dx/dy/缩放滑块 +- 数据存储在 `card_overrides[joker-key].image_dx, image_dy, image_scale` +- `drawJokerBody()` 绘制时应用偏移 + +## 数据模型 + +扩展 `card_overrides` JSON: + +```json +{ + "joker-big": { "image_dx": 0, "image_dy": 0, "image_scale": 1 }, + "joker-small": { "image_dx": 0, "image_dy": 0, "image_scale": 1 }, + "back": { "image_dx": 0, "image_dy": 0, "image_scale": 1 } +} +``` + +- `image_dx` / `image_dy`:相对躯体区域的比例偏移,范围 -0.05 ~ +0.05 +- `image_scale`:缩放系数,范围 0.6 ~ 1.4 + +## 前端实现 + +### cardRenderer.js + +#### `drawBackSide()` 改动 +- 新增 `project` 参数以读取素材和 card_overrides +- 调用 `assetByType(project, 'back', 'back')` 获取素材 +- 有素材时绘制图片(contain 适配 + 偏移应用) +- `renderCard()` 调用处传入 `project` + +#### `drawJokerBody()` 改动 +- 读取 `override = design.image_dx/dy/scale`(由 `getEffectiveDesign()` 合并后传入的 design 包含 card_overrides 字段) +- 在图片绘制坐标上叠加 `bodyW * image_dx`、`bodyH * image_dy` +- 缩放叠加 `drawW * image_scale`、`drawH * image_scale` + +### projectStore.js +- 开放 `canHaveOverride` 覆盖 joker 和 back +- 添加图片偏移的 patch 方法(复用已有 `patchCardOverride` 或新增) + +### DesignPanel.vue +- 移除 `canHaveOverride` 对 joker/back 的限制 +- 当当前牌为 joker 或 back 时,显示图片位置微调滑块(dx/dy/缩放) + +## 后端实现 + +### exports/utils.py + +#### `generate_card_png()` back 分支 +- 查找 `project.assets.filter(asset_type='back')` 获取素材 +- `load_image_safe()` 加载图片 +- `draw_back()` 函数中用 `thumbnail` contain 适配 + 偏移 + +#### `draw_joker()` 改动 +- 读取 `card_overrides` 的 image_dx/dy/scale +- 在坐标计算中叠加偏移和缩放 + +## UI 滑块参数 +- dx: range -0.05 ~ +0.05, step 0.005 +- dy: range -0.05 ~ +0.05, step 0.005 +- scale: range 0.6 ~ 1.4, step 0.05 +(与现有 number_layout 一致) diff --git a/docs/superpowers/specs/2026-06-01-joker-symmetry-rendering.md b/docs/superpowers/specs/2026-06-01-joker-symmetry-rendering.md new file mode 100644 index 0000000..ec11f49 --- /dev/null +++ b/docs/superpowers/specs/2026-06-01-joker-symmetry-rendering.md @@ -0,0 +1,75 @@ +# 大小王对称渲染 + +## 背景 + +当前大小王在有素材图片时,将整张图居中适配绘制在躯体区域,无对称处理。需要改为:上半区显示原图,下半区显示原图的垂直镜像,实现类似 JQK 人像的对称效果。 + +## 需求 + +- 上传的素材图片作为**上半部分**的源图 +- 躯体区域纵向平分:上半区绘制原图(居中适配),下半区绘制垂直翻转后的同一张图 +- 角标 JOKER + BIG/SMALL 不变 +- 无素材时回退到白字 "JOKER" 居中(不变) + +## 修改范围 + +| 文件 | 函数 | +|------|------| +| `frontend/src/utils/cardRenderer.js` | `drawJokerBody()` | +| `backend/apps/exports/utils.py` | `draw_joker()` | + +## 前端实现 (Canvas2D) + +在 `drawJokerBody` 中,当有素材图片时: + +```js +// 躯体区域上下平分 +const halfH = bodyH / 2 + +// 上半区:原图居中适配 +const fit = fitSize(imgW, imgH, bodyW, halfH) +ctx.drawImage(img, bodyX + (bodyW - fit.w) / 2, bodyY + (halfH - fit.h) / 2, fit.w, fit.h) + +// 下半区:垂直翻转后居中适配 +ctx.save() +ctx.translate(0, bodyY + halfH) // 移动到下半区中线 +ctx.scale(1, -1) // 垂直翻转 +ctx.drawImage(img, bodyX + (bodyW - fit.w) / 2, -(halfH - fit.h) / 2, fit.w, fit.h) +ctx.restore() +``` + +## 后端实现 (PIL) + +在 `draw_joker` 中,当有素材图片时: + +```python +half_h = body_h // 2 + +# 上半区:原图居中适配 +img.thumbnail((body_w, half_h), Image.LANCZOS) +x1 = body_x + (body_w - img.width) // 2 +y1 = body_y + (half_h - img.height) // 2 +canvas.paste(img, (x1, y1), img) + +# 下半区:垂直翻转后居中适配 +img_flipped = img.transpose(Image.FLIP_TOP_BOTTOM) +y2 = body_y + half_h + (half_h - img_flipped.height) // 2 +canvas.paste(img_flipped, (x1, y2), img_flipped) +``` + +Note: `thumbnail` 是原地修改,所以上半区粘贴完成后需要重新加载原图再做翻转,或者先复制一份。 + +修正: +```python +img_copy = img.copy() +img_copy.thumbnail((body_w, half_h), Image.LANCZOS) +# 上半区粘贴 img_copy +img_flipped = img_copy.transpose(Image.FLIP_TOP_BOTTOM) +# 下半区粘贴 img_flipped +``` + +## 注意事项 + +- 前端当前使用 `Math.max` (fill) 适配策略,改为对称后每个半区内图片尺寸一致 +- 后端当前使用 `thumbnail` (contain) 适配,保持一致即可 +- 躯体 padding 前端 18%/22%,后端 15%/20%,各自保持不变 diff --git a/frontend/src/components/DesignPanel.vue b/frontend/src/components/DesignPanel.vue index 7c5ce32..5893966 100644 --- a/frontend/src/components/DesignPanel.vue +++ b/frontend/src/components/DesignPanel.vue @@ -79,6 +79,24 @@ + +
+

图片位置微调

+

拖动滑块微调素材图片的位置与缩放(0 = 默认)

+
+ + + + + + +
+ +
+

数字牌花色位置微调

@@ -141,9 +159,7 @@ function setBorder(path, v) { store.patchDesign(path, v) } function setSuit(suit, path, v) { store.patchDesign(`suit_symbols.${suit}.${path}`, v) } function setDesign(path, v) { store.patchDesign(path, v) } -const canHaveOverride = computed(() => { - return !isJoker(store.currentCard) && store.currentCard !== 'back' -}) +const canHaveOverride = computed(() => true) function setOverrideBg() { if (!canHaveOverride.value) return store.patchCardOverride(store.currentCard, 'background_color', design.value.background_color) @@ -161,6 +177,25 @@ const isNumberCard = computed(() => { return /^[0-9]+$/.test(r) || r === 'A' }) +const showImageOffset = computed(() => { + return isJoker(store.currentCard) || store.currentCard === 'back' +}) + +function imageOffsetVal(key) { + if (!override.value) return key === 'image_scale' ? 1 : 0 + const v = Number(override.value[key]) + if (Number.isNaN(v)) return key === 'image_scale' ? 1 : 0 + return v +} +function setImageOffset(key, val) { + store.patchCardOverride(store.currentCard, key, parseFloat(val)) +} +function resetImageOffset() { + store.patchCardOverride(store.currentCard, 'image_dx', 0) + store.patchCardOverride(store.currentCard, 'image_dy', 0) + store.patchCardOverride(store.currentCard, 'image_scale', 1) +} + const selectedRank = ref(1) watch(() => store.currentCard, () => { if (isNumberCard.value) { diff --git a/frontend/src/utils/cardLayout.js b/frontend/src/utils/cardLayout.js index 7990aa2..b7ec2bd 100644 --- a/frontend/src/utils/cardLayout.js +++ b/frontend/src/utils/cardLayout.js @@ -16,7 +16,7 @@ export const LAYOUT_POSITIONS = { 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 }], + 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.45 }, { x: 0.50, y: 0.55 }, { 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 const SUIT_TEXT = { diff --git a/frontend/src/utils/cardRenderer.js b/frontend/src/utils/cardRenderer.js index 18e06ec..756fa9c 100644 --- a/frontend/src/utils/cardRenderer.js +++ b/frontend/src/utils/cardRenderer.js @@ -271,15 +271,30 @@ async function drawJokerBody(ctx, w, h, which, design, project) { } if (img && img.complete && img.naturalWidth) { - const ratio = img.naturalWidth / img.naturalHeight - const target = bodyW / bodyH + const halfH = bodyH / 2 + const imgRatio = img.naturalWidth / img.naturalHeight + const target = bodyW / halfH let drawW, drawH - if (ratio > target) { - drawW = bodyW; drawH = bodyW / ratio + if (imgRatio > target) { + drawW = bodyW; drawH = bodyW / imgRatio } else { - drawH = bodyH; drawW = bodyH * ratio + drawH = halfH; drawW = halfH * imgRatio } - ctx.drawImage(img, padX + (bodyW - drawW) / 2, padTop + (bodyH - drawH) / 2, drawW, drawH) + 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) + ctx.save() + ctx.translate(0, padTop + bodyH) + ctx.scale(1, -1) + ctx.drawImage(img, topX, (halfH - finalH) / 2 - offsetY, finalW, finalH) + ctx.restore() } else { // 退化 const big = Math.round(h * 0.25) @@ -313,21 +328,57 @@ async function drawJokerBody(ctx, w, h, which, design, project) { ctx.restore() } -function drawBackSide(ctx, w, h, design) { +async function drawBackSide(ctx, w, h, design, project) { 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() + + 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(design.image_dx) || 0 + const imageDy = Number(design.image_dy) || 0 + const imageScale = Number(design.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) + } else { + 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 ---------- */ @@ -365,7 +416,7 @@ export async function renderCard(canvas, project, cardKey) { await drawJokerBody(ctx, w, h, which, design, project) drawBorder(ctx, w, h, design) } else if (cardKey === 'back') { - drawBackSide(ctx, w, h, design) + await drawBackSide(ctx, w, h, design, project) } else { const [suit, rank] = cardKey.split('-') drawBackground(ctx, w, h, design)