/** * 画个标 · DiceBear 头像风格引擎(M4) * 29 种 DiceBear v10 风格定义内置(js/data/sources/dicebear-*.js,由构建脚本生成), * 本模块:按"风格 × seed"确定性组合出无限头像 SVG(不占图标配额)。 * * 风格定义结构(v10 单文件 JSON): * canvas 画布模板(elements 树,component 节点按 name 引用 components) * components { name: { width, height, rotate/scale/translate 随机范围, variants: { 名: { elements, weight } } } } * colors { 色名: { values: [...] } }(element 属性 fill/stroke 以 {type:'color',name} 引用) * attributes 根 svg 属性(fill、shape-rendering 等) * * 渲染:seed → 确定性 PRNG → 每 component 加权选 variant + 随机变换 → color 引用取色 → 组装 SVG */ const HGB_Dicebear = (function () { 'use strict'; const VERSION = '20260822e'; const STYLES = [ 'adventurer', 'adventurer-neutral', 'avataaars', 'avataaars-neutral', 'big-ears', 'big-ears-neutral', 'big-smile', 'blobs', 'bottts', 'bottts-neutral', 'clay', 'constellation', 'critters', 'croodles', 'croodles-neutral', 'cutouts', 'disco', 'dylan', 'fun-emoji', 'glass', 'glyphs', 'icons', 'identicon', 'initial-face', 'initials', 'landscape', 'line-face', 'loops', 'lorelei', 'lorelei-neutral', 'micah', 'miniavs', 'moods', 'notionists', 'notionists-neutral', 'open-peeps', 'patchwork', 'personas', 'pixel-art', 'pixel-art-neutral', 'pixelbot', 'planets', 'rings', 'shape-grid', 'shapes', 'sprouts', 'squircles', 'stripes', 'thumbs', 'toon-head', 'triangles', 'voxel-art', 'voxel-bot', 'waves', 'weave' ]; const CN = { adventurer: '冒险家', 'adventurer-neutral': '冒险家中性', avataaars: '阿凡达', 'avataaars-neutral': '阿凡达中性', 'big-ears': '大耳朵', 'big-ears-neutral': '大耳朵中性', 'big-smile': '大笑脸', blobs: '斑点', bottts: '机器人', 'bottts-neutral': '机器人中性', clay: '黏土', constellation: '星座', critters: '小动物', croodles: '卡通线条', 'croodles-neutral': '卡通线条中性', cutouts: '剪贴画', disco: '迪斯科', dylan: '迪伦', 'fun-emoji': '趣味表情', glass: '玻璃拟态', glyphs: '字形符', icons: '图标风', identicon: '指纹几何', 'initial-face': '首字脸', initials: '首字母', landscape: '风景', 'line-face': '线条脸', loops: '回环', lorelei: '洛雷莱', 'lorelei-neutral': '洛雷莱中性', micah: '米迦', miniavs: '迷你头像', moods: '心情', notionists: 'Notion 风格', 'notionists-neutral': 'Notion 中性', 'open-peeps': '开放小人', patchwork: '拼布', personas: '人物剪影', 'pixel-art': '像素风', 'pixel-art-neutral': '像素风中性', pixelbot: '像素机器人', planets: '星球', rings: '圆环几何', 'shape-grid': '形状网格', shapes: '几何形状', sprouts: '萌芽', squircles: '圆角方', stripes: '条纹', thumbs: '拇指人', 'toon-head': '卡通头', triangles: '三角形', 'voxel-art': '体素艺术', 'voxel-bot': '体素机器人', waves: '波浪', weave: '编织' }; /* ---------- 确定性 PRNG(seed 字符串 → mulberry32) ---------- */ 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 rng(seedStr) { let s = hash(seedStr) || 1; return function () { s = (s + 0x6D2B79F5) | 0; let t = Math.imul(s ^ (s >>> 15), 1 | s); t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t; return ((t ^ (t >>> 14)) >>> 0) / 4294967296; }; } function pick(r, arr) { return arr[Math.floor(r() * arr.length)]; } function weightedPick(r, items) { let total = 0; for (const i of items) total += (i.weight != null ? i.weight : 0.01); if (total <= 0) return items[0].key; let x = r() * total; for (const i of items) { x -= (i.weight != null ? i.weight : 0.01); if (x <= 0) return i.key; } return items[items.length - 1].key; } function rn(v) { return Math.round(v * 100) / 100; } /** 变量引用解析(initials 风格:initial/fontFamily/fontWeight 等) */ function resolveVariable(name, seed, opts) { switch (name) { case 'initial': case 'initials': { const s = String(opts.initial || seed || '').trim(); if (s) return s[0].toUpperCase(); return opts.defaultInitial || 'D'; } case 'fontFamily': return opts.fontFamily || 'Arial'; case 'fontWeight': return String(opts.fontWeight != null ? opts.fontWeight : 600); default: return ''; } } /** 属性值:color 引用 → 从色板取色;variable 引用 → 变量解析;其他原样 */ function resolveAttr(v, r, colors, opts) { if (v && typeof v === 'object') { if (v.type === 'color' && v.name) { const c = colors[v.name]; if (typeof c === 'string') return c; // 已解析的单一颜色 if (c && Array.isArray(c.values) && c.values.length) return pick(r, c.values); return '#000000'; } if (v.type === 'variable' && v.name) { return resolveVariable(v.name, opts.seed, opts); } return ''; } return v; } /* ---------- 递归渲染 element / component / text 节点 ---------- */ function renderComponent(comp, r, colors, components, opts) { const names = Object.keys(comp.variants || {}); if (!names.length) return ''; const items = names.map(n => ({ key: n, weight: comp.variants[n].weight })); const variant = comp.variants[weightedPick(r, items)] || {}; // 随机变换(rotate/scale/translate 范围;个别风格 translate 只有 x 或 y,缺字段视为 0) const t = []; if (comp.translate) { const tx = comp.translate.x; const ty = comp.translate.y; const x = tx ? tx.min + r() * (tx.max - tx.min) : 0; const y = ty ? ty.min + r() * (ty.max - ty.min) : 0; if (x || y) t.push('translate(' + rn(x) + ' ' + rn(y) + ')'); } if (comp.rotate) { const deg = comp.rotate.min + r() * (comp.rotate.max - comp.rotate.min); if (deg) t.push('rotate(' + rn(deg) + ')'); } if (comp.scale) { const s = comp.scale.min + r() * (comp.scale.max - comp.scale.min); if (s !== 1) t.push('scale(' + rn(s) + ')'); } const inner = (variant.elements || []).map(e => renderNode(e, r, colors, components, opts)).join(''); return t.length ? '' + inner + '' : inner; } /** * 解析组件继承(DiceBear schema 的 extends 字段): * - identicon 的 row01~row05 → row;glass 的 shape01/shape02 → shape * 继承语义 = 复用父组件定义(无自身 variants 时完全继承;带环保护) */ function resolveComp(name, components) { const seen = new Set(); let cur = name; while (cur && !seen.has(cur)) { seen.add(cur); const c = (components || {})[cur]; if (!c) return (components || {})[name] || null; if (!c.extends) return c; if (c.variants && Object.keys(c.variants).length) return c; cur = c.extends; } return (components || {})[name] || null; } /** * 统一渲染节点:支持三种类型 * - component 节点(任意层级出现,如 shapes 的 )→ 渲染其变体 * - text 节点 {type:'text', value} → 渲染为纯文本(initials 风格文字,value 可为变量引用) * - element 节点 → 拼属性 + 递归 children * components 仅在 canvas 层传入(renderComponent 内部复用自身 variants 而非 components 表) */ function renderNode(el, r, colors, components, opts) { if (!el) return ''; if (el.type === 'component') { const comp = resolveComp(el.name, components); if (!comp) return ''; const inner = renderComponent(comp, r, colors, components, opts); // component 节点自带 transform 等属性(如 shapes 的 matrix 定位) const attrs = []; for (const k of Object.keys(el.attributes || {})) { const v = el.attributes[k]; if (v == null) continue; attrs.push(k + '="' + String(v).replace(/"/g, '"') + '"'); } return '' + inner + ''; } if (el.type === 'text') { const v = resolveAttr(el.value, r, colors, opts); return String(v != null ? v : ''); } // element:拼属性(color 引用取色,数字取整) const attrs = []; for (const k of Object.keys(el.attributes || {})) { const v = resolveAttr(el.attributes[k], r, colors, opts); if (v === '' || v == null) continue; const sv = typeof v === 'number' ? rn(v) : String(v); attrs.push(k + '="' + sv.replace(/"/g, '"') + '"'); } const children = (el.children || []).map(c => renderNode(c, r, colors, components, opts)).join(''); const name = (el.name || 'g').replace(/[^a-zA-Z0-9-]/g, '') || 'g'; return '<' + name + (attrs.length ? ' ' + attrs.join(' ') : '') + (children ? '>' + children + '' : '/>'); } /* ---------- 画布组装 ---------- */ function renderCanvas(canvas, r, colors, components, opts) { return (canvas.elements || []).map(n => renderNode(n, r, colors, components, opts)).join(''); } /** 画布是否已含显式 background 色元素(避免重复垫底) */ function hasBgElement(canvas) { function scan(el) { if (!el || typeof el !== 'object') return false; const a = el.attributes || {}; for (const k of Object.keys(a)) { const v = a[k]; if (v && typeof v === 'object' && v.type === 'color' && v.name === 'background') return true; if (v === 'background') return true; } return (el.children || []).some(scan); } return (canvas && canvas.elements || []).some(scan); } /** * 渲染单个风格头像 * @param {string} styleId 风格 id(如 adventurer / pixel-art) * @param {string} seed 确定性种子(同 seed 同输出) * @returns {{svg:string, styleId:string}} svg 为完整 文档 */ function renderAvatar(styleId, seed, opts) { const def = window['HGB_Dicebear_' + styleId.replace(/-/g, '_')]; if (!def) return { svg: '', styleId }; opts = opts || {}; const r = rng(String(seed || 'dicebear')); const colors = def.colors || {}; // 每色名先定一个颜色(保持跨 component 一致) const colorMap = {}; for (const name of Object.keys(colors)) { colorMap[name] = pick(r, colors[name].values || ['#000000']); } let inner = renderCanvas(def.canvas, r, colorMap, def.components || {}, { seed, ...opts }); const w = (def.canvas && def.canvas.width) || 100; const h = (def.canvas && def.canvas.height) || 100; // 背景填充:dicebear 官方渲染器默认按 backgroundColor 垫底。neutral 系列(pixel-art-neutral 等) // 脸部肤色依赖此背景,画布无显式背景元素且色板含 background 时先垫同尺寸 rect; // 无 background 色板的风格(adventurer/pixel-art 等)零影响 if (colorMap.background && !hasBgElement(def.canvas)) { inner = '' + inner; } const rootAttrs = []; for (const k of Object.keys(def.attributes || {})) { rootAttrs.push(k + '="' + String(def.attributes[k]).replace(/"/g, '"') + '"'); } const svg = '\n' + '' + inner + ''; return { svg, styleId }; } /** 懒加载单个风格定义(缓存 + 并发去重) */ const loaded = {}; const pending = {}; function loadStyle(styleId) { if (loaded[styleId]) return Promise.resolve(loaded[styleId]); if (pending[styleId]) return pending[styleId]; pending[styleId] = new Promise((resolve, reject) => { const s = document.createElement('script'); s.src = 'js/data/sources/dicebear-' + styleId + '.js?v=' + VERSION; s.onload = () => { const def = window['HGB_Dicebear_' + styleId.replace(/-/g, '_')]; if (!def) { reject(new Error('风格定义缺失: ' + styleId)); return; } loaded[styleId] = def; delete pending[styleId]; resolve(def); }; s.onerror = () => { delete pending[styleId]; reject(new Error('风格加载失败: ' + styleId)); }; document.head.appendChild(s); }); return pending[styleId]; } /** 加载全部风格(预载) */ function loadAll() { return Promise.allSettled(STYLES.map(loadStyle)); } return { VERSION, STYLES, CN, renderAvatar, loadStyle, loadAll }; })();