(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);
})();