/** * 头像特效:定义 + 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() }