Bläddra i källkod

feat(desktop-pet): 离线自主活动与本地点击互动

liaoxg 1 dag sedan
förälder
incheckning
7c66e61b3d

+ 1 - 0
cfc-desktop-pet/src/bridge.js

@@ -39,6 +39,7 @@
   // 启动时应用已保存的自定义形象
   try {
     invoke('get_settings').then(function (s) {
+      window.__petHasToken = !!(s && s.hasToken);
       if (s && s.customMascotUrl) {
         applyCustomImage(s.customMascotUrl, null);
       }

+ 15 - 2
cfc-desktop-pet/src/interactions.js

@@ -41,7 +41,8 @@ ctxMenu.addEventListener('click', function (e) {
     dragStartY = e.clientY;
     isDragging = false;
     pressTimer = setTimeout(() => {
-      notifyInteract('pet', {});
+      localReact('pet');
+      if (backendEnabled()) notifyInteract('pet', {});
     }, 1500);
   });
 
@@ -58,12 +59,24 @@ ctxMenu.addEventListener('click', function (e) {
   petEl.addEventListener('mouseup', function (e) {
     clearTimeout(pressTimer);
     if (!isDragging) {
-      notifyInteract('click', {});
+      localReact('click');
+      // 未登录 / 离线时不打后端,避免只剩「打卡失败」噪音,仅保留本地互动反馈。
+      if (backendEnabled()) notifyInteract('click', {});
     } else {
       notifyDragEnd();
     }
   });
 
+  // 本地即时反馈:不依赖后端也能看到宠物对点击的响应。
+  function localReact(kind) {
+    try { if (window.cfcPetAPI && window.cfcPetAPI.react) window.cfcPetAPI.react(kind); } catch (_) { /* noop */ }
+  }
+
+  // 是否已连后端(有令牌)。默认允许,bridge 拉到设置后按 hasToken 修正。
+  function backendEnabled() {
+    return window.__petHasToken !== false;
+  }
+
   function notifyInteract(action, data) {
     try {
       window.cfcHost.onInteract(action, JSON.stringify(data));

+ 78 - 3
cfc-desktop-pet/src/pet-engine.js

@@ -22,6 +22,31 @@
     encodeURIComponent('<svg xmlns="http://www.w3.org/2000/svg" width="120" height="140">' +
       '<text x="60" y="100" font-size="90" text-anchor="middle">🦜</text></svg>');
 
+  // 离线兜底配置:未连后端(未登录 / 服务不可达)时也要有「状态→动作」映射,
+  // 否则 setState 会退化成 .anim-<state> 而动画样式并不存在 → 宠物静止。
+  const FALLBACK_CONFIG = {
+    image_base_url: 'DefaultPets/xibao/',
+    default_image: 'standing.png',
+    actions: {
+      idle: 'wings_idle',
+      happy: 'wings_spread',
+      busy: 'head_tilt',
+      waitingForInteraction: 'hand_gesture',
+      sleeping: 'breath_loop',
+      celebrate: 'celebrate',
+      sad: 'sad_animation'
+    }
+  };
+
+  // 服务端状态超过该时长未到达即视为离线,进入「自主活动」模式。
+  const SERVER_TRUST_MS = 90000;
+  const LIFE_TICK_MS = 2800;
+  const LIFE_CYCLE = ['idle', 'idle', 'busy', 'idle', 'happy', 'idle',
+    'waitingForInteraction', 'idle', 'sleeping', 'idle', 'celebrate'];
+  const LIFE_BUBBLES = ['在想什么呢~', '陪我玩一会儿吧!', '今天也要加油哦~', '嗯…休息一下~', '你好呀!'];
+  let lastServerStateAt = 0;
+  let lifeIdx = 0;
+
   const BUBBLE_TEXTS = {
     happy: '完成了!继续保持~',
     busy: '有任务在等你呢~',
@@ -39,6 +64,8 @@
   window.cfcPetAPI = {
     notifyState: function (stateJson) {
       const payload = typeof stateJson === 'string' ? JSON.parse(stateJson) : stateJson;
+      lastServerStateAt = Date.now();
+      lifeIdx = 0;
       setState(payload.state, payload.reason || null, payload.data || null);
     },
     setCustomState: function (stateName, dataJson) {
@@ -71,6 +98,7 @@
       if (durationMs) bubbleTimer = setTimeout(hideBubble, durationMs);
     },
     hideBubble: hideBubble,
+    react: react,
     getState: function () { return currentState; }
   };
 
@@ -81,10 +109,13 @@
     stopClip();
     lastConfig = cfg;
     const src = customImage || (cfg.default_image ? cfg.image_base_url + cfg.default_image : null);
-    if (!src) return;
+    if (!src) { petImg.src = DEFAULT_IMAGE; ensureAnimations(); return; }
     petImg.src = src;
     petImg.onload = function () { ensureAnimations(); };
-    petImg.onerror = function () { petImg.src = DEFAULT_IMAGE; };
+    petImg.onerror = function () {
+      petImg.src = DEFAULT_IMAGE;
+      petImg.onload = function () { ensureAnimations(); };
+    };
   }
 
   function setState(stateName, reason, data) {
@@ -118,7 +149,7 @@
       bubble.style.display = 'block';
       clearTimeout(bubbleTimer);
       bubbleTimer = setTimeout(hideBubble, 3000);
-    } else if (BUBBLE_TEXTS[stateName]) {
+    } else if (reason !== 'life' && BUBBLE_TEXTS[stateName]) {
       bubble.textContent = BUBBLE_TEXTS[stateName];
       bubble.style.display = 'block';
       clearTimeout(bubbleTimer);
@@ -158,6 +189,16 @@
       setState(currentState, null, null);
     }).catch(function () {
       animPending = false;
+      // 配置形象缺失(如默认图不存在)时,退回内置形象再生成一次,保证离线也有帧动画。
+      if (src !== DEFAULT_IMAGE) {
+        animPending = true;
+        window.cfcPetGen.generateForConfig(cfg, DEFAULT_IMAGE).then(function (clips) {
+          runtimeAnimations = clips || {};
+          animSig = sig + '|fallback';
+          animPending = false;
+          setState(currentState, null, null);
+        }).catch(function () { animPending = false; });
+      }
     });
   }
 
@@ -190,6 +231,40 @@
     }, 1000 / fps);
   }
 
+  // 本地即时互动反馈(不依赖后端,也不改变当前状态):点击/抚摸时立即给出动作。
+  function react(kind) {
+    const cls = kind === 'pet' ? 'anim-clap' : 'anim-pet_touch';
+    const ms = kind === 'pet' ? 1900 : 700;
+    petEl.classList.remove(cls);
+    void petEl.offsetWidth; // 强制回流,确保动画可重播
+    petEl.classList.add(cls);
+    setTimeout(function () { petEl.classList.remove(cls); }, ms);
+    if (kind === 'pet') {
+      bubble.textContent = '好痒~嘻嘻!';
+      bubble.style.display = 'block';
+      clearTimeout(bubbleTimer);
+      bubbleTimer = setTimeout(hideBubble, 2000);
+    }
+  }
+
+  // 自主活动循环:服务端状态长时间未到达(离线 / 未登录)时,按节奏切换轻量动作,
+  // 让宠物始终保持「活着」的互动感;服务端状态恢复后自动让位。
+  setInterval(function () {
+    if (Date.now() - lastServerStateAt <= SERVER_TRUST_MS) { lifeIdx = 0; return; }
+    lifeIdx = (lifeIdx + 1) % LIFE_CYCLE.length;
+    const st = LIFE_CYCLE[lifeIdx];
+    setState(st, 'life', null);
+    if (st !== 'idle' && Math.random() < 0.5) {
+      bubble.textContent = LIFE_BUBBLES[Math.floor(Math.random() * LIFE_BUBBLES.length)];
+      bubble.style.display = 'block';
+      clearTimeout(bubbleTimer);
+      bubbleTimer = setTimeout(hideBubble, 3000);
+    }
+  }, LIFE_TICK_MS);
+
+  // 启动即以兜底配置呈现,确保离线也具备「状态→动作」映射;服务端配置到达后覆盖。
+  config = FALLBACK_CONFIG;
+  lastConfig = FALLBACK_CONFIG;
   petImg.src = DEFAULT_IMAGE;
   petImg.onload = function () { ensureAnimations(); };