(function () { 'use strict'; const petImg = document.getElementById('pet-img'); const petEl = document.getElementById('pet'); const bubble = document.getElementById('bubble'); let currentState = 'idle'; let currentAnimation = null; let config = null; let lastConfig = null; let customImage = null; let customDataUrl = null; let runtimeAnimations = {}; let animSig = null; let animPending = false; let bubbleTimer = null; let frameTimer = null; let frameIdx = 0; const DEFAULT_IMAGE = 'data:image/svg+xml;utf8,' + encodeURIComponent('' + '🦜'); // 离线兜底配置:未连后端(未登录 / 服务不可达)时也要有「状态→动作」映射, // 否则 setState 会退化成 .anim- 而动画样式并不存在 → 宠物静止。 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: '有任务在等你呢~', worried: '任务要过期啦,快去看看!', excited: '打卡成功,太棒了!', remind: '该喝水啦~', tired: '有点累了呢…', sad: '要照顾好自己哦…', waitingForInteraction: '来和我互动一下吧!', miniGame: '我们一起玩吧!', sleeping: 'zZ…', custom: '自定义状态' }; 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) { const data = typeof dataJson === 'string' ? JSON.parse(dataJson) : dataJson; setState(stateName, 'custom', data); }, loadConfig: function (configJson) { config = typeof configJson === 'string' ? JSON.parse(configJson) : configJson; applyImage(config); }, setCustomImage: function (url, dataUrl) { customImage = url || null; customDataUrl = dataUrl || null; stopClip(); if (customImage) { petImg.src = customImage; petImg.onload = function () { ensureAnimations(); }; petImg.onerror = function () { petImg.src = DEFAULT_IMAGE; }; } else if (lastConfig) { applyImage(lastConfig); } else { petImg.src = DEFAULT_IMAGE; ensureAnimations(); } }, showBubble: function (text, durationMs) { bubble.textContent = text; bubble.style.display = 'block'; clearTimeout(bubbleTimer); if (durationMs) bubbleTimer = setTimeout(hideBubble, durationMs); }, hideBubble: hideBubble, react: react, getState: function () { return currentState; } }; function hideBubble() { bubble.style.display = 'none'; } function applyImage(cfg) { if (!cfg) return; stopClip(); lastConfig = cfg; const src = customImage || (cfg.default_image ? cfg.image_base_url + cfg.default_image : null); if (!src) { petImg.src = DEFAULT_IMAGE; ensureAnimations(); return; } petImg.src = src; petImg.onload = function () { ensureAnimations(); }; petImg.onerror = function () { petImg.src = DEFAULT_IMAGE; petImg.onload = function () { ensureAnimations(); }; }; } function setState(stateName, reason, data) { currentState = stateName; if (currentAnimation) { petEl.classList.remove(currentAnimation); currentAnimation = null; } const actionName = (config && config.actions && config.actions[stateName]) || stateName; const clipCfg = (config && config.animations && config.animations[actionName]) || runtimeAnimations[actionName] || null; if (clipCfg && clipCfg.frames && clipCfg.frames.length) { playClip(clipCfg); } else { stopClip(); applyImage(lastConfig); const animClass = 'anim-' + actionName; const styleEl = document.getElementById('pet-animations'); const sheet = styleEl ? styleEl.sheet : null; const hasClass = sheet && Array.from(sheet.cssRules).some(r => r.selectorText === '.' + animClass); if (hasClass) { petEl.classList.add(animClass); currentAnimation = animClass; } } if (reason === 'drink_water') { bubble.textContent = '该喝水啦~'; bubble.style.display = 'block'; clearTimeout(bubbleTimer); bubbleTimer = setTimeout(hideBubble, 8000); } else if (reason === 'welcome') { bubble.textContent = '我来了!'; bubble.style.display = 'block'; clearTimeout(bubbleTimer); bubbleTimer = setTimeout(hideBubble, 3000); } else if (reason !== 'life' && BUBBLE_TEXTS[stateName]) { bubble.textContent = BUBBLE_TEXTS[stateName]; bubble.style.display = 'block'; clearTimeout(bubbleTimer); bubbleTimer = setTimeout(hideBubble, 5000); } } function baseSrc() { if (customImage) return customImage; if (lastConfig && lastConfig.default_image) return lastConfig.image_base_url + lastConfig.default_image; return DEFAULT_IMAGE; } // 生成动作帧所用的基础形象:优先用 data URL(避免 canvas 跨域污染)。 function generationSrc() { if (customImage) return customDataUrl || customImage; if (lastConfig && lastConfig.default_image) return lastConfig.image_base_url + lastConfig.default_image; return DEFAULT_IMAGE; } // 本地程序化生成动作帧(provider 无关、无需 API Key)。 // 成功后重新应用当前状态,使生成的动作立即生效。 // 以「基础形象 + 动作映射」为签名,同一形象下不重复生成。 function ensureAnimations() { if (!window.cfcPetGen) return; const src = generationSrc(); if (!src) return; const cfg = lastConfig || { actions: {} }; const sig = src + '|' + JSON.stringify(cfg.actions || {}); if (animSig === sig && Object.keys(runtimeAnimations).length) return; if (animPending) return; animPending = true; window.cfcPetGen.generateForConfig(cfg, src).then(function (clips) { runtimeAnimations = clips || {}; animSig = sig; animPending = false; 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; }); } }); } function stopClip() { if (frameTimer) { clearInterval(frameTimer); frameTimer = null; } } // 播放帧序列剪辑(provider 无关)。loop:false 播完回到基础形象。 function playClip(clip) { stopClip(); const base = (lastConfig && lastConfig.image_base_url) || ''; const frames = clip.frames.map(function (f) { return /^(https?:|data:|\/)/.test(f) ? f : base + f; }); const fps = clip.fps > 0 ? clip.fps : 12; frameIdx = 0; petImg.onerror = null; petImg.src = frames[0]; frameTimer = setInterval(function () { frameIdx++; if (frameIdx >= frames.length) { if (clip.loop === false) { stopClip(); petImg.src = baseSrc(); return; } frameIdx = 0; } petImg.src = frames[frameIdx]; }, 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(); }; setInterval(function () { if (document.getElementById('pet-img')) { petImg.classList.add('anim-blink_eye'); setTimeout(() => petImg.classList.remove('anim-blink_eye'), 200); } }, 3500); })();