|
|
@@ -0,0 +1,106 @@
|
|
|
+// 程序化动作帧生成器(provider 无关,本地 canvas 变换,无需 API Key)。
|
|
|
+// 依据基础形象为每个动作生成 8-16 帧序列(PNG data URL),供 pet-engine.js 的 playClip 播放。
|
|
|
+// 数据源必须是 data: URL 或同源资源;若为跨域远程图,canvas 会被污染导致 toDataURL 抛错,
|
|
|
+// 此时 Promise reject,前端静默回退 CSS 动画。
|
|
|
+(function () {
|
|
|
+ 'use strict';
|
|
|
+
|
|
|
+ var MAX_DIM = 256;
|
|
|
+
|
|
|
+ // 每个状态 → 一段随时间 t∈[0,1) 的仿射变换。
|
|
|
+ // dx/dy: 相对宽/高的位移比例;rot: 弧度;sx/sy: 缩放。
|
|
|
+ var PRESETS = {
|
|
|
+ idle: { frames: 12, fps: 8, loop: true, tf: function (t) { var s = Math.sin(2 * Math.PI * t); return { dy: -0.015 * s, sx: 1 - 0.010 * s, sy: 1 + 0.020 * s }; } },
|
|
|
+ happy: { frames: 12, fps: 14, loop: true, tf: function (t) { return { dy: -0.060 * Math.abs(Math.sin(2 * Math.PI * t)), rot: 0.030 * Math.sin(2 * Math.PI * t) }; } },
|
|
|
+ excited: { frames: 14, fps: 16, loop: true, tf: function (t) { return { dy: -0.080 * Math.abs(Math.sin(2 * Math.PI * t)), rot: 0.050 * Math.sin(2 * Math.PI * t) }; } },
|
|
|
+ celebrate: { frames: 16, fps: 16, loop: true, tf: function (t) { return { dy: -0.100 * Math.abs(Math.sin(Math.PI * t)), rot: 0.080 * Math.sin(2 * Math.PI * t) }; } },
|
|
|
+ busy: { frames: 8, fps: 16, loop: true, tf: function (t) { return { dx: 0.012 * Math.sin(4 * Math.PI * t), rot: 0.020 * Math.sin(4 * Math.PI * t) }; } },
|
|
|
+ miniGame: { frames: 12, fps: 14, loop: true, tf: function (t) { return { dy: -0.060 * Math.abs(Math.sin(2 * Math.PI * t)), rot: 0.050 * Math.sin(4 * Math.PI * t) }; } },
|
|
|
+ waitingForInteraction: { frames: 14, fps: 10, loop: true, tf: function (t) { return { rot: 0.050 * Math.sin(2 * Math.PI * t), dx: 0.010 * Math.sin(2 * Math.PI * t) }; } },
|
|
|
+ remind: { frames: 10, fps: 8, loop: true, tf: function (t) { return { rot: 0.060 * Math.sin(4 * Math.PI * t) }; } },
|
|
|
+ sleeping: { frames: 16, fps: 6, loop: true, tf: function (t) { return { rot: 0.040, sy: 1 + 0.025 * Math.sin(2 * Math.PI * t), dy: 0.010 * Math.sin(2 * Math.PI * t) }; } },
|
|
|
+ worried: { frames: 12, fps: 6, loop: true, tf: function (t) { return { dy: 0.015, sy: 1 - 0.020 * Math.abs(Math.sin(Math.PI * t)), rot: -0.030 * Math.sin(2 * Math.PI * t) }; } },
|
|
|
+ sad: { frames: 12, fps: 6, loop: true, tf: function (t) { return { dy: 0.015, sy: 1 - 0.020 * Math.abs(Math.sin(Math.PI * t)), rot: -0.030 * Math.sin(2 * Math.PI * t) }; } },
|
|
|
+ tired: { frames: 12, fps: 5, loop: true, tf: function (t) { return { dy: 0.020, rot: -0.020 * Math.sin(2 * Math.PI * t) }; } },
|
|
|
+ energetic: { frames: 14, fps: 18, loop: true, tf: function (t) { return { dy: -0.070 * Math.abs(Math.sin(2 * Math.PI * t)), rot: 0.040 * Math.sin(2 * Math.PI * t) }; } },
|
|
|
+ curious: { frames: 12, fps: 8, loop: true, tf: function (t) { return { rot: 0.070 * Math.sin(2 * Math.PI * t), dy: -0.020 * Math.sin(2 * Math.PI * t) }; } },
|
|
|
+ watching: { frames: 12, fps: 6, loop: true, tf: function (t) { return { rot: 0.030 * Math.sin(2 * Math.PI * t), dy: -0.010 * Math.abs(Math.sin(2 * Math.PI * t)) }; } },
|
|
|
+ custom: { frames: 12, fps: 8, loop: true, tf: function (t) { var s = Math.sin(2 * Math.PI * t); return { dy: -0.015 * s }; } }
|
|
|
+ };
|
|
|
+
|
|
|
+ var KNOWN_STATES = ['idle', 'happy', 'busy', 'worried', 'excited', 'sad', 'tired',
|
|
|
+ 'remind', 'energetic', 'curious', 'watching', 'waitingForInteraction', 'miniGame',
|
|
|
+ 'sleeping', 'celebrate', 'custom'];
|
|
|
+
|
|
|
+ function presetFor(state) { return PRESETS[state] || PRESETS.idle; }
|
|
|
+
|
|
|
+ function loadImage(src) {
|
|
|
+ return new Promise(function (resolve, reject) {
|
|
|
+ var img = new Image();
|
|
|
+ img.onload = function () { resolve(img); };
|
|
|
+ img.onerror = function () { reject(new Error('image load failed')); };
|
|
|
+ img.src = src;
|
|
|
+ });
|
|
|
+ }
|
|
|
+
|
|
|
+ function makeClip(img, preset) {
|
|
|
+ var iw = img.naturalWidth || img.width || 120;
|
|
|
+ var ih = img.naturalHeight || img.height || 140;
|
|
|
+ var scale = Math.min(1, MAX_DIM / Math.max(iw, ih));
|
|
|
+ var cw = Math.max(1, Math.round(iw * scale));
|
|
|
+ var ch = Math.max(1, Math.round(ih * scale));
|
|
|
+ var canvas = document.createElement('canvas');
|
|
|
+ canvas.width = cw;
|
|
|
+ canvas.height = ch;
|
|
|
+ var ctx = canvas.getContext('2d');
|
|
|
+ var frames = [];
|
|
|
+ for (var i = 0; i < preset.frames; i++) {
|
|
|
+ var tr = preset.tf(i / preset.frames) || {};
|
|
|
+ ctx.clearRect(0, 0, cw, ch);
|
|
|
+ ctx.save();
|
|
|
+ ctx.translate(cw / 2 + (tr.dx || 0) * cw, ch / 2 + (tr.dy || 0) * ch);
|
|
|
+ if (tr.rot) ctx.rotate(tr.rot);
|
|
|
+ ctx.scale(tr.sx || 1, tr.sy || 1);
|
|
|
+ ctx.drawImage(img, -cw / 2, -ch / 2, cw, ch);
|
|
|
+ ctx.restore();
|
|
|
+ frames.push(canvas.toDataURL('image/png'));
|
|
|
+ }
|
|
|
+ return {
|
|
|
+ frames: frames,
|
|
|
+ sprite: null,
|
|
|
+ frame_width: cw,
|
|
|
+ frame_height: ch,
|
|
|
+ count: frames.length,
|
|
|
+ fps: preset.fps,
|
|
|
+ loop: preset.loop
|
|
|
+ };
|
|
|
+ }
|
|
|
+
|
|
|
+ // 依据 config.actions(状态→动作名)为每个动作生成剪辑,按动作名为键。
|
|
|
+ // 返回 Promise<{ [actionName]: ActionClip }>;canvas 被污染时 reject。
|
|
|
+ function generateForConfig(config, src) {
|
|
|
+ var actions = (config && config.actions) || {};
|
|
|
+ var map = {}; // actionName -> state
|
|
|
+ KNOWN_STATES.forEach(function (s) {
|
|
|
+ var a = actions[s] || s;
|
|
|
+ if (!(a in map)) map[a] = s;
|
|
|
+ });
|
|
|
+ Object.keys(actions).forEach(function (s) {
|
|
|
+ var a = actions[s];
|
|
|
+ if (a && !(a in map)) map[a] = s;
|
|
|
+ });
|
|
|
+
|
|
|
+ return loadImage(src).then(function (img) {
|
|
|
+ var cache = {}; // state -> clip
|
|
|
+ var out = {};
|
|
|
+ Object.keys(map).forEach(function (actionName) {
|
|
|
+ var state = map[actionName];
|
|
|
+ var clip = cache[state] || (cache[state] = makeClip(img, presetFor(state)));
|
|
|
+ out[actionName] = clip;
|
|
|
+ });
|
|
|
+ return out;
|
|
|
+ });
|
|
|
+ }
|
|
|
+
|
|
|
+ window.cfcPetGen = { generateForConfig: generateForConfig, presets: PRESETS };
|
|
|
+})();
|