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