Appearance
实现 头像生成 工具
分类: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-input、el-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 效果截图
![]()