feat: 背面图案支持 + 大小王素材位置微调 + 10号牌pip修复

This commit is contained in:
Poker Design Developer
2026-06-01 21:48:51 +08:00
parent f64c94a2f4
commit b0cdd8c3ad
6 changed files with 324 additions and 37 deletions

View File

@@ -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('-')

View File

@@ -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 一致)

View File

@@ -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%,各自保持不变

View File

@@ -79,6 +79,24 @@
</div>
</section>
<!-- 图片位置微调大小王 / 背面 -->
<section v-if="showImageOffset">
<h4>图片位置微调</h4>
<p class="hint">拖动滑块微调素材图片的位置与缩放0 = 默认</p>
<div class="pip-row">
<label class="mini-label">dx</label>
<input type="range" min="-0.05" max="0.05" step="0.005"
:value="imageOffsetVal('image_dx')" @input="setImageOffset('image_dx', $event.target.value)" />
<label class="mini-label">dy</label>
<input type="range" min="-0.05" max="0.05" step="0.005"
:value="imageOffsetVal('image_dy')" @input="setImageOffset('image_dy', $event.target.value)" />
<label class="mini-label">缩放</label>
<input type="range" min="0.6" max="1.4" step="0.05"
:value="imageOffsetVal('image_scale')" @input="setImageOffset('image_scale', $event.target.value)" />
</div>
<button @click="resetImageOffset" class="mini ghost">重置图片位置</button>
</section>
<!-- 数字牌花色位置微调 -->
<section v-if="isNumberCard">
<h4>数字牌花色位置微调</h4>
@@ -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) {

View File

@@ -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 = {

View File

@@ -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,9 +328,44 @@ 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)
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
@@ -329,6 +379,7 @@ function drawBackSide(ctx, w, h, design) {
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)