221 lines
8.4 KiB
JavaScript
221 lines
8.4 KiB
JavaScript
/**
|
||
* 画个标 · 核心:动态表情动画引擎(Motion)
|
||
* 参数化动画:动画类型 + 参数(speed/amplitude/duration/fps)→ 帧函数 apply(el, t, anim)
|
||
* 返回该帧的变换 {tx, ty, rot, s, opacity}(tx/ty 为视口像素偏移,rot 度,s 缩放倍率,opacity 0-1)
|
||
*
|
||
* 设计要点:
|
||
* - 预览(requestAnimationFrame 驱动)与导出(GIF/WebM 逐帧采样)走同一套 apply(),预览与产物一致
|
||
* - 纯函数、零 DOM 依赖,Node 无头可测(确定性:同 t 同 anim 同 el 输出相同)
|
||
* - AI 决策只输出「类型 + 参数」,系统负责渲染,保持 AI 驱动架构铁律
|
||
*/
|
||
const HGB_Motion = (function () {
|
||
'use strict';
|
||
|
||
/** 动画类型注册表(apply 内部用 p 中的归一化参数 + 元素参考尺寸) */
|
||
const ANIMS = {
|
||
bounce: {
|
||
id: 'bounce', name: '弹跳', emoji: '🏀',
|
||
apply(t, p) {
|
||
// 经典弹跳:前 55% 上升,后 45% 落回 + 压缩
|
||
const up = Math.sin(Math.PI * Math.min(t * 1.82, 1));
|
||
const squash = Math.max(0, Math.sin(Math.PI * (t - 0.55) / 0.45)) * 0.12;
|
||
const h = (p.elH || 80) * 0.5 * p.amplitude;
|
||
return { tx: 0, ty: -h * up, rot: 0, s: 1 - squash, opacity: 1 };
|
||
}
|
||
},
|
||
pulse: {
|
||
id: 'pulse', name: '脉冲', emoji: '💓',
|
||
apply(t, p) {
|
||
const s = 1 + 0.18 * p.amplitude * Math.sin(2 * Math.PI * t);
|
||
return { tx: 0, ty: 0, rot: 0, s, opacity: 1 };
|
||
}
|
||
},
|
||
wiggle: {
|
||
id: 'wiggle', name: '摇摆', emoji: '🌀',
|
||
apply(t, p) {
|
||
return { tx: 0, ty: 0, rot: 10 * p.amplitude * Math.sin(2 * Math.PI * t), s: 1, opacity: 1 };
|
||
}
|
||
},
|
||
float: {
|
||
id: 'float', name: '漂浮', emoji: '🎈',
|
||
apply(t, p) {
|
||
const h = (p.elH || 80) * 0.14 * p.amplitude;
|
||
return { tx: 0, ty: h * Math.sin(2 * Math.PI * t), rot: 4 * p.amplitude * Math.sin(2 * Math.PI * t + 1), s: 1, opacity: 1 };
|
||
}
|
||
},
|
||
spin: {
|
||
id: 'spin', name: '旋转', emoji: '🔄',
|
||
apply(t, p) {
|
||
return { tx: 0, ty: 0, rot: 360 * p.amplitude * t, s: 1, opacity: 1 };
|
||
}
|
||
},
|
||
fade: {
|
||
id: 'fade', name: '闪烁', emoji: '✨',
|
||
apply(t, p) {
|
||
// 呼吸式闪烁(平滑余弦,不完全消失)
|
||
return { tx: 0, ty: 0, rot: 0, s: 1, opacity: 0.4 + 0.6 * (0.5 + 0.5 * Math.cos(2 * Math.PI * t)) };
|
||
}
|
||
},
|
||
twirl: {
|
||
id: 'twirl', name: '旋摆', emoji: '🪁',
|
||
apply(t, p) {
|
||
return { tx: 0, ty: 0, rot: 30 * p.amplitude * Math.sin(2 * Math.PI * t) * Math.sin(2 * Math.PI * t * 0.5), s: 1, opacity: 1 };
|
||
}
|
||
},
|
||
heartbeat: {
|
||
id: 'heartbeat', name: '心跳', emoji: '💗',
|
||
apply(t, p) {
|
||
// 咚-咚 双脉冲节奏:每个周期两次快速放大(k² 平滑不突变)
|
||
const k = Math.sin(Math.PI * ((t * 2) % 1));
|
||
return { tx: 0, ty: 0, rot: 0, s: 1 + 0.22 * p.amplitude * k * k, opacity: 1 };
|
||
}
|
||
},
|
||
shake: {
|
||
id: 'shake', name: '抖动', emoji: '📳',
|
||
apply(t, p) {
|
||
// 高频小位移 + 轻微旋转(确定性正弦,非随机)
|
||
const ph = t * 2 * Math.PI * 8;
|
||
const a = 5 * p.amplitude;
|
||
return { tx: a * Math.sin(ph), ty: a * 0.6 * Math.sin(ph * 1.37 + 1.2), rot: 2 * p.amplitude * Math.sin(ph * 0.7), s: 1, opacity: 1 };
|
||
}
|
||
},
|
||
breath: {
|
||
id: 'breath', name: '呼吸', emoji: '🫁',
|
||
apply(t, p) {
|
||
// 平滑低幅呼吸(比脉冲更柔和)
|
||
return { tx: 0, ty: 0, rot: 0, s: 1 + 0.09 * p.amplitude * Math.sin(2 * Math.PI * t), opacity: 1 };
|
||
}
|
||
},
|
||
hopspin: {
|
||
id: 'hopspin', name: '弹转', emoji: '🤸',
|
||
apply(t, p) {
|
||
// 弹跳 + 半圈旋转(落地即回正)
|
||
const up = Math.sin(Math.PI * Math.min(t * 1.82, 1));
|
||
const h = (p.elH || 80) * 0.5 * p.amplitude;
|
||
return { tx: 0, ty: -h * up, rot: 180 * p.amplitude * Math.min(t * 1.82, 1), s: 1, opacity: 1 };
|
||
}
|
||
}
|
||
};
|
||
|
||
const ORDER = Object.keys(ANIMS);
|
||
|
||
function clamp(v, lo, hi, dft) {
|
||
const n = Number(v);
|
||
return (isFinite(n) && n >= lo && n <= hi) ? n : dft;
|
||
}
|
||
|
||
/** 归一化动画参数(补全默认值) */
|
||
function normalize(anim) {
|
||
const a = anim || {};
|
||
const def = ANIMS[a.type] || ANIMS.bounce;
|
||
return {
|
||
type: def.id,
|
||
speed: clamp(a.speed, 0.5, 4, 1), // 周期数(duration 内循环几次)
|
||
amplitude: clamp(a.amplitude, 0.3, 2, 1), // 强度倍率
|
||
duration: clamp(a.duration, 0.4, 4, 1.2), // 时长(秒)
|
||
fps: Math.round(clamp(a.fps, 4, 24, 12)),
|
||
layered: !!a.layered, // 元素级分层(各元素错峰动,表情更活)
|
||
layerSpread: clamp(a.layerSpread, 0, 0.6, 0.3) // 分层错峰比例(0=同步)
|
||
};
|
||
}
|
||
|
||
/** 总帧数 */
|
||
function frameCount(anim) {
|
||
const a = normalize(anim);
|
||
return Math.max(2, Math.round(a.duration * a.fps));
|
||
}
|
||
|
||
/** 第 i 帧的归一化时间 t(含速度:speed 个周期) */
|
||
function frameT(i, anim) {
|
||
const a = normalize(anim);
|
||
const n = frameCount(a);
|
||
return ((i / n) * a.speed) % 1;
|
||
}
|
||
|
||
/** 预览时间(now 毫秒时间戳 → t) */
|
||
function previewT(now, anim) {
|
||
const a = normalize(anim);
|
||
const period = a.duration / a.speed; // 单周期秒数
|
||
return period > 0 ? ((now / 1000) % period) / period : 0;
|
||
}
|
||
|
||
/**
|
||
* 元素级时间(分层动画):各元素错峰偏移,让表情的多个图形"各自动"而非整图同步
|
||
* @param {number} t 全局时间 [0,1)
|
||
* @param {object} anim 动画参数(需含 layered/layerSpread)
|
||
* @param {number} i 元素索引
|
||
* @param {number} n 元素总数
|
||
* @returns {number} 该元素的局部时间 [0,1)
|
||
*/
|
||
function elementT(t, anim, i, n) {
|
||
const a = normalize(anim);
|
||
if (!a.layered || n <= 1) return t;
|
||
return (t + a.layerSpread * (i / Math.max(1, n - 1))) % 1;
|
||
}
|
||
|
||
/** 计算某元素某帧的变换(el 需含 x/y/w/h) */
|
||
function apply(el, t, anim) {
|
||
const a = normalize(anim);
|
||
const def = ANIMS[a.type] || ANIMS.pulse;
|
||
const f = def.apply(t, { amplitude: a.amplitude, elH: (el && el.h) || 80, elW: (el && el.w) || 80 }) || {};
|
||
return {
|
||
tx: f.tx || 0,
|
||
ty: f.ty || 0,
|
||
rot: f.rot || 0,
|
||
s: (f.s != null && isFinite(f.s)) ? f.s : 1,
|
||
opacity: (f.opacity != null && isFinite(f.opacity)) ? clamp(f.opacity, 0, 1, 1) : 1
|
||
};
|
||
}
|
||
|
||
/**
|
||
* 合成元素最终 transform 字符串(在元素原始 transform 之上叠加动画变换)
|
||
* @param {object} el 元素(x/y/w/h)
|
||
* @param {object} frame apply() 返回值
|
||
* @param {string} base 元素原始 transform(如 buildSvgFrom 输出的 translate(x y) scale(sx sy))
|
||
*/
|
||
function composeTransform(el, frame, base) {
|
||
const cx = el.x + el.w / 2, cy = el.y + el.h / 2;
|
||
const r = frame.rot ? trimNum(frame.rot) + '' : '';
|
||
const s = (frame.s !== 1) ? ' scale(' + trimNum(frame.s) + ')' : '';
|
||
const rot = r ? ' rotate(' + r + ')' : '';
|
||
// 变换顺序(右→左执行):base(原始定位)→ 回中缩放 → 旋转 → 平移
|
||
return 'translate(' + trimNum(frame.tx) + ' ' + trimNum(frame.ty) + ')' +
|
||
' translate(' + trimNum(cx) + ' ' + trimNum(cy) + ')' + rot + s +
|
||
' translate(' + trimNum(-cx) + ' ' + trimNum(-cy) + ')' +
|
||
(base ? ' ' + base : '');
|
||
}
|
||
|
||
/** 数字去尾零(0 → '0',禁止 toFixed 产生 0.00) */
|
||
function trimNum(n) {
|
||
if (!isFinite(n)) n = 0;
|
||
return (Math.round(n * 10000) / 10000).toString();
|
||
}
|
||
|
||
/** 确定性分配:按索引轮换动画类型(AI 缺失时降级,保证每批动画多样) */
|
||
function pickAnim(i) {
|
||
return normalize({ type: ORDER[i % ORDER.length], speed: 1, amplitude: 1 });
|
||
}
|
||
|
||
/** 随机分配(用户点「换动画」) */
|
||
function randomAnim() {
|
||
const a = pickAnim(Math.floor(Math.random() * ORDER.length));
|
||
a.speed = [0.75, 1, 1.25][Math.floor(Math.random() * 3)];
|
||
return a;
|
||
}
|
||
|
||
/** 全部动画类型列表 [{id,name,emoji}] */
|
||
function animTypes() {
|
||
return ORDER.map(id => ({ id, name: ANIMS[id].name, emoji: ANIMS[id].emoji }));
|
||
}
|
||
|
||
function get(id) { return ANIMS[id] || ANIMS.pulse; }
|
||
|
||
return {
|
||
ANIMS, ORDER, normalize, frameCount, frameT, previewT, elementT,
|
||
apply, composeTransform, pickAnim, randomAnim, animTypes, get
|
||
};
|
||
})();
|
||
|
||
// Node 无头冒烟自检(浏览器同样执行,幂等)
|
||
if (typeof module !== 'undefined' && module.exports) module.exports = HGB_Motion;
|