Files
qitongxue-wx/src/utils/image/avatarfx.ts
2026-09-17 14:39:15 +08:00

276 lines
8.0 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 头像特效:定义 + Canvas 绘制
* 输出 720×720 头像 PNG(圆形 / 圆角方形两种形状:照片裁剪 + 彩色环 + 角标装饰 + 文字徽章)。
* 装饰全部用 Canvas 原语绘制(小程序 canvas 不能画 SVG),双端表现一致。
* 坐标约定:decor/badge 的 x/y 为百分比(0-100),CSS 实时预览与 Canvas 输出共用同一份定义。
*/
export type FxShape = 'circle' | 'square'
export interface FxDecor {
kind: 'star5' | 'sparkle' | 'dot' | 'bolt'
x: number // % (中心)
y: number // %
size: number // % (半径基准)
color: string
}
export interface FxBadge {
text: string
bg: string
color: string
x: number // %
y: number // %
}
export interface AvatarEffect {
key: string
name: string
/** 环:由外到内的多层同宽环 */
rings: string[]
ringWidth: number // px @基准尺寸
decors: FxDecor[]
badge?: FxBadge
}
/** 方形的圆角占比(相对边长) */
export const FX_SQUARE_RADIUS = 0.12
export const AVATAR_EFFECTS: AvatarEffect[] = [
{
key: 'guoqing',
name: '国庆·红旗',
rings: ['#DE2910', '#FFDE00'],
ringWidth: 26,
decors: [
{ kind: 'star5', x: 20, y: 18, size: 8, color: '#FFDE00' },
{ kind: 'star5', x: 31, y: 12, size: 5, color: '#FFDE00' },
{ kind: 'star5', x: 13, y: 30, size: 5, color: '#FFDE00' },
{ kind: 'star5', x: 24, y: 27, size: 4, color: '#FFDE00' },
{ kind: 'star5', x: 32, y: 22, size: 4, color: '#FFDE00' },
],
},
{
key: 'xinchun',
name: '新春·福运',
rings: ['#E60012'],
ringWidth: 30,
decors: [{ kind: 'star5', x: 20, y: 20, size: 6, color: '#FFDE00' }],
badge: { text: '福', bg: '#E60012', color: '#FFDE00', x: 79, y: 79 },
},
{
key: 'shengdan',
name: '圣诞·星语',
rings: ['#0E7A4A', '#FFFFFF'],
ringWidth: 26,
decors: [
{ kind: 'star5', x: 79, y: 17, size: 8, color: '#FFDE00' },
{ kind: 'dot', x: 22, y: 74, size: 2.5, color: '#FFFFFF' },
{ kind: 'dot', x: 70, y: 68, size: 2, color: '#FFFFFF' },
{ kind: 'dot', x: 34, y: 34, size: 2, color: '#FFFFFF' },
],
},
{
key: 'wansheng',
name: '万圣·夜',
rings: ['#FF7A00'],
ringWidth: 26,
decors: [{ kind: 'bolt', x: 76, y: 18, size: 9, color: '#FFC53D' }],
},
{
key: 'xingguang',
name: '星光·紫夜',
rings: ['#8B7CF6'],
ringWidth: 24,
decors: [
{ kind: 'sparkle', x: 18, y: 20, size: 7, color: '#FFC53D' },
{ kind: 'sparkle', x: 82, y: 26, size: 6, color: '#3ECFB2' },
{ kind: 'sparkle', x: 74, y: 78, size: 5, color: '#FF7BA9' },
],
},
{
key: 'caihong',
name: '彩虹·光环',
rings: ['#FF6B35', '#FFC53D', '#3ECFB2'],
ringWidth: 12,
decors: [],
},
{
key: 'yuanqi',
name: '元气·橙',
rings: ['#FF6B35'],
ringWidth: 28,
decors: [
{ kind: 'star5', x: 80, y: 18, size: 7, color: '#FF6B35' },
{ kind: 'dot', x: 16, y: 74, size: 3, color: '#FFC53D' },
],
},
{
key: 'bohe',
name: '薄荷·清新',
rings: ['#3ECFB2', '#C8F5EA'],
ringWidth: 22,
decors: [
{ kind: 'dot', x: 20, y: 22, size: 3, color: '#3ECFB2' },
{ kind: 'dot', x: 78, y: 74, size: 3, color: '#3ECFB2' },
],
},
]
// ===== Canvas 绘制 =====
function roundedRectPath(ctx: CanvasRenderingContext2D, x: number, y: number, w: number, h: number, r: number) {
const rr = Math.min(r, w / 2, h / 2)
ctx.beginPath()
ctx.moveTo(x + rr, y)
ctx.arcTo(x + w, y, x + w, y + h, rr)
ctx.arcTo(x + w, y + h, x, y + h, rr)
ctx.arcTo(x, y + h, x, y, rr)
ctx.arcTo(x, y, x + w, y, rr)
ctx.closePath()
}
function drawStar5(ctx: CanvasRenderingContext2D, cx: number, cy: number, r: number, color: string, rot = -Math.PI / 2) {
ctx.save()
ctx.beginPath()
for (let i = 0; i < 5; i++) {
const outerA = rot + (i * 2 * Math.PI) / 5
const innerA = outerA + Math.PI / 5
const ox = cx + Math.cos(outerA) * r
const oy = cy + Math.sin(outerA) * r
const ix = cx + Math.cos(innerA) * r * 0.42
const iy = cy + Math.sin(innerA) * r * 0.42
if (i === 0)
ctx.moveTo(ox, oy)
else ctx.lineTo(ox, oy)
ctx.lineTo(ix, iy)
}
ctx.closePath()
ctx.fillStyle = color
ctx.fill()
ctx.restore()
}
function drawSparkle(ctx: CanvasRenderingContext2D, cx: number, cy: number, r: number, color: string) {
ctx.save()
ctx.beginPath()
ctx.moveTo(cx, cy - r)
ctx.quadraticCurveTo(cx, cy, cx + r, cy)
ctx.quadraticCurveTo(cx, cy, cx, cy + r)
ctx.quadraticCurveTo(cx, cy, cx - r, cy)
ctx.quadraticCurveTo(cx, cy, cx, cy - r)
ctx.closePath()
ctx.fillStyle = color
ctx.fill()
ctx.restore()
}
function drawBolt(ctx: CanvasRenderingContext2D, cx: number, cy: number, r: number, color: string) {
ctx.save()
ctx.beginPath()
ctx.moveTo(cx + r * 0.15, cy - r)
ctx.lineTo(cx - r * 0.55, cy + r * 0.15)
ctx.lineTo(cx - r * 0.05, cy + r * 0.15)
ctx.lineTo(cx - r * 0.15, cy + r)
ctx.lineTo(cx + r * 0.55, cy - r * 0.15)
ctx.lineTo(cx + r * 0.05, cy - r * 0.15)
ctx.closePath()
ctx.fillStyle = color
ctx.fill()
ctx.restore()
}
function drawDot(ctx: CanvasRenderingContext2D, cx: number, cy: number, r: number, color: string) {
ctx.save()
ctx.beginPath()
ctx.arc(cx, cy, r, 0, Math.PI * 2)
ctx.fillStyle = color
ctx.fill()
ctx.restore()
}
function drawDecor(ctx: CanvasRenderingContext2D, size: number, d: FxDecor) {
const cx = (d.x / 100) * size
const cy = (d.y / 100) * size
const r = (d.size / 100) * size
if (d.kind === 'star5')
drawStar5(ctx, cx, cy, r, d.color)
else if (d.kind === 'sparkle')
drawSparkle(ctx, cx, cy, r, d.color)
else if (d.kind === 'bolt')
drawBolt(ctx, cx, cy, r, d.color)
else drawDot(ctx, cx, cy, r, d.color)
}
/** 绘制特效层(环 + 角标 + 徽章),支持形状与描边粗细缩放 */
export function drawEffect(ctx: CanvasRenderingContext2D, size: number, fx: AvatarEffect, shape: FxShape = 'circle', ringScale = 1) {
const w = fx.ringWidth * ringScale
const c = size / 2
if (shape === 'circle') {
let r = c - w / 2
for (const color of fx.rings) {
ctx.save()
ctx.beginPath()
ctx.arc(c, c, r, 0, Math.PI * 2)
ctx.lineWidth = w
ctx.strokeStyle = color
ctx.stroke()
ctx.restore()
r -= w + 2
}
}
else {
const R = size * FX_SQUARE_RADIUS
let inset = w / 2
for (const color of fx.rings) {
ctx.save()
roundedRectPath(ctx, inset, inset, size - inset * 2, size - inset * 2, R + inset * 0.6)
ctx.lineWidth = w
ctx.strokeStyle = color
ctx.stroke()
ctx.restore()
inset += w + 2
}
}
for (const d of fx.decors) drawDecor(ctx, size, d)
if (fx.badge) {
const bx = (fx.badge.x / 100) * size
const by = (fx.badge.y / 100) * size
const br = size * 0.085
drawDot(ctx, bx, by, br, fx.badge.bg)
ctx.save()
ctx.beginPath()
ctx.arc(bx, by, br - size * 0.008, 0, Math.PI * 2)
ctx.strokeStyle = '#FFFFFF'
ctx.lineWidth = size * 0.01
ctx.stroke()
ctx.restore()
ctx.save()
ctx.fillStyle = fx.badge.color
ctx.font = `bold ${Math.round(br * 1.15)}px sans-serif`
ctx.textAlign = 'center'
ctx.textBaseline = 'middle'
ctx.fillText(fx.badge.text, bx, by + br * 0.05)
ctx.restore()
}
}
/** 照片裁剪画到画布:圆形内切圆 / 圆角方形;zoom 以中心放大(1 = cover 铺满) */
export function drawPhoto(ctx: CanvasRenderingContext2D, size: number, img: unknown, shape: FxShape = 'circle', zoom = 1) {
const src = img as CanvasImageSource & { width: number, height: number }
const R = shape === 'circle' ? size / 2 : size * FX_SQUARE_RADIUS
ctx.save()
ctx.beginPath()
if (shape === 'circle')
ctx.arc(size / 2, size / 2, R, 0, Math.PI * 2)
else roundedRectPath(ctx, 0, 0, size, size, R)
ctx.clip()
const base = Math.min(src.width, src.height)
const sw = base / zoom
const sh = base / zoom
const sx = (src.width - sw) / 2
const sy = (src.height - sh) / 2
ctx.drawImage(src, sx, sy, sw, sh, 0, 0, size, size)
ctx.restore()
}