/** * 画个标 · 表情技能(12 个:motion 11 / emotion 1) * 全部为纯函数:run(args) 返回 {ok/tip/...},test() 可自跑。 * 用于表情生成后的评审/优化建议(动态节奏/物理/循环/体积/平台/情绪)。 * 来源标注「自研」或公开设计原则。 */ HGB_StickerSkills.register([ /* ---------- motion 动画节奏 ---------- */ { id: 'motion-timing', name: '节奏合理性', category: 'motion', emoji: '⏱', scene: '评估动画时长与帧率是否合理(过短看不清、过长拖沓、帧率过低卡顿)', summary: '按动画类型建议合适时长与帧率:表情通常 0.8-2s,弹跳/闪烁宜短促,漂浮/旋转可稍长。', params: { duration: '时长秒(默认 1.2)', fps: '帧率(默认 12)', type: '动画类型 id' }, source: '自研(动态表情通用规范)', run(args) { const d = Number(args.duration) || 1.2; const fps = Number(args.fps) || 12; const type = args.type || 'bounce'; const fastTypes = ['bounce', 'wiggle', 'fade']; const ideal = fastTypes.includes(type) ? 1.0 : 1.5; const fpsOk = fps >= 8; const durOk = Math.abs(d - ideal) <= 0.6; const tips = []; if (!fpsOk) tips.push('帧率过低(<8fps)会卡顿,建议 12fps'); if (!durOk) tips.push(d < ideal - 0.6 ? '时长过短,动作看不清,建议 ' + ideal + 's' : '时长偏长,动作拖沓,建议 ' + ideal + 's'); return { ok: fpsOk && durOk, fps, duration: d, idealDuration: ideal, tip: tips.length ? tips.join(';') : '节奏合理(' + d + 's × ' + fps + 'fps)' }; }, test() { return this.run({ duration: 1.2, fps: 12, type: 'bounce' }); } }, { id: 'bounce-physics', name: '弹跳物理', category: 'motion', emoji: '🏀', scene: '检查弹跳动画的落点与压缩是否符合物理直觉', summary: '弹跳应在上升段减速、下落段加速,触底时有轻微压缩(squash)并回弹,幅度不溢出画面。', params: { amplitude: '强度倍率(0.3-2)', elH: '元素高度 px' }, source: '自研(弹跳 12 原则)', run(args) { const amp = Number(args.amplitude) || 1; const elH = Number(args.elH) || 80; const jump = elH * 0.5 * amp; const overflows = jump > elH * 0.9; return { ok: amp <= 1.2 && !overflows, jumpHeight: Math.round(jump), tip: overflows ? '弹跳幅度过大,位移可能超出表情画面,建议 amplitude ≤ 1.2' : (amp <= 1.2 ? '弹跳物理合理(高度 ≈ 元素高的 ' + Math.round(50 * amp) + '%)' : '幅度偏大,建议 0.8-1.2 之间') }; }, test() { return this.run({ amplitude: 1, elH: 80 }); } }, { id: 'loop-seamless', name: '循环无缝', category: 'motion', emoji: '🔁', scene: '检查动画首尾帧是否平滑衔接(循环无跳变)', summary: '正弦/余弦类动画天然无缝;线性动画(旋转/位移动画)需确认首尾状态一致。', params: { type: '动画类型 id' }, source: '自研(循环动画原则)', run(args) { const t = args.type || 'bounce'; const seamless = ['pulse', 'wiggle', 'float', 'fade', 'spin', 'twirl'].includes(t); return { ok: seamless, tip: seamless ? '该类型使用正弦/整周期驱动,首尾自然衔接' : '该类型首尾状态可能跳变,建议确认结束帧与起始帧一致或使用整周期时长' }; }, test() { return this.run({ type: 'pulse' }); } }, { id: 'motion-simplicity', name: '动画克制性', category: 'motion', emoji: '🧘', scene: '检查表情动画是否过于花哨(多种动画叠加/幅度过大)', summary: '单个表情建议只做一种主动画(或主+极轻微辅助),幅度适中,避免炫技影响识别。', params: { animCount: '同时叠加的动画层数(默认 1)', amplitude: '强度倍率(默认 1)' }, source: '自研(动态设计克制原则)', run(args) { const n = Number(args.animCount) || 1; const amp = Number(args.amplitude) || 1; const ok = n <= 1 && amp <= 1.4; return { ok, tip: ok ? '动画克制,主次分明' : (n > 1 ? '同时叠加 ' + n + ' 层动画,建议只保留主动画' : '幅度过大,建议降低到 1.4 以内') }; }, test() { return this.run({ animCount: 1, amplitude: 1 }); } }, /* ---------- emotion 情绪传达 ---------- */ { id: 'emotion-fit', name: '情绪传达', category: 'motion', emoji: '😄', scene: '检查动画类型与表情主题情绪是否匹配(开心/生气/惊讶/难过/兴奋/平静/鼓励/警示等)', summary: '开心→弹跳/摇摆;生气→抖动;惊讶→心跳/脉冲;难过→漂浮;兴奋→弹跳旋转;平静→呼吸;鼓励→漂浮;警示→闪烁。', params: { keyword: '主题/场景关键词(中文)', type: '动画类型 id' }, source: '自研(情绪-动作映射,与 emotion-params 生成决策同源)', run(args) { const kw = String(args.keyword || ''); const type = args.type || 'bounce'; const map = [ { re: /开心|欢乐|庆祝|成功|胜利|点赞/, types: ['bounce', 'wiggle', 'spin'], hint: '弹跳/摇摆/旋转' }, { re: /生气|愤怒|暴躁/, types: ['shake'], hint: '高频抖动' }, { re: /惊讶|震惊|惊喜|哇/, types: ['heartbeat', 'pulse'], hint: '心跳脉冲' }, { re: /难过|伤心|失落/, types: ['float'], hint: '低落漂浮' }, { re: /兴奋|激动|high/, types: ['hopspin', 'bounce'], hint: '弹跳旋转' }, { re: /平静|舒缓|放松/, types: ['breath', 'float'], hint: '柔和呼吸' }, { re: /鼓励|加油|支持|打气/, types: ['float'], hint: '上下浮动' }, { re: /警示|提醒|注意|重要/, types: ['fade', 'pulse'], hint: '闪烁/脉冲' } ]; const hit = kw ? map.find(m => m.re.test(kw)) : null; if (!hit) return { ok: true, tip: '未识别情绪关键词,保持默认动画即可' }; const ok = hit.types.includes(type); return { ok, tip: ok ? '动画与「' + kw + '」情绪匹配' : '「' + kw + '」更建议使用 ' + hit.hint + ' 表达' }; }, test() { return this.run({ keyword: '庆祝', type: 'bounce' }); } }, /* ---------- style 风格匹配 ---------- */ { id: 'motion-style-match', name: '动画与风格匹配', category: 'motion', emoji: '🎭', scene: '检查动画风格与视觉风格是否统一(拟物→弹性动画,极简→克制位移)', summary: '圆润/拟物风格适合弹性夸张动画;极简/几何风格适合小幅线性动画;像素风格适合定格跳帧。', params: { styleId: '风格 id', type: '动画类型 id' }, source: '自研(风格-动画匹配)', run(args) { const sid = args.styleId || ''; const type = args.type || 'bounce'; const linear = ['minimal', 'swiss', 'flat'].some(k => sid.includes(k)); const pixel = sid.includes('pixel'); const playful = ['neon', 'cartoon', 'vibrant', 'fun'].some(k => sid.includes(k)); let tip = '动画与风格匹配'; let ok = true; if (linear && ['wiggle', 'spin'].includes(type)) { ok = false; tip = '极简/几何风格建议克制位移(小幅漂浮/脉冲),摇摆旋转偏花哨'; } if (pixel && ['float', 'wiggle'].includes(type)) { ok = false; tip = '像素风格建议定格跳帧动画,平滑漂浮/摇摆不协调'; } if (playful && ['fade'].includes(type)) { ok = false; tip = '活泼风格建议弹性动画(弹跳/摇摆),闪烁过于安静'; } return { ok, tip }; }, test() { return this.run({ styleId: 'minimal', type: 'bounce' }); } }, /* ---------- export 导出工程 ---------- */ { id: 'gif-volume', name: 'GIF 体积控制', category: 'motion', emoji: '📦', scene: '评估导出 GIF 体积是否在平台限制内(微信动图建议 <500KB)', summary: '体积 ≈ 尺寸² × 帧数 × 色彩复杂度;超限时建议降尺寸/降帧率/减少帧数。', params: { size: '边长 px(默认 240)', fps: '帧率(默认 12)', duration: '时长秒(默认 1.2)' }, source: '自研(GIF 工程经验)', run(args) { const size = Number(args.size) || 240; const fps = Number(args.fps) || 12; const d = Number(args.duration) || 1.2; const frames = Math.round(fps * d); const estKb = Math.round(size * size * frames * 0.035 / 1024); const ok = estKb <= 500; const tips = []; if (estKb > 500) { if (size > 240) tips.push('降到 240px'); if (fps > 10) tips.push('帧率降到 8-10fps'); if (d > 1.2) tips.push('时长缩到 1.2s'); } return { ok, estimatedKb: estKb, tip: ok ? '预计体积约 ' + estKb + 'KB,平台内合规' : '预计体积约 ' + estKb + 'KB 超限,建议:' + (tips.join('、') || '减少色彩数') }; }, test() { return this.run({ size: 240, fps: 12, duration: 1.2 }); } }, { id: 'platform-spec', name: '平台尺寸规范', category: 'motion', emoji: '📱', scene: '按目标平台校验表情尺寸/格式合规性(微信/QQ/Telegram)', summary: '微信动图 240px、QQ 表情 320px、Telegram 贴纸 512px;超限会压缩失真。', params: { platform: '平台 id(wechat/qq/telegram/other)', size: '边长 px' }, source: '自研(平台规范)', run(args) { const spec = { wechat: { size: 240, note: '微信动图/自定义表情 240' }, qq: { size: 320, note: 'QQ 表情 320' }, telegram: { size: 512, note: 'Telegram 贴纸 512' } }; const p = spec[args.platform]; if (!p) return { ok: true, tip: '自定义尺寸 ' + (args.size || '?') + 'px 可直接使用' }; const size = Number(args.size) || 0; const ok = size === p.size; return { ok, tip: ok ? '符合' + p.note + '规格' : '建议导出为 ' + p.size + 'px(' + p.note + '),当前 ' + size + 'px 会被平台压缩' }; }, test() { return this.run({ platform: 'wechat', size: 240 }); } }, /* ---------- emotion 情绪驱动(gen:情绪 → 动画决策) ---------- */ { id: 'emotion-params', name: '情绪-动画映射', category: 'emotion', emoji: '😊', scene: '根据目标情绪给出动画类型与参数建议(开心/生气/惊讶/难过/兴奋/平静)', summary: '情绪决定动效语言:开心→轻快弹跳、生气→高频抖动、惊讶→心跳脉冲、难过→低落漂浮、兴奋→弹跳旋转、平静→柔和呼吸。', params: { mood: '情绪词(happy/angry/surprised/sad/excited/calm)', type: '当前动画类型(可选)' }, source: '自研(情绪-动效语言映射)', run(args) { const map = { happy: { type: 'bounce', speed: 1.2, amplitude: 1, note: '开心:轻快弹跳,节奏明快' }, angry: { type: 'shake', speed: 1.4, amplitude: 1.2, note: '生气:高频抖动,幅度略大' }, surprised: { type: 'heartbeat', speed: 1.2, amplitude: 1.1, note: '惊讶:心跳脉冲,节奏感强' }, sad: { type: 'float', speed: 0.8, amplitude: 0.8, note: '难过:低落漂浮,慢而轻' }, excited: { type: 'hopspin', speed: 1.3, amplitude: 1.1, note: '兴奋:弹跳旋转,活力四射' }, calm: { type: 'breath', speed: 0.9, amplitude: 0.8, note: '平静:柔和呼吸,舒缓' } }; const m = map[String(args.mood || 'happy').toLowerCase()]; if (!m) return { ok: false, tip: '未识别情绪,可用:' + Object.keys(map).join('/') }; const matched = !args.type || args.type === m.type; return Object.assign({ ok: matched, mood: m.type, tip: matched ? '推荐「' + m.note + '」' : '当前 ' + args.type + ' 与情绪不符,建议改用 ' + m.type + '(' + m.note + ')' }, m); }, test() { return this.run({ mood: 'happy' }); } }, /* ---------- motion 分层与幅度评审 ---------- */ { id: 'layer-harmony', name: '分层动画协调', category: 'motion', emoji: '🎼', scene: '检查分层动画(元素错峰)是否协调,不被打乱节奏', summary: '分层错峰应在 0-0.5 周期内:主导元素同拍或先动,辅助元素错峰跟随;超过 0.5 会"各动各的"。', params: { layered: '是否分层', layerSpread: '错峰比例 0-0.6', elementCount: '元素数' }, source: '自研(分层动画编排)', run(args) { const layered = !!args.layered; const spread = Number(args.layerSpread) || 0; const n = Number(args.elementCount) || 1; if (!layered || n <= 1) return { ok: true, tip: '单拍动画,无需协调' }; const ok = spread >= 0 && spread <= 0.5; return { ok, tip: ok ? '错峰 ' + spread + ' 周期,分层协调有层次' : '错峰比例超 0.5 会各动各的,建议 0.2-0.4' }; }, test() { return this.run({ layered: true, layerSpread: 0.3, elementCount: 3 }); } }, { id: 'motion-range', name: '幅度不溢出', category: 'motion', emoji: '📐', scene: '检查动画位移/缩放是否超出表情画布(边缘被裁切)', summary: '位移与缩放总和应在画布内:弹跳高度 ≤ 元素高 50%,缩放 ≤ 1.35,抖动位移 ≤ 8px。', params: { type: '动画类型', amplitude: '强度倍率', elH: '元素高 px', size: '画布边长 px' }, source: '自研(画布安全域)', run(args) { const amp = Number(args.amplitude) || 1; const elH = Number(args.elH) || 80; const size = Number(args.size) || 240; const t = args.type || 'bounce'; let maxShift = 0, maxScale = 1; if (t === 'bounce' || t === 'hopspin') maxShift = elH * 0.5 * amp; else if (t === 'shake') maxShift = 5 * amp; else if (t === 'pulse') maxScale = 1 + 0.18 * amp; else if (t === 'heartbeat') maxScale = 1 + 0.22 * amp; else if (t === 'breath') maxScale = 1 + 0.09 * amp; const margin = (size - elH) / 2; const ok = maxShift <= margin + elH * 0.1 && maxScale <= 1.5; return { ok, maxShift: Math.round(maxShift * 10) / 10, maxScale, tip: ok ? '位移 ' + Math.round(maxShift) + 'px / 缩放 ' + Math.round(maxScale * 100) + '%,画布内安全' : '动画幅度可能溢出画布,建议降低 amplitude 或扩大安全边距' }; }, test() { return this.run({ type: 'bounce', amplitude: 1, elH: 80, size: 240 }); } }, { id: 'motion-ease', name: '缓动曲线', category: 'motion', emoji: '📈', scene: '检查动画是否平滑缓动(无生硬突变)', summary: '正弦/余弦类平滑;弹跳应有减速段;线性匀速位移显得生硬。', params: { type: '动画类型 id' }, source: '自研(缓动 12 原则)', run(args) { const t = args.type || 'bounce'; const smooth = ['pulse', 'float', 'fade', 'breath', 'heartbeat', 'twirl', 'wiggle'].includes(t); const eased = ['bounce', 'hopspin'].includes(t); return { ok: smooth || eased, tip: (smooth ? '正弦驱动,平滑自然' : eased ? '含减速/压缩段,物理自然' : '线性驱动,建议叠加缓动曲线') + '(' + t + ')' }; }, test() { return this.run({ type: 'bounce' }); } } ]);