Skip to content

实现 头像生成 工具

分类:general | 标签:头像、identicon、生成 根据种子生成对称头像

2.1 功能说明

根据种子生成对称头像

2.1.1 使用指南

功能说明

根据输入的种子(如昵称/邮箱)本地生成对称 Identicon 头像,可下载 PNG。

使用场景

无头像用户的默认标识、匿名化展示。

卡通头像 / 装饰

该页签包含两个本地功能,全部在浏览器 canvas 中绘制,不上传图片、不调用任何外部接口:

  • 头像装饰:选择本地图片后,可叠加国旗角标(内置六面几何图形简单的国旗)、节日边框(春节 / 圣诞 / 生日 / 金边)与挂件(圣诞帽 / 皇冠 / 爱心 / 星星)。三类装饰可任意组合,切换选项即时重绘,导出 PNG 为最终合成效果。
  • 随机卡通头像:由几何图形随机组合生成卡通脸,随机维度包括底色、肤色、发色、发型(4 种)、眼睛(4 种)、嘴巴(4 种)以及腮红、眼镜等配饰。点击「换一个」重新随机,也可锁定当前种子后再下载。

提示:上传的图片仅在本页内存中处理,刷新页面后需重新选择;下载使用 Blob 方式生成临时链接,下载完成后会立即释放。

2.2 代码实现

2.2.1 组件结构

本工具是一个基于 Vue 3 <script setup> 语法的单文件组件(SFC),统一包裹在 ToolPageShell 组件内,由它提供页面标题、工具 ID、分类与「使用指南」插槽等通用外壳;核心业务逻辑(响应式状态、计算属性、事件处理函数)全部写在 <script setup> 中,输入/输出通过 el-inputel-button 等 Element Plus 组件与用户交互,所有数据均在浏览器本地处理,不会上传服务器。

2.2.2 核心逻辑概览

<script setup> 中定义的主要函数/方法:

  • hash()
  • draw()
  • download()
  • downloadCanvas()
  • fillStar()
  • roundRect()
  • pickFile()
  • onFileChange()
  • removePhoto()
  • resetDeco()
  • drawDeco()
  • drawBorder()
  • drawPendant()
  • drawFlagBadge()
  • drawFlagRect()
  • downloadDeco()
  • pick()
  • randomCartoon()
  • setFaceShape()
  • drawCartoon()
  • drawHair()
  • drawEyes()
  • drawMouth()
  • downloadCartoon()

2.2.3 关键实现代码

以下是该工具的完整 <script setup> 实现(核心代码)。模板(<template>)与样式(<style>)已省略。

vue
import { ref, reactive, watch, onMounted, nextTick } from 'vue'
import ToolPageShell from '@/components/ToolPageShell.vue'

const tab = ref('identicon')

/* ---------------- 原有 Identicon ---------------- */
const seed = ref('user@example.com')
const cv = ref(null)

function hash(str) {
  let h = 2166136261
  for (let i = 0; i < str.length; i++) { h ^= str.charCodeAt(i); h = Math.imul(h, 16777619) }
  return h >>> 0
}
function draw() {
  const c = cv.value; if (!c) return
  const ctx = c.getContext('2d')
  const h = hash(seed.value || 'x')
  const bg = '#' + (h & 0xffffff).toString(16).padStart(6, '0')
  const fg = '#' + ((h >> 9) & 0xffffff).toString(16).padStart(6, '0')
  ctx.fillStyle = bg; ctx.fillRect(0, 0, 240, 240)
  const cell = 240 / 5
  for (let y = 0; y < 5; y++) {
    for (let x = 0; x < 3; x++) {
      if ((h >> (y * 3 + x)) & 1) {
        ctx.fillStyle = fg
        ctx.fillRect(x * cell, y * cell, cell, cell)
        ctx.fillRect((4 - x) * cell, y * cell, cell, cell)
      }
    }
  }
}
function download() {
  const a = document.createElement('a'); a.href = cv.value.toDataURL('image/png'); a.download = 'identicon.png'; a.click()
}

/* ---------------- 通用绘制辅助 ---------------- */
const SIZE = 320

function downloadCanvas(canvas, name) {
  if (!canvas) return
  canvas.toBlob(blob => {
    if (!blob) return
    const url = URL.createObjectURL(blob)
    const a = document.createElement('a')
    a.href = url
    a.download = name
    a.click()
    URL.revokeObjectURL(url)
  }, 'image/png')
}

function fillStar(ctx, cx, cy, r, rot) {
  ctx.beginPath()
  for (let i = 0; i < 5; i++) {
    const a = rot + i * (Math.PI * 2 / 5) - Math.PI / 2
    const b = a + Math.PI / 5
    ctx.lineTo(cx + r * Math.cos(a), cy + r * Math.sin(a))
    ctx.lineTo(cx + r * 0.382 * Math.cos(b), cy + r * 0.382 * Math.sin(b))
  }
  ctx.closePath()
  ctx.fill()
}

function roundRect(ctx, x, y, w, h, r) {
  ctx.beginPath()
  ctx.moveTo(x + r, y)
  ctx.arcTo(x + w, y, x + w, y + h, r)
  ctx.arcTo(x + w, y + h, x, y + h, r)
  ctx.arcTo(x, y + h, x, y, r)
  ctx.arcTo(x, y, x + w, y, r)
  ctx.closePath()
}

/* ---------------- 头像装饰 ---------------- */
const FLAGS = [
  { key: 'none', label: '无' },
  { key: 'cn', label: '中国' },
  { key: 'jp', label: '日本' },
  { key: 'fr', label: '法国' },
  { key: 'de', label: '德国' },
  { key: 'it', label: '意大利' },
  { key: 'se', label: '瑞典' }
]
const BORDERS = [
  { key: 'none', label: '无' },
  { key: 'spring', label: '春节红金' },
  { key: 'christmas', label: '圣诞条纹' },
  { key: 'birthday', label: '生日彩点' },
  { key: 'gold', label: '新年金边' }
]
const PENDANTS = [
  { key: 'none', label: '无' },
  { key: 'hat', label: '圣诞帽' },
  { key: 'crown', label: '皇冠' },
  { key: 'heart', label: '爱心' },
  { key: 'star', label: '星星' }
]

const subMode = ref('deco')
const decoCv = ref(null)
const fileInput = ref(null)
const flag = ref('none')
const border = ref('none')
const pendant = ref('none')
const hasPhoto = ref(false)
let photoImg = null

function pickFile() { fileInput.value?.click() }

function onFileChange(e) {
  const file = e.target.files && e.target.files[0]
  if (!file) return
  const url = URL.createObjectURL(file)
  const img = new Image()
  img.onload = () => {
    photoImg = img
    hasPhoto.value = true
    URL.revokeObjectURL(url)
    drawDeco()
  }
  img.onerror = () => { URL.revokeObjectURL(url) }
  img.src = url
  e.target.value = ''
}

function removePhoto() {
  photoImg = null
  hasPhoto.value = false
  drawDeco()
}

function resetDeco() {
  flag.value = 'none'
  border.value = 'none'
  pendant.value = 'none'
  drawDeco()
}

function drawDeco() {
  const c = decoCv.value
  if (!c) return
  const ctx = c.getContext('2d')
  ctx.clearRect(0, 0, SIZE, SIZE)

  if (photoImg) {
    const s = Math.max(SIZE / photoImg.width, SIZE / photoImg.height)
    const w = photoImg.width * s
    const h = photoImg.height * s
    ctx.drawImage(photoImg, (SIZE - w) / 2, (SIZE - h) / 2, w, h)
  } else {
    const g = ctx.createLinearGradient(0, 0, SIZE, SIZE)
    g.addColorStop(0, '#e8f1ff')
    g.addColorStop(1, '#cfe0f7')
    ctx.fillStyle = g
    ctx.fillRect(0, 0, SIZE, SIZE)
    ctx.fillStyle = '#8aa4c8'
    ctx.font = '16px sans-serif'
    ctx.textAlign = 'center'
    ctx.fillText('请选择本地图片', SIZE / 2, SIZE / 2)
    ctx.textAlign = 'start'
  }

  drawBorder(ctx, border.value)
  drawPendant(ctx, pendant.value)
  drawFlagBadge(ctx, flag.value)
}

function drawBorder(ctx, key) {
  if (key === 'none') return
  if (key === 'spring') {
    ctx.strokeStyle = '#c62828'
    ctx.lineWidth = 18
    ctx.strokeRect(9, 9, SIZE - 18, SIZE - 18)
    ctx.strokeStyle = '#f5c542'
    ctx.lineWidth = 3
    ctx.strokeRect(21, 21, SIZE - 42, SIZE - 42)
    ctx.fillStyle = '#f5c542'
    const corners = [[18, 18], [SIZE - 18, 18], [18, SIZE - 18], [SIZE - 18, SIZE - 18]]
    corners.forEach(([x, y]) => {
      ctx.beginPath()
      ctx.arc(x, y, 9, 0, Math.PI * 2)
      ctx.fill()
    })
  } else if (key === 'christmas') {
    const band = 16
    ctx.save()
    ctx.beginPath()
    ctx.rect(0, 0, SIZE, SIZE)
    ctx.rect(band, band, SIZE - band * 2, SIZE - band * 2)
    ctx.clip('evenodd')
    ctx.fillStyle = '#ffffff'
    ctx.fillRect(0, 0, SIZE, SIZE)
    ctx.strokeStyle = '#d32f2f'
    ctx.lineWidth = 10
    for (let i = -SIZE; i < SIZE * 2; i += 26) {
      ctx.beginPath()
      ctx.moveTo(i, 0)
      ctx.lineTo(i + SIZE, SIZE)
      ctx.stroke()
    }
    ctx.restore()
    ctx.strokeStyle = '#2e7d32'
    ctx.lineWidth = 4
    ctx.strokeRect(2, 2, SIZE - 4, SIZE - 4)
    ctx.strokeRect(band + 2, band + 2, SIZE - band * 2 - 4, SIZE - band * 2 - 4)
  } else if (key === 'birthday') {
    ctx.strokeStyle = '#ffffff'
    ctx.lineWidth = 16
    ctx.strokeRect(8, 8, SIZE - 16, SIZE - 16)
    const palette = ['#ff6b6b', '#ffa94d', '#ffd43b', '#69db7c', '#4dabf7', '#b197fc']
    let n = 0
    for (let x = 16; x <= SIZE - 16; x += 24) {
      ctx.fillStyle = palette[n++ % palette.length]
      ctx.beginPath(); ctx.arc(x, 8, 6, 0, Math.PI * 2); ctx.fill()
      ctx.beginPath(); ctx.arc(x, SIZE - 8, 6, 0, Math.PI * 2); ctx.fill()
    }
    for (let y = 40; y <= SIZE - 40; y += 24) {
      ctx.fillStyle = palette[n++ % palette.length]
      ctx.beginPath(); ctx.arc(8, y, 6, 0, Math.PI * 2); ctx.fill()
      ctx.beginPath(); ctx.arc(SIZE - 8, y, 6, 0, Math.PI * 2); ctx.fill()
    }
  } else if (key === 'gold') {
    const g = ctx.createLinearGradient(0, 0, SIZE, SIZE)
    g.addColorStop(0, '#f9e29c')
    g.addColorStop(0.5, '#c9a227')
    g.addColorStop(1, '#f9e29c')
    ctx.strokeStyle = g
    ctx.lineWidth = 16
    ctx.strokeRect(8, 8, SIZE - 16, SIZE - 16)
    ctx.strokeStyle = 'rgba(255,255,255,0.7)'
    ctx.lineWidth = 2
    ctx.strokeRect(18, 18, SIZE - 36, SIZE - 36)
  }
}

function drawPendant(ctx, key) {
  if (key === 'none') return
  if (key === 'hat') {
    ctx.fillStyle = '#d32f2f'
    ctx.beginPath()
    ctx.moveTo(52, 96)
    ctx.quadraticCurveTo(96, 8, 168, 78)
    ctx.quadraticCurveTo(120, 74, 52, 96)
    ctx.closePath()
    ctx.fill()
    ctx.fillStyle = '#ffffff'
    roundRect(ctx, 40, 88, 140, 22, 11)
    ctx.fill()
    ctx.beginPath()
    ctx.arc(172, 74, 15, 0, Math.PI * 2)
    ctx.fill()
  } else if (key === 'crown') {
    ctx.fillStyle = '#f2b705'
    ctx.beginPath()
    ctx.moveTo(96, 78)
    ctx.lineTo(118, 34)
    ctx.lineTo(140, 68)
    ctx.lineTo(160, 26)
    ctx.lineTo(180, 68)
    ctx.lineTo(202, 34)
    ctx.lineTo(224, 78)
    ctx.closePath()
    ctx.fill()
    ctx.fillStyle = '#d99e00'
    roundRect(ctx, 94, 76, 132, 16, 6)
    ctx.fill()
    ctx.fillStyle = '#fff3b0'
    ctx.beginPath(); ctx.arc(160, 62, 7, 0, Math.PI * 2); ctx.fill()
  } else if (key === 'heart') {
    ctx.fillStyle = '#e53935'
    const cx = 58
    const cy = 250
    const s = 22
    ctx.beginPath()
    ctx.moveTo(cx, cy + s * 0.8)
    ctx.bezierCurveTo(cx - s * 1.6, cy - s * 0.4, cx - s * 0.5, cy - s * 1.4, cx, cy - s * 0.4)
    ctx.bezierCurveTo(cx + s * 0.5, cy - s * 1.4, cx + s * 1.6, cy - s * 0.4, cx, cy + s * 0.8)
    ctx.closePath()
    ctx.fill()
  } else if (key === 'star') {
    ctx.fillStyle = '#ffc93c'
    fillStar(ctx, 262, 62, 34, 0)
    ctx.fillStyle = '#fff1a8'
    fillStar(ctx, 262, 62, 16, 0)
  }
}

function drawFlagBadge(ctx, key) {
  if (key === 'none') return
  const cx = SIZE - 56
  const cy = SIZE - 56
  const r = 42
  ctx.save()
  ctx.beginPath()
  ctx.arc(cx, cy, r, 0, Math.PI * 2)
  ctx.clip()
  const x = cx - r
  const y = cy - r
  const w = r * 2
  const h = r * 2
  drawFlagRect(ctx, key, x, y, w, h)
  ctx.restore()
  ctx.beginPath()
  ctx.arc(cx, cy, r, 0, Math.PI * 2)
  ctx.strokeStyle = '#ffffff'
  ctx.lineWidth = 5
  ctx.stroke()
}

function drawFlagRect(ctx, key, x, y, w, h) {
  if (key === 'cn') {
    ctx.fillStyle = '#de2910'
    ctx.fillRect(x, y, w, h)
    ctx.fillStyle = '#ffde00'
    fillStar(ctx, x + w * 0.28, y + h * 0.34, w * 0.15, 0)
    const smalls = [[0.5, 0.16], [0.6, 0.26], [0.6, 0.42], [0.5, 0.52]]
    smalls.forEach(([sx, sy]) => fillStar(ctx, x + w * sx, y + h * sy, w * 0.055, 0.4))
  } else if (key === 'jp') {
    ctx.fillStyle = '#ffffff'
    ctx.fillRect(x, y, w, h)
    ctx.fillStyle = '#bc002d'
    ctx.beginPath()
    ctx.arc(x + w / 2, y + h / 2, w * 0.3, 0, Math.PI * 2)
    ctx.fill()
  } else if (key === 'fr') {
    const cols = ['#002395', '#ffffff', '#ed2939']
    cols.forEach((c, i) => { ctx.fillStyle = c; ctx.fillRect(x + (w / 3) * i, y, w / 3 + 1, h) })
  } else if (key === 'de') {
    const cols = ['#000000', '#dd0000', '#ffce00']
    cols.forEach((c, i) => { ctx.fillStyle = c; ctx.fillRect(x, y + (h / 3) * i, w, h / 3 + 1) })
  } else if (key === 'it') {
    const cols = ['#009246', '#ffffff', '#ce2b37']
    cols.forEach((c, i) => { ctx.fillStyle = c; ctx.fillRect(x + (w / 3) * i, y, w / 3 + 1, h) })
  } else if (key === 'se') {
    ctx.fillStyle = '#006aa7'
    ctx.fillRect(x, y, w, h)
    ctx.fillStyle = '#fecc00'
    ctx.fillRect(x + w * 0.33, y, w * 0.16, h)
    ctx.fillRect(x, y + h * 0.42, w, h * 0.16)
  }
}

function downloadDeco() { downloadCanvas(decoCv.value, 'avatar-decorated.png') }

watch([flag, border, pendant], () => drawDeco())

/* ---------------- 随机卡通头像 ---------------- */
const HAIR_NAMES = ['短发', '双马尾', '中分长发', '爆炸头']
const EYE_NAMES = ['圆眼', '弯弯眼', '眯眼', '大眼']
const MOUTH_NAMES = ['微笑', '大笑', '抿嘴', '惊讶']

const SKINS = ['#ffe0bd', '#f7d1b0', '#eac086', '#d9a06b', '#b97a56']
const HAIRS = ['#2f2f2f', '#4b3621', '#7b4b2a', '#b5651d', '#5b4b8a', '#c0392b']
const BGS = ['#e3f2fd', '#fce4ec', '#e8f5e9', '#fff3e0', '#ede7f6', '#e0f7fa']

const cartoonCv = ref(null)
const cartoon = reactive({
  bg: BGS[0], skin: SKINS[0], hairColor: HAIRS[0],
  hair: 0, eye: 0, mouth: 0, blush: true, glasses: false, round: true
})

function pick(arr) { return arr[Math.floor(Math.random() * arr.length)] }

function randomCartoon() {
  cartoon.bg = pick(BGS)
  cartoon.skin = pick(SKINS)
  cartoon.hairColor = pick(HAIRS)
  cartoon.hair = Math.floor(Math.random() * HAIR_NAMES.length)
  cartoon.eye = Math.floor(Math.random() * EYE_NAMES.length)
  cartoon.mouth = Math.floor(Math.random() * MOUTH_NAMES.length)
  cartoon.blush = Math.random() < 0.6
  cartoon.glasses = Math.random() < 0.35
  cartoon.round = Math.random() < 0.7
  drawCartoon()
}

function setFaceShape(round) {
  cartoon.round = round
  drawCartoon()
}

function drawCartoon() {
  const c = cartoonCv.value
  if (!c) return
  const ctx = c.getContext('2d')
  ctx.clearRect(0, 0, SIZE, SIZE)

  ctx.fillStyle = cartoon.bg
  ctx.fillRect(0, 0, SIZE, SIZE)
  ctx.fillStyle = 'rgba(255,255,255,0.55)'
  ctx.beginPath()
  ctx.arc(SIZE / 2, SIZE / 2 + 10, 118, 0, Math.PI * 2)
  ctx.fill()

  const cx = SIZE / 2
  const cy = SIZE / 2 + 6
  const fw = 92
  const fh = 106

  // 耳朵
  ctx.fillStyle = cartoon.skin
  ctx.beginPath(); ctx.arc(cx - fw + 6, cy + 6, 15, 0, Math.PI * 2); ctx.fill()
  ctx.beginPath(); ctx.arc(cx + fw - 6, cy + 6, 15, 0, Math.PI * 2); ctx.fill()

  // 脖子
  ctx.fillStyle = cartoon.skin
  ctx.fillRect(cx - 24, cy + fh - 34, 48, 52)

  // 衣服
  ctx.fillStyle = '#5c7cfa'
  ctx.beginPath()
  ctx.moveTo(cx - 110, SIZE)
  ctx.quadraticCurveTo(cx, cy + fh - 4, cx + 110, SIZE)
  ctx.closePath()
  ctx.fill()

  // 脸
  ctx.fillStyle = cartoon.skin
  if (cartoon.round) {
    ctx.beginPath()
    ctx.ellipse(cx, cy, fw, fh, 0, 0, Math.PI * 2)
    ctx.fill()
  } else {
    roundRect(ctx, cx - fw, cy - fh, fw * 2, fh * 2, 42)
    ctx.fill()
  }

  drawHair(ctx, cx, cy, fw, fh)
  drawEyes(ctx, cx, cy)
  drawMouth(ctx, cx, cy)

  if (cartoon.blush) {
    ctx.fillStyle = 'rgba(255,138,128,0.45)'
    ctx.beginPath(); ctx.ellipse(cx - 54, cy + 26, 17, 11, 0, 0, Math.PI * 2); ctx.fill()
    ctx.beginPath(); ctx.ellipse(cx + 54, cy + 26, 17, 11, 0, 0, Math.PI * 2); ctx.fill()
  }

  // 鼻子
  ctx.strokeStyle = 'rgba(0,0,0,0.35)'
  ctx.lineWidth = 3
  ctx.lineCap = 'round'
  ctx.beginPath()
  ctx.moveTo(cx, cy + 2)
  ctx.lineTo(cx - 5, cy + 16)
  ctx.lineTo(cx + 4, cy + 16)
  ctx.stroke()

  if (cartoon.glasses) {
    ctx.strokeStyle = '#37474f'
    ctx.lineWidth = 4
    ctx.beginPath(); ctx.arc(cx - 32, cy - 12, 23, 0, Math.PI * 2); ctx.stroke()
    ctx.beginPath(); ctx.arc(cx + 32, cy - 12, 23, 0, Math.PI * 2); ctx.stroke()
    ctx.beginPath(); ctx.moveTo(cx - 9, cy - 12); ctx.lineTo(cx + 9, cy - 12); ctx.stroke()
  }
}

function drawHair(ctx, cx, cy, fw, fh) {
  ctx.fillStyle = cartoon.hairColor
  if (cartoon.hair === 0) {
    ctx.beginPath()
    ctx.ellipse(cx, cy - fh * 0.5, fw + 4, fh * 0.62, 0, Math.PI, Math.PI * 2)
    ctx.fill()
    ctx.fillRect(cx - fw - 4, cy - fh * 0.5, 12, fh * 0.35)
    ctx.fillRect(cx + fw - 8, cy - fh * 0.5, 12, fh * 0.35)
  } else if (cartoon.hair === 1) {
    ctx.beginPath()
    ctx.ellipse(cx, cy - fh * 0.5, fw + 4, fh * 0.6, 0, Math.PI, Math.PI * 2)
    ctx.fill()
    ctx.beginPath(); ctx.ellipse(cx - fw - 12, cy - 6, 22, 40, 0, 0, Math.PI * 2); ctx.fill()
    ctx.beginPath(); ctx.ellipse(cx + fw + 12, cy - 6, 22, 40, 0, 0, Math.PI * 2); ctx.fill()
  } else if (cartoon.hair === 2) {
    ctx.beginPath()
    ctx.ellipse(cx, cy - fh * 0.42, fw + 8, fh * 0.66, 0, Math.PI, Math.PI * 2)
    ctx.fill()
    ctx.fillRect(cx - fw - 8, cy - fh * 0.42, 18, fh * 1.05)
    ctx.fillRect(cx + fw - 10, cy - fh * 0.42, 18, fh * 1.05)
    ctx.fillStyle = cartoon.skin
    ctx.beginPath()
    ctx.moveTo(cx, cy - fh * 0.95)
    ctx.lineTo(cx - 16, cy - fh * 0.5)
    ctx.lineTo(cx + 16, cy - fh * 0.5)
    ctx.closePath()
    ctx.fill()
  } else {
    ctx.beginPath()
    ctx.arc(cx, cy - fh * 0.46, fw + 14, 0, Math.PI * 2)
    ctx.fill()
    ctx.fillStyle = cartoon.skin
    ctx.beginPath()
    ctx.ellipse(cx, cy + 4, fw, fh, 0, 0, Math.PI * 2)
    ctx.fill()
  }
}

function drawEyes(ctx, cx, cy) {
  const ey = cy - 12
  ctx.fillStyle = '#2b2b2b'
  ctx.strokeStyle = '#2b2b2b'
  ctx.lineWidth = 4
  ctx.lineCap = 'round'
  if (cartoon.eye === 0) {
    ctx.beginPath(); ctx.arc(cx - 32, ey, 9, 0, Math.PI * 2); ctx.fill()
    ctx.beginPath(); ctx.arc(cx + 32, ey, 9, 0, Math.PI * 2); ctx.fill()
  } else if (cartoon.eye === 1) {
    ctx.beginPath(); ctx.arc(cx - 32, ey + 4, 11, Math.PI * 1.15, Math.PI * 1.85); ctx.stroke()
    ctx.beginPath(); ctx.arc(cx + 32, ey + 4, 11, Math.PI * 1.15, Math.PI * 1.85); ctx.stroke()
  } else if (cartoon.eye === 2) {
    ctx.beginPath(); ctx.moveTo(cx - 43, ey); ctx.lineTo(cx - 21, ey); ctx.stroke()
    ctx.beginPath(); ctx.moveTo(cx + 21, ey); ctx.lineTo(cx + 43, ey); ctx.stroke()
  } else {
    ctx.fillStyle = '#ffffff'
    ctx.beginPath(); ctx.ellipse(cx - 32, ey, 15, 17, 0, 0, Math.PI * 2); ctx.fill()
    ctx.beginPath(); ctx.ellipse(cx + 32, ey, 15, 17, 0, 0, Math.PI * 2); ctx.fill()
    ctx.strokeStyle = '#2b2b2b'
    ctx.lineWidth = 3
    ctx.beginPath(); ctx.ellipse(cx - 32, ey, 15, 17, 0, 0, Math.PI * 2); ctx.stroke()
    ctx.beginPath(); ctx.ellipse(cx + 32, ey, 15, 17, 0, 0, Math.PI * 2); ctx.stroke()
    ctx.fillStyle = '#2b2b2b'
    ctx.beginPath(); ctx.arc(cx - 30, ey + 2, 7, 0, Math.PI * 2); ctx.fill()
    ctx.beginPath(); ctx.arc(cx + 34, ey + 2, 7, 0, Math.PI * 2); ctx.fill()
  }
  // 眉毛
  ctx.strokeStyle = cartoon.hairColor
  ctx.lineWidth = 4
  ctx.beginPath(); ctx.moveTo(cx - 46, ey - 24); ctx.quadraticCurveTo(cx - 32, ey - 31, cx - 18, ey - 24); ctx.stroke()
  ctx.beginPath(); ctx.moveTo(cx + 18, ey - 24); ctx.quadraticCurveTo(cx + 32, ey - 31, cx + 46, ey - 24); ctx.stroke()
}

function drawMouth(ctx, cx, cy) {
  const my = cy + 46
  ctx.strokeStyle = '#8d3b3b'
  ctx.fillStyle = '#c0392b'
  ctx.lineWidth = 4
  ctx.lineCap = 'round'
  if (cartoon.mouth === 0) {
    ctx.beginPath(); ctx.arc(cx, my - 8, 18, Math.PI * 0.15, Math.PI * 0.85); ctx.stroke()
  } else if (cartoon.mouth === 1) {
    ctx.beginPath()
    ctx.moveTo(cx - 24, my - 6)
    ctx.quadraticCurveTo(cx, my + 22, cx + 24, my - 6)
    ctx.closePath()
    ctx.fill()
    ctx.fillStyle = '#ffffff'
    ctx.beginPath()
    ctx.moveTo(cx - 20, my - 6)
    ctx.lineTo(cx + 20, my - 6)
    ctx.lineTo(cx + 16, my + 1)
    ctx.lineTo(cx - 16, my + 1)
    ctx.closePath()
    ctx.fill()
  } else if (cartoon.mouth === 2) {
    ctx.beginPath(); ctx.moveTo(cx - 18, my); ctx.lineTo(cx + 18, my); ctx.stroke()
  } else {
    ctx.fillStyle = '#8d3b3b'
    ctx.beginPath(); ctx.ellipse(cx, my, 11, 14, 0, 0, Math.PI * 2); ctx.fill()
  }
}

function downloadCartoon() { downloadCanvas(cartoonCv.value, 'cartoon-avatar.png') }

watch(tab, v => {
  if (v === 'fancy') nextTick(() => { drawDeco(); drawCartoon() })
})
watch(subMode, () => nextTick(() => { drawDeco(); drawCartoon() }))

onMounted(() => {
  draw()
  randomCartoon()
  nextTick(drawDeco)
})

2.3 效果截图

头像生成 效果截图

工具访问地址:https://www.i91tools.com/tools/avatar-generator