Files

221 lines
8.4 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 画个标 · 核心:动态表情动画引擎(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;