feat: 对称方式可选 - 垂直翻转 / 180° 旋转
为 JQK 人物图和大小王素材添加 symmetry_mode 字段: - 'flip'(默认):下半身用垂直镜像(沿水平中线翻转,左右不变) - 'rotate':下半身用 180° 旋转(上下颠倒 + 左右镜像) 改动: - 后端 draw_face_card / draw_joker:根据 symmetry_mode 选不同的 PIL transpose - 前端 cardRenderer.js:face card 和 joker 都用统一的分支: - flip: translate + scale(1, -1) - rotate: translate + rotate(Math.PI) - DesignPanel.vue:JQK/大小王面板加下拉选择器,存到 card_overrides
This commit is contained in:
@@ -248,16 +248,24 @@ def draw_face_card(canvas, design, suit, rank, project, card_key, asset):
|
|||||||
new_w = int(body_h * img_ratio)
|
new_w = int(body_h * img_ratio)
|
||||||
img = img.resize((new_w, new_h), Image.LANCZOS)
|
img = img.resize((new_w, new_h), Image.LANCZOS)
|
||||||
|
|
||||||
# 取上半部分 + 上下翻转的下半部分(取上半 = 下半 = 整个图按上下中线翻转)
|
# 取上半部分 + 下半部分
|
||||||
top = img.crop((0, 0, img.width, img.height // 2))
|
top = img.crop((0, 0, img.width, img.height // 2))
|
||||||
bot = img.crop((0, img.height // 2, img.width, img.height)).transpose(Image.FLIP_TOP_BOTTOM)
|
bot_src = img.crop((0, img.height // 2, img.width, img.height))
|
||||||
|
# symmetry_mode: 'flip' = 垂直翻转;'rotate' = 180° 旋转
|
||||||
|
sym_mode = design.get('symmetry_mode', 'flip')
|
||||||
|
if sym_mode == 'rotate':
|
||||||
|
# 180° 旋转 = 先水平翻转再垂直翻转(PIL 没有一步到位的 180 旋转 transpose)
|
||||||
|
bot_src = bot_src.transpose(Image.FLIP_LEFT_RIGHT)
|
||||||
|
bot_src = bot_src.transpose(Image.FLIP_TOP_BOTTOM)
|
||||||
|
else: # 'flip'(默认)
|
||||||
|
bot_src = bot_src.transpose(Image.FLIP_TOP_BOTTOM)
|
||||||
|
|
||||||
# 拼接成 body_h 高的新图
|
# 拼接成 body_h 高的新图
|
||||||
full = Image.new('RGBA', (img.width, body_h), (0, 0, 0, 0))
|
full = Image.new('RGBA', (img.width, body_h), (0, 0, 0, 0))
|
||||||
top = top.resize((img.width, body_h // 2), Image.LANCZOS)
|
top = top.resize((img.width, body_h // 2), Image.LANCZOS)
|
||||||
bot = bot.resize((img.width, body_h - body_h // 2), Image.LANCZOS)
|
bot_src = bot_src.resize((img.width, body_h - body_h // 2), Image.LANCZOS)
|
||||||
full.paste(top, (0, 0), top)
|
full.paste(top, (0, 0), top)
|
||||||
full.paste(bot, (0, body_h // 2), bot)
|
full.paste(bot_src, (0, body_h // 2), bot_src)
|
||||||
|
|
||||||
full = full.resize((body_w, body_h), Image.LANCZOS)
|
full = full.resize((body_w, body_h), Image.LANCZOS)
|
||||||
canvas.alpha_composite(full, (body_pad_x, body_pad_y_top))
|
canvas.alpha_composite(full, (body_pad_x, body_pad_y_top))
|
||||||
@@ -316,9 +324,14 @@ def draw_joker(canvas, design, which, project, card_key, asset):
|
|||||||
x = body_pad_x + offset_x + (body_w - img_copy.width) // 2
|
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
|
y_top = body_pad_y_top + offset_y + (half_h - img_copy.height) // 2
|
||||||
canvas.alpha_composite(img_copy, (x, y_top))
|
canvas.alpha_composite(img_copy, (x, y_top))
|
||||||
img_flipped = img_copy.transpose(Image.FLIP_TOP_BOTTOM)
|
# symmetry_mode: 'flip' = 垂直翻转;'rotate' = 180° 旋转
|
||||||
y_bot = body_pad_y_top + half_h + offset_y + (half_h - img_flipped.height) // 2
|
sym_mode = design.get('symmetry_mode', 'flip')
|
||||||
canvas.alpha_composite(img_flipped, (x, y_bot))
|
if sym_mode == 'rotate':
|
||||||
|
img_bot = img_copy.transpose(Image.ROTATE_180)
|
||||||
|
else:
|
||||||
|
img_bot = img_copy.transpose(Image.FLIP_TOP_BOTTOM)
|
||||||
|
y_bot = body_pad_y_top + half_h + offset_y + (half_h - img_bot.height) // 2
|
||||||
|
canvas.alpha_composite(img_bot, (x, y_bot))
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
else:
|
else:
|
||||||
|
|||||||
@@ -79,6 +79,18 @@
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<!-- 对称方式(JQK / 大小王) -->
|
||||||
|
<section v-if="showSymmetryMode">
|
||||||
|
<h4>对称方式</h4>
|
||||||
|
<p class="hint">下半身素材的生成方式</p>
|
||||||
|
<div class="row">
|
||||||
|
<select :value="symmetryMode" @change="setSymmetryMode($event.target.value)">
|
||||||
|
<option value="flip">垂直翻转(左右不变)</option>
|
||||||
|
<option value="rotate">180° 旋转(左右镜像)</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
<!-- 图片位置微调(大小王 / 背面) -->
|
<!-- 图片位置微调(大小王 / 背面) -->
|
||||||
<section v-if="showImageOffset">
|
<section v-if="showImageOffset">
|
||||||
<h4>图片位置微调</h4>
|
<h4>图片位置微调</h4>
|
||||||
@@ -181,6 +193,24 @@ const showImageOffset = computed(() => {
|
|||||||
return isJoker(store.currentCard) || store.currentCard === 'back'
|
return isJoker(store.currentCard) || store.currentCard === 'back'
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const isFaceCard = computed(() => {
|
||||||
|
if (!store.currentCard || !store.currentCard.includes('-')) return false
|
||||||
|
const r = store.currentCard.split('-')[1]
|
||||||
|
return r === 'J' || r === 'Q' || r === 'K'
|
||||||
|
})
|
||||||
|
|
||||||
|
const showSymmetryMode = computed(() => {
|
||||||
|
return isFaceCard.value || isJoker(store.currentCard)
|
||||||
|
})
|
||||||
|
|
||||||
|
const symmetryMode = computed(() => design.value.symmetry_mode || 'flip')
|
||||||
|
|
||||||
|
function setSymmetryMode(v) {
|
||||||
|
// JQK 和大小王都存到 design 里(因为它们是单牌设置)
|
||||||
|
// 但 design 是合并后的,所以用 patchCardOverride 写到当前牌更干净
|
||||||
|
store.patchCardOverride(store.currentCard, 'symmetry_mode', v)
|
||||||
|
}
|
||||||
|
|
||||||
function imageOffsetVal(key) {
|
function imageOffsetVal(key) {
|
||||||
if (!override.value) return key === 'image_scale' ? 1 : 0
|
if (!override.value) return key === 'image_scale' ? 1 : 0
|
||||||
const v = Number(override.value[key])
|
const v = Number(override.value[key])
|
||||||
|
|||||||
@@ -212,27 +212,32 @@ async function drawFaceCardBody(ctx, w, h, suit, rank, design, project) {
|
|||||||
const drawX = padX + (bodyW - drawW) / 2
|
const drawX = padX + (bodyW - drawW) / 2
|
||||||
const drawY = padTop + (bodyH - drawH) / 2
|
const drawY = padTop + (bodyH - drawH) / 2
|
||||||
|
|
||||||
// 上下对称:先画上半,再把上半翻转贴到下半
|
// 上下对称:先画上半,再把下半用指定方式生成
|
||||||
const halfH = drawH / 2
|
const halfH = drawH / 2
|
||||||
|
// 上半:用 clip 限定为上半
|
||||||
ctx.save()
|
ctx.save()
|
||||||
// 上半(先画完整图,用 clip 限定为上半)
|
|
||||||
ctx.beginPath()
|
ctx.beginPath()
|
||||||
ctx.rect(drawX, drawY, drawW, halfH)
|
ctx.rect(drawX, drawY, drawW, halfH)
|
||||||
ctx.clip()
|
ctx.clip()
|
||||||
ctx.drawImage(img, drawX, drawY, drawW, drawH)
|
ctx.drawImage(img, drawX, drawY, drawW, drawH)
|
||||||
ctx.restore()
|
ctx.restore()
|
||||||
// 下半:把原图翻转 180° 贴到下半区域
|
// 下半:symmetry_mode = 'flip' 垂直翻转,'rotate' 180° 旋转
|
||||||
ctx.save()
|
const symMode = design.symmetry_mode || 'flip'
|
||||||
ctx.translate(drawX + drawW, drawY + drawH)
|
if (symMode === 'rotate') {
|
||||||
ctx.rotate(Math.PI)
|
// 180° 旋转:translate 到 (x+w, y+h) + 旋转 PI
|
||||||
// 现在画完整图(经过旋转坐标系),让它 fill bodyW x halfH
|
ctx.save()
|
||||||
const tmpW = drawW
|
ctx.translate(drawX + drawW, drawY + drawH)
|
||||||
const tmpH = halfH
|
ctx.rotate(Math.PI)
|
||||||
// 由于旋转,drawImage 时 x/y 是反向的;目标 = (0,0) 到 (drawW, halfH)
|
ctx.drawImage(img, 0, 0, drawW, drawH) // 旋转后图片左上角在 (0,0),覆盖 (-drawW,-drawH)~(0,0)
|
||||||
// 经过 180° 旋转,相当于在 (drawX, drawY) + (drawW, drawH) 处映射为 (-drawW, -drawH) 到 (0, 0)
|
ctx.restore()
|
||||||
// 所以让图片左上角对应 (0,0) 即可
|
} else {
|
||||||
ctx.drawImage(img, 0, 0, tmpW, tmpH * 2) // 0,0 -> drawW,halfH (after 180 rotation)
|
// 垂直翻转:translate 到 (x, y+halfH) + scale(1, -1)
|
||||||
ctx.restore()
|
ctx.save()
|
||||||
|
ctx.translate(drawX, drawY + halfH)
|
||||||
|
ctx.scale(1, -1)
|
||||||
|
ctx.drawImage(img, 0, 0, drawW, halfH)
|
||||||
|
ctx.restore()
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
// 退化:绘制大花色 + 字母
|
// 退化:绘制大花色 + 字母
|
||||||
const big = Math.round(h * 0.30)
|
const big = Math.round(h * 0.30)
|
||||||
@@ -290,11 +295,21 @@ async function drawJokerBody(ctx, w, h, which, design, project) {
|
|||||||
const topX = padX + offsetX + (bodyW - finalW) / 2
|
const topX = padX + offsetX + (bodyW - finalW) / 2
|
||||||
const topY = padTop + offsetY + (halfH - finalH) / 2
|
const topY = padTop + offsetY + (halfH - finalH) / 2
|
||||||
ctx.drawImage(img, topX, topY, finalW, finalH)
|
ctx.drawImage(img, topX, topY, finalW, finalH)
|
||||||
ctx.save()
|
// symmetry_mode: 'flip' 垂直翻转,'rotate' 180° 旋转
|
||||||
ctx.translate(0, padTop + bodyH)
|
const symMode = design.symmetry_mode || 'flip'
|
||||||
ctx.scale(1, -1)
|
if (symMode === 'rotate') {
|
||||||
ctx.drawImage(img, topX, (halfH - finalH) / 2 - offsetY, finalW, finalH)
|
ctx.save()
|
||||||
ctx.restore()
|
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 {
|
} else {
|
||||||
// 退化
|
// 退化
|
||||||
const big = Math.round(h * 0.25)
|
const big = Math.round(h * 0.25)
|
||||||
|
|||||||
Reference in New Issue
Block a user