pet-engine.js 10 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277
  1. (function () {
  2. 'use strict';
  3. const petImg = document.getElementById('pet-img');
  4. const petEl = document.getElementById('pet');
  5. const bubble = document.getElementById('bubble');
  6. let currentState = 'idle';
  7. let currentAnimation = null;
  8. let config = null;
  9. let lastConfig = null;
  10. let customImage = null;
  11. let customDataUrl = null;
  12. let runtimeAnimations = {};
  13. let animSig = null;
  14. let animPending = false;
  15. let bubbleTimer = null;
  16. let frameTimer = null;
  17. let frameIdx = 0;
  18. const DEFAULT_IMAGE = 'data:image/svg+xml;utf8,' +
  19. encodeURIComponent('<svg xmlns="http://www.w3.org/2000/svg" width="120" height="140">' +
  20. '<text x="60" y="100" font-size="90" text-anchor="middle">🦜</text></svg>');
  21. // 离线兜底配置:未连后端(未登录 / 服务不可达)时也要有「状态→动作」映射,
  22. // 否则 setState 会退化成 .anim-<state> 而动画样式并不存在 → 宠物静止。
  23. const FALLBACK_CONFIG = {
  24. image_base_url: 'DefaultPets/xibao/',
  25. default_image: 'standing.png',
  26. actions: {
  27. idle: 'wings_idle',
  28. happy: 'wings_spread',
  29. busy: 'head_tilt',
  30. waitingForInteraction: 'hand_gesture',
  31. sleeping: 'breath_loop',
  32. celebrate: 'celebrate',
  33. sad: 'sad_animation'
  34. }
  35. };
  36. // 服务端状态超过该时长未到达即视为离线,进入「自主活动」模式。
  37. const SERVER_TRUST_MS = 90000;
  38. const LIFE_TICK_MS = 2800;
  39. const LIFE_CYCLE = ['idle', 'idle', 'busy', 'idle', 'happy', 'idle',
  40. 'waitingForInteraction', 'idle', 'sleeping', 'idle', 'celebrate'];
  41. const LIFE_BUBBLES = ['在想什么呢~', '陪我玩一会儿吧!', '今天也要加油哦~', '嗯…休息一下~', '你好呀!'];
  42. let lastServerStateAt = 0;
  43. let lifeIdx = 0;
  44. const BUBBLE_TEXTS = {
  45. happy: '完成了!继续保持~',
  46. busy: '有任务在等你呢~',
  47. worried: '任务要过期啦,快去看看!',
  48. excited: '打卡成功,太棒了!',
  49. remind: '该喝水啦~',
  50. tired: '有点累了呢…',
  51. sad: '要照顾好自己哦…',
  52. waitingForInteraction: '来和我互动一下吧!',
  53. miniGame: '我们一起玩吧!',
  54. sleeping: 'zZ…',
  55. custom: '自定义状态'
  56. };
  57. window.cfcPetAPI = {
  58. notifyState: function (stateJson) {
  59. const payload = typeof stateJson === 'string' ? JSON.parse(stateJson) : stateJson;
  60. lastServerStateAt = Date.now();
  61. lifeIdx = 0;
  62. setState(payload.state, payload.reason || null, payload.data || null);
  63. },
  64. setCustomState: function (stateName, dataJson) {
  65. const data = typeof dataJson === 'string' ? JSON.parse(dataJson) : dataJson;
  66. setState(stateName, 'custom', data);
  67. },
  68. loadConfig: function (configJson) {
  69. config = typeof configJson === 'string' ? JSON.parse(configJson) : configJson;
  70. applyImage(config);
  71. },
  72. setCustomImage: function (url, dataUrl) {
  73. customImage = url || null;
  74. customDataUrl = dataUrl || null;
  75. stopClip();
  76. if (customImage) {
  77. petImg.src = customImage;
  78. petImg.onload = function () { ensureAnimations(); };
  79. petImg.onerror = function () { petImg.src = DEFAULT_IMAGE; };
  80. } else if (lastConfig) {
  81. applyImage(lastConfig);
  82. } else {
  83. petImg.src = DEFAULT_IMAGE;
  84. ensureAnimations();
  85. }
  86. },
  87. showBubble: function (text, durationMs) {
  88. bubble.textContent = text;
  89. bubble.style.display = 'block';
  90. clearTimeout(bubbleTimer);
  91. if (durationMs) bubbleTimer = setTimeout(hideBubble, durationMs);
  92. },
  93. hideBubble: hideBubble,
  94. react: react,
  95. getState: function () { return currentState; }
  96. };
  97. function hideBubble() { bubble.style.display = 'none'; }
  98. function applyImage(cfg) {
  99. if (!cfg) return;
  100. stopClip();
  101. lastConfig = cfg;
  102. const src = customImage || (cfg.default_image ? cfg.image_base_url + cfg.default_image : null);
  103. if (!src) { petImg.src = DEFAULT_IMAGE; ensureAnimations(); return; }
  104. petImg.src = src;
  105. petImg.onload = function () { ensureAnimations(); };
  106. petImg.onerror = function () {
  107. petImg.src = DEFAULT_IMAGE;
  108. petImg.onload = function () { ensureAnimations(); };
  109. };
  110. }
  111. function setState(stateName, reason, data) {
  112. currentState = stateName;
  113. if (currentAnimation) { petEl.classList.remove(currentAnimation); currentAnimation = null; }
  114. const actionName = (config && config.actions && config.actions[stateName]) || stateName;
  115. const clipCfg = (config && config.animations && config.animations[actionName])
  116. || runtimeAnimations[actionName]
  117. || null;
  118. if (clipCfg && clipCfg.frames && clipCfg.frames.length) {
  119. playClip(clipCfg);
  120. } else {
  121. stopClip();
  122. applyImage(lastConfig);
  123. const animClass = 'anim-' + actionName;
  124. const styleEl = document.getElementById('pet-animations');
  125. const sheet = styleEl ? styleEl.sheet : null;
  126. const hasClass = sheet && Array.from(sheet.cssRules).some(r => r.selectorText === '.' + animClass);
  127. if (hasClass) { petEl.classList.add(animClass); currentAnimation = animClass; }
  128. }
  129. if (reason === 'drink_water') {
  130. bubble.textContent = '该喝水啦~';
  131. bubble.style.display = 'block';
  132. clearTimeout(bubbleTimer);
  133. bubbleTimer = setTimeout(hideBubble, 8000);
  134. } else if (reason === 'welcome') {
  135. bubble.textContent = '我来了!';
  136. bubble.style.display = 'block';
  137. clearTimeout(bubbleTimer);
  138. bubbleTimer = setTimeout(hideBubble, 3000);
  139. } else if (reason !== 'life' && BUBBLE_TEXTS[stateName]) {
  140. bubble.textContent = BUBBLE_TEXTS[stateName];
  141. bubble.style.display = 'block';
  142. clearTimeout(bubbleTimer);
  143. bubbleTimer = setTimeout(hideBubble, 5000);
  144. }
  145. }
  146. function baseSrc() {
  147. if (customImage) return customImage;
  148. if (lastConfig && lastConfig.default_image) return lastConfig.image_base_url + lastConfig.default_image;
  149. return DEFAULT_IMAGE;
  150. }
  151. // 生成动作帧所用的基础形象:优先用 data URL(避免 canvas 跨域污染)。
  152. function generationSrc() {
  153. if (customImage) return customDataUrl || customImage;
  154. if (lastConfig && lastConfig.default_image) return lastConfig.image_base_url + lastConfig.default_image;
  155. return DEFAULT_IMAGE;
  156. }
  157. // 本地程序化生成动作帧(provider 无关、无需 API Key)。
  158. // 成功后重新应用当前状态,使生成的动作立即生效。
  159. // 以「基础形象 + 动作映射」为签名,同一形象下不重复生成。
  160. function ensureAnimations() {
  161. if (!window.cfcPetGen) return;
  162. const src = generationSrc();
  163. if (!src) return;
  164. const cfg = lastConfig || { actions: {} };
  165. const sig = src + '|' + JSON.stringify(cfg.actions || {});
  166. if (animSig === sig && Object.keys(runtimeAnimations).length) return;
  167. if (animPending) return;
  168. animPending = true;
  169. window.cfcPetGen.generateForConfig(cfg, src).then(function (clips) {
  170. runtimeAnimations = clips || {};
  171. animSig = sig;
  172. animPending = false;
  173. setState(currentState, null, null);
  174. }).catch(function () {
  175. animPending = false;
  176. // 配置形象缺失(如默认图不存在)时,退回内置形象再生成一次,保证离线也有帧动画。
  177. if (src !== DEFAULT_IMAGE) {
  178. animPending = true;
  179. window.cfcPetGen.generateForConfig(cfg, DEFAULT_IMAGE).then(function (clips) {
  180. runtimeAnimations = clips || {};
  181. animSig = sig + '|fallback';
  182. animPending = false;
  183. setState(currentState, null, null);
  184. }).catch(function () { animPending = false; });
  185. }
  186. });
  187. }
  188. function stopClip() {
  189. if (frameTimer) { clearInterval(frameTimer); frameTimer = null; }
  190. }
  191. // 播放帧序列剪辑(provider 无关)。loop:false 播完回到基础形象。
  192. function playClip(clip) {
  193. stopClip();
  194. const base = (lastConfig && lastConfig.image_base_url) || '';
  195. const frames = clip.frames.map(function (f) {
  196. return /^(https?:|data:|\/)/.test(f) ? f : base + f;
  197. });
  198. const fps = clip.fps > 0 ? clip.fps : 12;
  199. frameIdx = 0;
  200. petImg.onerror = null;
  201. petImg.src = frames[0];
  202. frameTimer = setInterval(function () {
  203. frameIdx++;
  204. if (frameIdx >= frames.length) {
  205. if (clip.loop === false) {
  206. stopClip();
  207. petImg.src = baseSrc();
  208. return;
  209. }
  210. frameIdx = 0;
  211. }
  212. petImg.src = frames[frameIdx];
  213. }, 1000 / fps);
  214. }
  215. // 本地即时互动反馈(不依赖后端,也不改变当前状态):点击/抚摸时立即给出动作。
  216. function react(kind) {
  217. const cls = kind === 'pet' ? 'anim-clap' : 'anim-pet_touch';
  218. const ms = kind === 'pet' ? 1900 : 700;
  219. petEl.classList.remove(cls);
  220. void petEl.offsetWidth; // 强制回流,确保动画可重播
  221. petEl.classList.add(cls);
  222. setTimeout(function () { petEl.classList.remove(cls); }, ms);
  223. if (kind === 'pet') {
  224. bubble.textContent = '好痒~嘻嘻!';
  225. bubble.style.display = 'block';
  226. clearTimeout(bubbleTimer);
  227. bubbleTimer = setTimeout(hideBubble, 2000);
  228. }
  229. }
  230. // 自主活动循环:服务端状态长时间未到达(离线 / 未登录)时,按节奏切换轻量动作,
  231. // 让宠物始终保持「活着」的互动感;服务端状态恢复后自动让位。
  232. setInterval(function () {
  233. if (Date.now() - lastServerStateAt <= SERVER_TRUST_MS) { lifeIdx = 0; return; }
  234. lifeIdx = (lifeIdx + 1) % LIFE_CYCLE.length;
  235. const st = LIFE_CYCLE[lifeIdx];
  236. setState(st, 'life', null);
  237. if (st !== 'idle' && Math.random() < 0.5) {
  238. bubble.textContent = LIFE_BUBBLES[Math.floor(Math.random() * LIFE_BUBBLES.length)];
  239. bubble.style.display = 'block';
  240. clearTimeout(bubbleTimer);
  241. bubbleTimer = setTimeout(hideBubble, 3000);
  242. }
  243. }, LIFE_TICK_MS);
  244. // 启动即以兜底配置呈现,确保离线也具备「状态→动作」映射;服务端配置到达后覆盖。
  245. config = FALLBACK_CONFIG;
  246. lastConfig = FALLBACK_CONFIG;
  247. petImg.src = DEFAULT_IMAGE;
  248. petImg.onload = function () { ensureAnimations(); };
  249. setInterval(function () {
  250. if (document.getElementById('pet-img')) {
  251. petImg.classList.add('anim-blink_eye');
  252. setTimeout(() => petImg.classList.remove('anim-blink_eye'), 200);
  253. }
  254. }, 3500);
  255. })();