(() => {
'use strict';
/* --------------------------------------------------------------------------
pico · a tiny digital companion
A dependency-free virtual pet that remembers itself in localStorage.
-------------------------------------------------------------------------- */
const SAVE_KEY = 'pico.pet.v1';
const MAX_OFFLINE_SECONDS = 6 * 60 * 60;
const LEVEL_COIN_REWARD = 8;
const PET_COOLDOWN_MS = 420;
const RATES = {
hunger: 0.012,
happiness: 0.0045,
energy: 0.003,
cleanliness: 0.005
};
const STAT_KEYS = ['hunger', 'happiness', 'energy', 'cleanliness'];
const FOODS = {
pudding: {
name: '草莓布丁',
hunger: 16,
happiness: 6,
energy: 1,
speech: [
'布丁在舌尖化开了,是草莓味的云。',
'甜度刚好。连尾巴尖都开心了。'
]
},
cookie: {
name: '星星饼干',
hunger: 11,
happiness: 9,
energy: 3,
speech: [
'咔嚓。你听见我的好心情了吗?',
'饼干屑掉在肚子上,我会自己拍拍干净。'
]
},
onigiri: {
name: '云朵饭团',
hunger: 24,
happiness: 3,
energy: 5,
speech: [
'热乎乎的。胃里像揣了一个小太阳。',
'吃完这个,我可以把整片草地跑一遍。'
]
}
};
const QUESTS = [
{
id: 'pets',
title: '摸摸 PICO 三次',
target: 3,
reward: 6,
xp: 6,
icon: 'i-heart',
className: 'quest--pets'
},
{
id: 'feeds',
title: '为 PICO 准备两餐',
target: 2,
reward: 8,
xp: 8,
icon: 'i-bowl',
className: 'quest--feeds'
},
{
id: 'plays',
title: '一起玩一局追星星',
target: 1,
reward: 10,
xp: 10,
icon: 'i-star',
className: 'quest--plays'
}
];
const ACCESSORIES = [
{
id: 'flower',
name: '晨露花环',
price: 12,
description: '在篱笆边采的小雏菊,还留着一点晨露。',
icon: 'flower'
},
{
id: 'scarf',
name: '莓果围巾',
price: 24,
description: '软软的、暖暖的,像一个轻轻的拥抱。',
icon: 'scarf'
},
{
id: 'beret',
name: '橡果贝雷帽',
price: 36,
description: '森林里最时兴的小帽子,戴上会想转圈。',
icon: 'beret'
}
];
const ACCESSORY_IDS = ACCESSORIES.map((item) => item.id);
const NEED_SPEECH = {
hungry: [
'闻到香味了吗?我的小肚子在打鼓。',
'如果现在有一块云朵饭团,我可以一口吃掉。',
'我刚刚把“想吃布丁”这句话,悄悄咽回去了。'
],
sleepy: [
'我的眼皮好像变得有一点点重……',
'可以只闭眼睛一小会儿吗?就一小会儿。',
'刚刚打了个小小的哈欠,被你看见啦。'
],
dirty: [
'毛上沾了一点点草籽,想洗个香香澡。',
'今天的风有一点点灰,我想变回香香的云。'
],
lonely: [
'可以陪我待一会儿吗?就这样坐着也很好。',
'我把想念折成了一只小纸船,放在窗边。'
]
};
const AMBIENT_SPEECH = {
morning: [
'早安。今天的阳光薄薄的,像一片饼干。',
'我刚数了窗外的云,一共三朵,都是软的。',
'叶子上的露水还没干,你要看看吗?'
],
day: [
'今天的云走得很慢,适合发呆。',
'我把阳光收进毛里了,摸起来暖乎乎的。',
'刚刚有一只小鸟停在篱笆上,它没有发现我。',
'如果时间是软的,今天大概是一块小蛋糕。'
],
dusk: [
'晚霞把草地染成了桃子味。',
'太阳要回家啦,我陪它走到门口。',
'这个时间最适合什么也不做。'
],
night: [
'星星出来了。我把最亮的那颗叫作“今天”。',
'嘘,月亮在给云朵盖被子。',
'夜深了。但如果你还醒着,我再陪你一会儿。'
]
};
const PETTING_SPEECH = [
'唔……再摸一下也可以。',
'咕噜咕噜。这是喜欢你的声音。',
'你的手心有阳光的味道。',
'我决定今天也待在你身边。',
'被你摸过的地方,好像开了一朵小花。'
];
const FULL_PETTING_SPEECH = [
'再摸下去,我的毛要发光啦。',
'好开心。今天的幸福已经装不下了。',
'如果开心能冒泡泡,现在大概已经飞起来了。'
];
const CLEANING_SPEECH = [
'啵。现在是香香的、亮亮的 PICO。',
'泡泡把我裹成了一颗大棉花糖。',
'洗好啦。闻闻,是雨后的味道。'
];
const FULL_HUNGER_SPEECH = [
'已经吃饱啦,小肚子圆滚滚的。',
'再吃就要变成一颗球了。等一下再来?'
];
const FULL_CLEAN_SPEECH = [
'我刚刚才洗过,还是最亮的那朵云。',
'再洗就要掉色啦,让我先香一会儿。'
];
const SLEEP_SPEECH = [
'晚安。等云朵把月亮盖好,我就醒啦。',
'我去梦里看看,有没有更大的草莓。'
];
const WAKE_SPEECH = [
'唔……刚刚梦到一颗很大的星星。',
'醒了。伸一个长长的懒腰——好啦。',
'早安,或者晚安。总之,见到你真好。'
];
const GAME_SPEECH = {
five: '接住啦,全都接住啦!我要把星星放进玻璃瓶里。',
some: '你看,亮晶晶的。今天的好心情有着落了。',
few: '星星有点淘气。不过和你一起跑一跑,就很开心。'
};
const $ = (selector, root = document) => root.querySelector(selector);
const $$ = (selector, root = document) => Array.from(root.querySelectorAll(selector));
const dom = {
habitat: $('#habitat'),
dateBadge: $('#dateBadge'),
dateIconUse: $('#dateBadge use'),
dateText: $('#dateText'),
speechBubble: $('#speechBubble'),
speechLabel: $('#speechLabel'),
speechText: $('#speechText'),
petButton: $('#petButton'),
petHint: $('#petHint'),
petFx: $('#petFx'),
bubbleField: $('#bubbleField'),
nameplate: $('.nameplate'),
dayCount: $('#dayCount'),
miniLevel: $('#miniLevel'),
miniMood: $('#miniMood'),
careTitle: $('#careTitle'),
moodLabel: $('#moodLabel'),
moodCopy: $('#moodCopy'),
levelBadge: $('#levelBadge'),
levelNum: $('#levelNum'),
levelXp: $('#levelXp'),
coinBtn: $('#coinBtn'),
coinCount: $('#coinCount'),
soundBtn: $('#soundBtn'),
wardrobeBtn: $('#wardrobeBtn'),
actionFeed: $('#actionFeed'),
actionPlay: $('#actionPlay'),
actionClean: $('#actionClean'),
actionSleep: $('#actionSleep'),
sleepLabel: $('#sleepLabel'),
sleepSub: $('#sleepSub'),
careSentence: $('#careSentence'),
questList: $('#questList'),
valueHunger: $('#valueHunger'),
valueHappiness: $('#valueHappiness'),
valueEnergy: $('#valueEnergy'),
valueCleanliness: $('#valueCleanliness'),
barHunger: $('#barHunger'),
barHappiness: $('#barHappiness'),
barEnergy: $('#barEnergy'),
barCleanliness: $('#barCleanliness'),
trackHunger: $('#trackHunger'),
trackHappiness: $('#trackHappiness'),
trackEnergy: $('#trackEnergy'),
trackCleanliness: $('#trackCleanliness'),
foodDialog: $('#foodDialog'),
gameDialog: $('#gameDialog'),
wardrobeDialog: $('#wardrobeDialog'),
foodGrid: $('.food-grid'),
gameIntro: $('#gameIntro'),
gamePlay: $('#gamePlay'),
gameStage: $('#gameStage'),
gameScore: $('#gameScore'),
gameTime: $('#gameTime'),
gameStageTip: $('#gameStageTip'),
gameResult: $('#gameResult'),
resultStars: $('#resultStars'),
resultTitle: $('#resultTitle'),
resultCopy: $('#resultCopy'),
resultRewards: $('#resultRewards'),
resultExtra: $('#resultExtra'),
startGameBtn: $('#startGameBtn'),
claimGameBtn: $('#claimGameBtn'),
shopGrid: $('#shopGrid'),
shopCoinCount: $('#shopCoinCount'),
shopMessage: $('#shopMessage'),
removeAccessoryBtn: $('#removeAccessoryBtn'),
toastStack: $('#toastStack'),
celebrationLayer: $('#celebrationLayer')
};
let state;
let lastTick = Date.now();
let lastSave = Date.now();
let lastSpeechAt = Date.now();
let lastSpeechText = '';
let lastClockRefresh = 0;
let lastDailyCheck = 0;
let lastPetAt = 0;
let petAnimationTimer = null;
const game = {
active: false,
applied: false,
wrappingUp: false,
score: 0,
endsAt: 0,
spawnTimer: null,
tickTimer: null,
finishTimer: null
};
/* --------------------------------------------------------------------------
Tiny helpers
-------------------------------------------------------------------------- */
const clamp = (value, min, max) => Math.min(max, Math.max(min, value));
const pad2 = (value) => String(value).padStart(2, '0');
const randomInt = (min, max) => Math.floor(Math.random() * (max - min + 1)) + min;
const pick = (items) => items[Math.floor(Math.random() * items.length)];
const reducedMotion = () => window.matchMedia?.('(prefers-reduced-motion: reduce)').matches;
function pickDifferent(items, previous) {
if (items.length === 1) return items[0];
let result = pick(items);
let guard = 0;
while (result === previous && guard < 8) {
result = pick(items);
guard += 1;
}
return result;
}
function padStat(value) {
return Math.round(clamp(Number(value) || 0, 0, 100));
}
function startOfDay(timestamp) {
const date = new Date(timestamp);
date.setHours(0, 0, 0, 0);
return date.getTime();
}
function dateKey(date = new Date()) {
return `${date.getFullYear()}-${pad2(date.getMonth() + 1)}-${pad2(date.getDate())}`;
}
function getDayNumber() {
const start = startOfDay(state.createdAt);
const today = startOfDay(Date.now());
return Math.max(1, Math.floor((today - start) / 86400000) + 1);
}
function getPhase(date = new Date()) {
const hour = date.getHours();
if (hour >= 5 && hour < 9) {
return {
key: 'morning',
label: '清晨',
greeting: '早安',
action: '刚刚睡醒',
icon: 'i-sun'
};
}
if (hour >= 9 && hour < 12) {
return {
key: 'day',
label: '上午',
greeting: '上午好',
action: '在窗边发呆',
icon: 'i-sun'
};
}
if (hour >= 12 && hour < 17) {
return {
key: 'day',
label: '午后',
greeting: '午后好',
action: '在晒太阳',
icon: 'i-sun'
};
}
if (hour >= 17 && hour < 20) {
return {
key: 'dusk',
label: '黄昏',
greeting: '傍晚好',
action: '在等晚霞',
icon: 'i-cloud'
};
}
return {
key: 'night',
label: hour >= 20 ? '夜晚' : '深夜',
greeting: hour >= 20 ? '晚上好' : '夜深了',
action: '在小声数星星',
icon: 'i-moon'
};
}
function formatAway(seconds) {
const minutes = Math.max(1, Math.round(seconds / 60));
if (minutes < 60) return `${minutes} 分钟`;
const hours = Math.floor(minutes / 60);
const rest = minutes % 60;
return rest ? `${hours} 小时 ${rest} 分钟` : `${hours} 小时`;
}
/* --------------------------------------------------------------------------
Save data
-------------------------------------------------------------------------- */
function makeDaily(date = dateKey()) {
return {
date,
pets: 0,
feeds: 0,
plays: 0,
care: 0,
claimed: []
};
}
function defaultState() {
const now = Date.now();
return {
version: 1,
createdAt: now,
lastSeen: now,
level: 1,
xp: 18,
coins: 18,
stats: {
hunger: 78,
happiness: 82,
energy: 68,
cleanliness: 88
},
asleep: false,
equipped: null,
owned: [],
sound: true,
daily: makeDaily(),
totals: {
care: 0
}
};
}
function normalizeDaily(raw) {
const today = dateKey();
if (!raw || raw.date !== today) return makeDaily(today);
const claimed = Array.isArray(raw.claimed)
? raw.claimed.filter((id) => QUESTS.some((quest) => quest.id === id))
: [];
return {
date: today,
pets: clamp(Math.floor(Number(raw.pets) || 0), 0, 999),
feeds: clamp(Math.floor(Number(raw.feeds) || 0), 0, 999),
plays: clamp(Math.floor(Number(raw.plays) || 0), 0, 999),
care: clamp(Math.floor(Number(raw.care) || 0), 0, 9999),
claimed
};
}
function normalizeState(raw) {
const base = defaultState();
if (!raw || typeof raw !== 'object') {
return { state: base, isNew: true };
}
const statsSource = raw.stats && typeof raw.stats === 'object' ? raw.stats : {};
const stats = {};
for (const key of STAT_KEYS) {
const value = Number(statsSource[key]);
stats[key] = Number.isFinite(value) ? clamp(value, 0, 100) : base.stats[key];
}
const owned = Array.isArray(raw.owned)
? [...new Set(raw.owned.filter((id) => ACCESSORY_IDS.includes(id)))]
: [];
const equipped = owned.includes(raw.equipped) ? raw.equipped : null;
const now = Date.now();
const normalized = {
...base,
...raw,
version: 1,
createdAt: Number.isFinite(Number(raw.createdAt))
? Math.min(Number(raw.createdAt), now)
: base.createdAt,
lastSeen: Number.isFinite(Number(raw.lastSeen))
? Math.min(Number(raw.lastSeen), now)
: base.lastSeen,
level: clamp(Math.floor(Number(raw.level) || 1), 1, 99),
xp: Math.max(0, Math.floor(Number(raw.xp) || 0)),
coins: Math.max(0, Math.floor(Number(raw.coins) || 0)),
stats,
asleep: Boolean(raw.asleep),
owned,
equipped,
sound: raw.sound !== false,
daily: normalizeDaily(raw.daily),
totals: {
care: Math.max(0, Math.floor(Number(raw.totals?.care) || 0))
}
};
return { state: normalized, isNew: false };
}
function loadState() {
try {
const raw = window.localStorage.getItem(SAVE_KEY);
if (!raw) return { state: defaultState(), isNew: true };
return normalizeState(JSON.parse(raw));
} catch (error) {
return { state: defaultState(), isNew: true };
}
}
function saveState() {
try {
state.lastSeen = Date.now();
window.localStorage.setItem(SAVE_KEY, JSON.stringify(state));
lastSave = Date.now();
} catch (error) {
// A private window may refuse storage. The app still works for this visit.
}
}
function ensureDaily() {
const today = dateKey();
if (!state.daily || state.daily.date !== today) {
state.daily = makeDaily(today);
return true;
}
return false;
}
function applyAwayTime(seconds, isNew) {
if (isNew) return 0;
const away = clamp(seconds, 0, MAX_OFFLINE_SECONDS);
if (away < 45) return 0;
if (state.asleep) {
state.stats.energy = clamp(state.stats.energy + away * 0.05, 0, 100);
state.stats.hunger = clamp(state.stats.hunger - away * 0.0012, 0, 100);
} else {
state.stats.hunger = clamp(state.stats.hunger - away * RATES.hunger * 0.18, 0, 100);
state.stats.happiness = clamp(state.stats.happiness - away * RATES.happiness * 0.2, 0, 100);
state.stats.energy = clamp(state.stats.energy - away * RATES.energy * 0.2, 0, 100);
state.stats.cleanliness = clamp(state.stats.cleanliness - away * RATES.cleanliness * 0.18, 0, 100);
}
return away;
}
/* --------------------------------------------------------------------------
Progress, quests & rewards
-------------------------------------------------------------------------- */
function xpNeeded(level) {
return 50 + level * 20;
}
function grantXp(amount) {
const gain = Math.max(0, Math.round(amount));
if (!gain) return 0;
state.xp += gain;
let levelsGained = 0;
while (state.xp >= xpNeeded(state.level) && levelsGained < 25) {
state.xp -= xpNeeded(state.level);
state.level += 1;
state.coins += LEVEL_COIN_REWARD;
levelsGained += 1;
}
return levelsGained;
}
function addQuestProgress(id, amount = 1) {
const definition = QUESTS.find((quest) => quest.id === id);
if (!definition) return null;
if (state.daily.claimed.includes(id)) return null;
const current = clamp(Math.floor(Number(state.daily[id]) || 0), 0, definition.target);
state.daily[id] = clamp(current + amount, 0, definition.target);
if (state.daily[id] >= definition.target) {
state.daily.claimed.push(id);
state.coins += definition.reward;
const levels = grantXp(definition.xp);
return { definition, levels };
}
return null;
}
function announceMilestones(levelBefore, questResult) {
const levels = state.level - levelBefore;
if (!questResult && levels <= 0) return;
if (levels > 0) {
sound.play('level');
celebrate();
} else if (questResult) {
sound.play('quest');
}
let title = '';
let detail = '';
if (questResult && levels > 0) {
title = `今日约定完成,PICO 升到 LV.${pad2(state.level)}`;
detail = `+${questResult.definition.reward + levels * LEVEL_COIN_REWARD} 颗星星币 · 它又长大一点点。`;
} else if (questResult) {
title = '今日小约定完成';
detail = `「${questResult.definition.title}」 · +${questResult.definition.reward} 颗星星币。`;
} else {
title = `PICO 升到 LV.${pad2(state.level)} 啦`;
detail = `长大了一点点 · +${levels * LEVEL_COIN_REWARD} 颗星星币。`;
}
showToast(title, detail, levels > 0 ? 'level' : 'star');
}
/* --------------------------------------------------------------------------
Mood, speech & expressions
-------------------------------------------------------------------------- */
function getMoodInfo() {
const stats = state.stats;
const lowest = Math.min(stats.hunger, stats.happiness, stats.energy, stats.cleanliness);
const average = (stats.hunger + stats.happiness + stats.energy + stats.cleanliness) / 4;
const phase = getPhase();
if (state.asleep) {
return {
label: '正在做云朵味的梦',
title: 'PICO 睡得正香',
copy: '被子盖好了,梦正轻轻路过。等它睡饱,再一起玩吧。',
short: '做着好梦',
expression: 'sleepy'
};
}
if (stats.hunger < 28) {
return {
label: '肚子在轻轻打鼓',
title: 'PICO 的肚子在打鼓',
copy: '它闻了闻空气,好像有一块云朵饭团正在靠近。准备一餐吧。',
short: '肚子饿了',
expression: 'hungry'
};
}
if (stats.energy < 25) {
return {
label: '眼皮快撑不住了',
title: 'PICO 困得睁不开眼',
copy: '它偷偷打了三个哈欠,正在努力把眼皮撑开。钻进被子里会好一点。',
short: '有点困',
expression: 'sleepy'
};
}
if (stats.cleanliness < 25) {
return {
label: '身上沾了小草籽',
title: 'PICO 想变成香香的云',
copy: '毛上沾了一点草籽和风的味道。一盆暖乎乎的泡泡澡就能解决。',
short: '想洗香香',
expression: 'neutral'
};
}
if (stats.happiness < 28) {
return {
label: '有一点安静',
title: 'PICO 有一点想你',
copy: '今天没有那么热闹。陪它坐一会儿,或者一起追几颗星星吧。',
short: '有点想你',
expression: 'sad'
};
}
if (average > 84 && lowest > 58) {
return {
label: '此刻心情很好',
title: 'PICO 开心得冒泡泡',
copy: '一切都刚刚好。PICO 把今天的阳光收进了口袋里,连耳朵尖都是软的。',
short: '心情很好',
expression: 'happy'
};
}
if (lowest < 52) {
return {
label: '有一点点小需要',
title: `${phase.greeting},PICO ${phase.action}`,
copy: '没有很糟糕,只是有一点点需要你。慢一点来,也完全来得及。',
short: '有一点需要你',
expression: lowest < 36 ? 'neutral' : 'happy'
};
}
return {
label: '此刻心情很好',
title: `${phase.greeting},PICO ${phase.action}`,
copy: '日子软软的。它把尾巴搭在草地上,等你来一起浪费时间。',
short: '心情很好',
expression: average > 78 ? 'happy' : 'neutral'
};
}
function getContextSpeech() {
const stats = state.stats;
if (state.asleep) return pick(SLEEP_SPEECH);
if (stats.hunger < 38) return pickDifferent(NEED_SPEECH.hungry, lastSpeechText);
if (stats.energy < 32) return pickDifferent(NEED_SPEECH.sleepy, lastSpeechText);
if (stats.cleanliness < 32) return pickDifferent(NEED_SPEECH.dirty, lastSpeechText);
if (stats.happiness < 32) return pickDifferent(NEED_SPEECH.lonely, lastSpeechText);
const phase = getPhase();
return pickDifferent(AMBIENT_SPEECH[phase.key], lastSpeechText);
}
function updateMood() {
const info = getMoodInfo();
dom.moodLabel.textContent = info.label;
dom.careTitle.textContent = info.title;
dom.moodCopy.textContent = info.copy;
dom.miniMood.textContent = info.short;
dom.petButton.dataset.mood = info.expression;
const sleeping = state.asleep;
dom.habitat.classList.toggle('is-sleeping', sleeping);
dom.petButton.classList.toggle('is-sleeping', sleeping);
dom.petButton.setAttribute('aria-label', sleeping ? 'PICO 正在睡觉' : '摸摸 PICO');
}
function setSpeech(text, label = 'PICO 说') {
if (!text) return;
dom.speechLabel.textContent = label;
dom.speechText.textContent = text;
lastSpeechText = text;
lastSpeechAt = Date.now();
dom.speechBubble.classList.remove('is-speaking');
void dom.speechBubble.offsetWidth;
dom.speechBubble.classList.add('is-speaking');
}
/* --------------------------------------------------------------------------
Rendering
-------------------------------------------------------------------------- */
function renderStats() {
const values = [
['hunger', dom.valueHunger, dom.barHunger, dom.trackHunger],
['happiness', dom.valueHappiness, dom.barHappiness, dom.trackHappiness],
['energy', dom.valueEnergy, dom.barEnergy, dom.trackEnergy],
['cleanliness', dom.valueCleanliness, dom.barCleanliness, dom.trackCleanliness]
];
for (const [key, valueNode, barNode, trackNode] of values) {
const raw = clamp(state.stats[key], 0, 100);
const rounded = padStat(raw);
valueNode.textContent = String(rounded);
barNode.style.width = `${raw.toFixed(1)}%`;
trackNode.setAttribute('aria-valuenow', String(rounded));
const statNode = trackNode.closest('.stat');
statNode?.classList.toggle('is-low', rounded < 25);
}
}
function renderMeta() {
dom.coinCount.textContent = String(state.coins);
dom.shopCoinCount.textContent = String(state.coins);
dom.levelNum.textContent = pad2(state.level);
dom.miniLevel.textContent = `LV.${pad2(state.level)}`;
const need = xpNeeded(state.level);
dom.levelXp.textContent = `${Math.floor(state.xp)} / ${need}`;
const progress = clamp((state.xp / need) * 100, 0, 100);
dom.levelBadge.style.setProperty('--progress', `${progress.toFixed(1)}%`);
dom.levelBadge.title = `成长值 ${Math.floor(state.xp)} / ${need}`;
dom.dayCount.textContent = String(getDayNumber());
}
function renderActions() {
const sleeping = state.asleep;
for (const button of [dom.actionFeed, dom.actionPlay, dom.actionClean]) {
button.disabled = sleeping;
button.title = sleeping ? '嘘,PICO 正在睡觉' : '';
}
dom.sleepLabel.textContent = sleeping ? '叫醒 PICO' : '哄它睡觉';
dom.sleepSub.textContent = sleeping ? '它正在梦里捡星星' : '钻进软绵绵的梦里';
dom.actionSleep.classList.toggle('is-resting', sleeping);
dom.actionSleep.setAttribute('aria-pressed', String(sleeping));
const hintText = dom.petHint.querySelector('span');
if (hintText) hintText.textContent = sleeping ? '嘘,它在睡觉' : '摸摸它';
dom.petHint.setAttribute('aria-label', sleeping ? '看看正在睡觉的 PICO' : '摸摸 PICO');
if (sleeping) {
dom.careSentence.textContent = 'PICO 正在梦里充电,慢慢等它醒来吧';
} else if (state.daily.care === 0) {
dom.careSentence.textContent = '今天还没有被照顾,先摸摸它吧';
} else {
dom.careSentence.textContent = `今天已经被照顾了 ${state.daily.care} 次,尾巴都翘起来啦`;
}
}
function renderQuests() {
dom.questList.innerHTML = QUESTS.map((quest) => {
const current = clamp(Math.floor(Number(state.daily[quest.id]) || 0), 0, quest.target);
const done = current >= quest.target;
const claimed = state.daily.claimed.includes(quest.id);
const progress = Math.round((current / quest.target) * 100);
const rewardMark = claimed ? '✓' : '';
const stateLabel = claimed ? ',奖励已领取' : done ? ',刚刚完成' : '';
const rowClass = [
'quest',
quest.className,
done ? 'is-done' : '',
claimed ? 'is-claimed' : ''
].filter(Boolean).join(' ');
return `
${quest.title}
${current} / ${quest.target}
${rewardMark}
${claimed ? `+${quest.reward}` : `+${quest.reward}`}
`;
}).join('');
}
function renderAccessory() {
for (const id of ACCESSORY_IDS) {
const accessory = $(`.accessory--${id}`);
accessory?.classList.toggle('is-on', state.equipped === id);
}
dom.petButton.classList.toggle('look--beret', state.equipped === 'beret');
}
function renderSound() {
dom.soundBtn.classList.toggle('is-muted', !state.sound);
dom.soundBtn.setAttribute('aria-pressed', String(state.sound));
dom.soundBtn.setAttribute('aria-label', state.sound ? '关闭音效' : '开启音效');
dom.soundBtn.title = state.sound ? '关闭音效' : '开启音效';
}
function renderShop() {
dom.shopCoinCount.textContent = String(state.coins);
dom.shopGrid.innerHTML = ACCESSORIES.map((item) => {
const owned = state.owned.includes(item.id);
const equipped = state.equipped === item.id;
const buttonLabel = equipped ? '摘下' : owned ? '戴上' : '兑换';
const priceLabel = owned
? '已拥有'
: `${item.price}`;
return `
${accessoryArt(item.icon)}
${equipped ? '' : ''}
${item.name}
${item.description}
${priceLabel}
`;
}).join('');
}
function accessoryArt(kind) {
if (kind === 'flower') {
return `
`;
}
if (kind === 'scarf') {
return `
`;
}
return `
`;
}
function renderShopIfOpen() {
if (dom.wardrobeDialog.open) renderShop();
}
function renderAll() {
renderStats();
renderMeta();
renderQuests();
renderActions();
renderAccessory();
renderSound();
renderShopIfOpen();
updateMood();
}
function refreshClock(force = false) {
const phase = getPhase();
const now = new Date();
const dateLabel = `${now.getMonth() + 1}月${now.getDate()}日 · ${phase.label}`;
if (force || dom.habitat.dataset.phase !== phase.key) {
dom.habitat.dataset.phase = phase.key;
}
if (dom.dateText.textContent !== dateLabel) {
dom.dateText.textContent = dateLabel;
}
dom.dateIconUse.setAttribute('href', `#${phase.icon}`);
dom.dateBadge.setAttribute('aria-label', dateLabel);
lastClockRefresh = Date.now();
}
/* --------------------------------------------------------------------------
Visual effects
-------------------------------------------------------------------------- */
function animatePet(className, duration = 1200) {
dom.petButton.classList.remove('is-eating', 'is-cleaning', 'is-petting');
void dom.petButton.offsetWidth;
dom.petButton.classList.add(className);
if (petAnimationTimer) window.clearTimeout(petAnimationTimer);
petAnimationTimer = window.setTimeout(() => {
dom.petButton.classList.remove(className);
}, duration);
}
function spawnFx(kind, count = 5) {
const petRect = dom.petButton.getBoundingClientRect();
const layerRect = dom.petFx.getBoundingClientRect();
const baseX = petRect.left - layerRect.left + petRect.width / 2;
const baseY = petRect.top - layerRect.top + petRect.height * 0.42;
const crumbColors = ['#dcb086', '#e8caa2', '#c69069'];
for (let index = 0; index < count; index += 1) {
const node = document.createElement('span');
const drift = randomInt(-52, 52);
const delay = index * 0.07 + Math.random() * 0.08;
if (kind === 'crumb') {
node.className = 'fx fx--crumb';
node.style.setProperty('--size', `${randomInt(4, 8)}px`);
node.style.setProperty('--crumb', pick(crumbColors));
} else if (kind === 'sparkle') {
node.className = 'fx fx--sparkle';
node.textContent = Math.random() > 0.4 ? '✦' : '✧';
node.style.setProperty('--size', `${randomInt(12, 22)}px`);
} else {
node.className = 'fx fx--heart';
node.textContent = '♥';
node.style.setProperty('--size', `${randomInt(12, 22)}px`);
}
node.style.left = `${baseX + randomInt(-1, 1) * petRect.width * randomInt(10, 30) / 100}px`;
node.style.top = `${baseY + randomInt(-34, 28)}px`;
node.style.setProperty('--dx', `${drift}px`);
node.style.setProperty('--rot', `${randomInt(-24, 24)}deg`);
node.style.setProperty('--scale', (0.8 + Math.random() * 0.6).toFixed(2));
node.style.setProperty('--delay', `${delay.toFixed(2)}s`);
dom.petFx.appendChild(node);
window.setTimeout(() => node.remove(), 1900 + delay * 1000);
}
}
function spawnBubbles(count = 14) {
const petRect = dom.petButton.getBoundingClientRect();
const layerRect = dom.bubbleField.getBoundingClientRect();
for (let index = 0; index < count; index += 1) {
const node = document.createElement('span');
const size = randomInt(10, 36);
const delay = Math.random() * 0.36;
node.className = 'soap-bubble';
node.style.setProperty('--size', `${size}px`);
node.style.setProperty('--delay', `${delay.toFixed(2)}s`);
node.style.setProperty('--drift', `${randomInt(-70, 70)}px`);
node.style.left = `${petRect.left - layerRect.left + petRect.width * (0.2 + Math.random() * 0.6)}px`;
node.style.top = `${petRect.top - layerRect.top + petRect.height * (0.42 + Math.random() * 0.42)}px`;
dom.bubbleField.appendChild(node);
window.setTimeout(() => node.remove(), 2900 + delay * 1000);
}
}
function celebrate() {
if (reducedMotion()) return;
const colors = ['#d9826a', '#e9c879', '#99b185', '#82a5a8', '#f4e6bf'];
const fragment = document.createDocumentFragment();
for (let index = 0; index < 34; index += 1) {
const confetti = document.createElement('span');
confetti.className = `confetti${Math.random() > 0.56 ? ' is-round' : ''}`;
confetti.style.left = `${randomInt(2, 98)}%`;
confetti.style.setProperty('--confetti-color', pick(colors));
confetti.style.setProperty('--drift', `${randomInt(-110, 110)}px`);
confetti.style.setProperty('--spin', `${randomInt(180, 900)}deg`);
confetti.style.setProperty('--fall-time', `${(1.8 + Math.random() * 1.7).toFixed(2)}s`);
confetti.style.setProperty('--fall-delay', `${(Math.random() * 0.35).toFixed(2)}s`);
fragment.appendChild(confetti);
window.setTimeout(() => confetti.remove(), 3900);
}
dom.celebrationLayer.appendChild(fragment);
}
function showToast(title, detail, kind = 'star') {
const toast = document.createElement('div');
toast.className = `toast${kind === 'level' ? ' toast--level' : ''}`;
const icon = document.createElement('span');
icon.className = 'toast-icon';
icon.innerHTML = ``;
const copy = document.createElement('span');
copy.className = 'toast-copy';
const strong = document.createElement('strong');
const small = document.createElement('span');
strong.textContent = title;
small.textContent = detail;
copy.append(strong, small);
toast.append(icon, copy);
while (dom.toastStack.children.length >= 3) {
dom.toastStack.firstElementChild?.remove();
}
dom.toastStack.appendChild(toast);
window.setTimeout(() => {
toast.classList.add('is-leaving');
window.setTimeout(() => toast.remove(), 280);
}, 3600);
}
/* --------------------------------------------------------------------------
Sound · tiny synthesized chimes, no audio files
-------------------------------------------------------------------------- */
const sound = {
ctx: null,
ensure() {
if (!state.sound) return null;
const AudioContextClass = window.AudioContext || window.webkitAudioContext;
if (!AudioContextClass) return null;
if (!this.ctx) {
try {
this.ctx = new AudioContextClass();
} catch (error) {
return null;
}
}
if (this.ctx.state === 'suspended') {
this.ctx.resume().catch(() => {});
}
return this.ctx;
},
tone(frequency, start = 0, duration = 0.11, volume = 0.022, type = 'sine') {
const ctx = this.ensure();
if (!ctx) return;
const when = ctx.currentTime + start;
const oscillator = ctx.createOscillator();
const gain = ctx.createGain();
oscillator.type = type;
oscillator.frequency.setValueAtTime(frequency, when);
gain.gain.setValueAtTime(0.0001, when);
gain.gain.exponentialRampToValueAtTime(volume, when + 0.012);
gain.gain.exponentialRampToValueAtTime(0.0001, when + duration);
oscillator.connect(gain);
gain.connect(ctx.destination);
oscillator.start(when);
oscillator.stop(when + duration + 0.03);
},
play(name) {
if (!state.sound) return;
try {
switch (name) {
case 'tap':
this.tone(523, 0, 0.07, 0.012, 'sine');
break;
case 'pet':
this.tone(587, 0, 0.09, 0.015, 'sine');
this.tone(740, 0.055, 0.1, 0.012, 'sine');
break;
case 'feed':
this.tone(392, 0, 0.1, 0.02, 'sine');
this.tone(523, 0.08, 0.13, 0.018, 'sine');
break;
case 'clean':
this.tone(660, 0, 0.09, 0.014, 'sine');
this.tone(880, 0.07, 0.1, 0.013, 'sine');
this.tone(1047, 0.14, 0.13, 0.011, 'sine');
break;
case 'star':
this.tone(1047, 0, 0.06, 0.015, 'triangle');
this.tone(1319, 0.045, 0.09, 0.012, 'sine');
break;
case 'sleep':
this.tone(392, 0, 0.15, 0.014, 'sine');
this.tone(330, 0.12, 0.2, 0.012, 'sine');
break;
case 'wake':
this.tone(440, 0, 0.09, 0.014, 'sine');
this.tone(554, 0.08, 0.1, 0.013, 'sine');
this.tone(659, 0.16, 0.13, 0.012, 'sine');
break;
case 'quest':
this.tone(784, 0, 0.09, 0.014, 'sine');
this.tone(988, 0.08, 0.15, 0.013, 'sine');
break;
case 'success':
this.tone(523, 0, 0.1, 0.018, 'sine');
this.tone(659, 0.09, 0.1, 0.017, 'sine');
this.tone(784, 0.18, 0.12, 0.016, 'sine');
this.tone(1047, 0.28, 0.2, 0.014, 'sine');
break;
case 'level':
this.tone(523, 0, 0.1, 0.02, 'triangle');
this.tone(659, 0.1, 0.1, 0.018, 'triangle');
this.tone(784, 0.2, 0.13, 0.017, 'sine');
this.tone(1047, 0.32, 0.24, 0.014, 'sine');
break;
default:
break;
}
} catch (error) {
// Audio is an enhancement, never a requirement.
}
}
};
/* --------------------------------------------------------------------------
Dialog helpers
-------------------------------------------------------------------------- */
function openDialog(dialog) {
if (dialog.open) return;
dialog.showModal();
document.body.classList.add('modal-open');
}
function closeDialog(dialog) {
if (dialog?.open) dialog.close();
}
function anyDialogOpen() {
return Boolean(document.querySelector('dialog[open]'));
}
function syncBodyLock() {
document.body.classList.toggle('modal-open', anyDialogOpen());
}
function setShopMessage(text, tone = '') {
dom.shopMessage.textContent = text;
dom.shopMessage.classList.toggle('is-warm', tone === 'warm');
dom.shopMessage.classList.toggle('is-good', tone === 'good');
}
/* --------------------------------------------------------------------------
Care actions
-------------------------------------------------------------------------- */
function openFoodDialog() {
ensureDaily();
if (state.asleep) {
setSpeech('嘘……我还在梦里。', '睡梦中的 PICO');
return;
}
if (state.stats.hunger >= 97) {
setSpeech(pick(FULL_HUNGER_SPEECH));
showToast('小肚子已经圆滚滚啦', '等它消化一会儿,再来准备下一餐吧。', 'star');
sound.play('tap');
return;
}
sound.play('tap');
openDialog(dom.foodDialog);
}
function feedPet(foodId) {
const food = FOODS[foodId];
if (!food || state.asleep) return;
ensureDaily();
if (state.stats.hunger >= 97) {
closeDialog(dom.foodDialog);
setSpeech(pick(FULL_HUNGER_SPEECH));
return;
}
const levelBefore = state.level;
state.stats.hunger = clamp(state.stats.hunger + food.hunger, 0, 100);
state.stats.happiness = clamp(state.stats.happiness + food.happiness, 0, 100);
state.stats.energy = clamp(state.stats.energy + food.energy, 0, 100);
state.daily.care = (Number(state.daily.care) || 0) + 1;
grantXp(8);
const questResult = addQuestProgress('feeds', 1);
closeDialog(dom.foodDialog);
renderAll();
animatePet('is-eating', 1250);
spawnFx('crumb', 7);
spawnFx('heart', 2);
setSpeech(pick(food.speech));
sound.play('feed');
announceMilestones(levelBefore, questResult);
saveState();
}
function petThePet() {
const now = Date.now();
if (now - lastPetAt < PET_COOLDOWN_MS) return;
lastPetAt = now;
if (state.asleep) {
setSpeech('呼……呼……(它在梦里翻了个身)', '睡梦中的 PICO');
spawnFx('sparkle', 1);
sound.play('pet');
return;
}
ensureDaily();
const levelBefore = state.level;
const gainedHappiness = state.stats.happiness < 99.7;
if (gainedHappiness) {
state.stats.happiness = clamp(state.stats.happiness + 2, 0, 100);
grantXp(1);
}
state.daily.care = (Number(state.daily.care) || 0) + 1;
const questResult = addQuestProgress('pets', 1);
renderAll();
animatePet('is-petting', 480);
spawnFx('heart', randomInt(2, 4));
setSpeech(pick(gainedHappiness ? PETTING_SPEECH : FULL_PETTING_SPEECH));
sound.play('pet');
announceMilestones(levelBefore, questResult);
saveState();
}
function cleanPet() {
if (state.asleep) return;
ensureDaily();
if (state.stats.cleanliness >= 98) {
setSpeech(pick(FULL_CLEAN_SPEECH));
showToast('PICO 已经香喷喷啦', '它现在是一朵很干净的云。', 'star');
sound.play('tap');
return;
}
const levelBefore = state.level;
state.stats.cleanliness = clamp(state.stats.cleanliness + 30, 0, 100);
state.stats.happiness = clamp(state.stats.happiness + 4, 0, 100);
state.daily.care = (Number(state.daily.care) || 0) + 1;
grantXp(7);
renderAll();
animatePet('is-cleaning', 1550);
spawnBubbles(15);
setSpeech(pick(CLEANING_SPEECH));
sound.play('clean');
announceMilestones(levelBefore, null);
saveState();
}
function toggleSleep() {
const levelBefore = state.level;
if (state.asleep) {
state.asleep = false;
renderAll();
setSpeech(pick(WAKE_SPEECH));
sound.play('wake');
announceMilestones(levelBefore, null);
} else {
state.asleep = true;
state.stats.energy = clamp(state.stats.energy + 3, 0, 100);
state.daily.care = (Number(state.daily.care) || 0) + 1;
renderAll();
setSpeech(pick(SLEEP_SPEECH), '睡梦中的 PICO');
sound.play('sleep');
announceMilestones(levelBefore, null);
}
saveState();
}
/* --------------------------------------------------------------------------
The star game
-------------------------------------------------------------------------- */
function resetGameUi() {
abortGame();
game.applied = false;
game.score = 0;
game.wrappingUp = false;
dom.gameIntro.hidden = false;
dom.gamePlay.hidden = true;
dom.gameResult.hidden = true;
dom.gameScore.textContent = '0';
dom.gameTime.textContent = '15.0';
dom.gameStageTip.textContent = '光在动,星星要出来了。';
clearStars();
dom.startGameBtn.disabled = false;
}
function openGameDialog() {
if (state.asleep) return;
ensureDaily();
resetGameUi();
openDialog(dom.gameDialog);
sound.play('tap');
window.setTimeout(() => dom.startGameBtn.focus(), 80);
}
function clearGameTimers() {
if (game.spawnTimer) window.clearInterval(game.spawnTimer);
if (game.tickTimer) window.clearInterval(game.tickTimer);
if (game.finishTimer) window.clearTimeout(game.finishTimer);
game.spawnTimer = null;
game.tickTimer = null;
game.finishTimer = null;
}
function clearStars() {
$$('.game-star', dom.gameStage).forEach((star) => star.remove());
}
function abortGame() {
clearGameTimers();
game.active = false;
game.wrappingUp = false;
clearStars();
}
function startGame() {
if (game.active) return;
ensureDaily();
clearStars();
game.active = true;
game.applied = false;
game.wrappingUp = false;
game.score = 0;
game.endsAt = performance.now() + 15000;
dom.gameIntro.hidden = true;
dom.gameResult.hidden = true;
dom.gamePlay.hidden = false;
dom.gameScore.textContent = '0';
dom.gameTime.textContent = '15.0';
dom.gameStageTip.textContent = '在光里点一下,星星就属于你啦。';
updateGameHud();
sound.play('tap');
game.spawnTimer = window.setInterval(spawnStar, 590);
game.tickTimer = window.setInterval(() => {
if (!game.active) return;
updateGameHud();
if (performance.now() >= game.endsAt) finishGame();
}, 80);
window.setTimeout(spawnStar, 160);
}
function updateGameHud() {
const remaining = Math.max(0, game.endsAt - performance.now());
dom.gameScore.textContent = String(game.score);
dom.gameTime.textContent = (remaining / 1000).toFixed(1);
}
function spawnStar() {
if (!game.active || game.wrappingUp) return;
if ($$('.game-star', dom.gameStage).length >= 6) return;
const star = document.createElement('button');
star.type = 'button';
star.className = 'game-star';
star.setAttribute('aria-label', '接住一颗星星');
star.style.left = `${randomInt(11, 89)}%`;
star.style.top = `${randomInt(14, 79)}%`;
star.innerHTML = '';
const lifetime = randomInt(1250, 1680);
star.addEventListener('click', () => catchStar(star));
dom.gameStage.appendChild(star);
window.setTimeout(() => {
if (!star.isConnected || star.classList.contains('is-caught')) return;
star.classList.add('is-gone');
window.setTimeout(() => star.remove(), 300);
}, lifetime);
}
function catchStar(star) {
if (!game.active || game.wrappingUp || star.classList.contains('is-caught')) return;
star.classList.add('is-caught');
game.score += 1;
dom.gameScore.textContent = String(game.score);
sound.play('star');
window.setTimeout(() => star.remove(), 360);
if (game.score >= 5) {
game.wrappingUp = true;
clearGameTimers();
game.finishTimer = window.setTimeout(finishGame, 520);
}
}
function finishGame() {
if (!game.active || game.applied) return;
game.active = false;
game.applied = true;
game.wrappingUp = true;
clearGameTimers();
clearStars();
const score = Math.min(game.score, 5);
applyGameRewards(score);
}
function applyGameRewards(score) {
ensureDaily();
const levelBefore = state.level;
const happyBefore = state.stats.happiness;
const coinGain = 1 + score;
const xpGain = 5 + score * 2;
state.stats.happiness = clamp(state.stats.happiness + 8 + score * 2, 0, 100);
state.coins += coinGain;
state.daily.care = (Number(state.daily.care) || 0) + 1;
grantXp(xpGain);
const questResult = addQuestProgress('plays', 1);
const levels = state.level - levelBefore;
const actualHappiness = Math.max(0, Math.round(state.stats.happiness - happyBefore));
renderAll();
saveState();
dom.gamePlay.hidden = true;
dom.gameIntro.hidden = true;
dom.gameResult.hidden = false;
const resultTitle = score >= 5
? '五颗星星,全都接住啦!'
: score > 0
? `接住 ${score} 颗星星!`
: '星星躲进云里去了…';
const resultCopy = score >= 5
? 'PICO 开心得在草地上滚了一圈,星星装满了它的玻璃瓶。'
: score >= 3
? '星星落进 PICO 的口袋里,它正低头偷偷地笑。'
: score >= 1
? '抓到几颗亮亮的星星,PICO 的尾巴已经翘起来啦。'
: '星星有点害羞。不过和你一起跑一跑,PICO 就已经很开心了。';
dom.resultTitle.textContent = resultTitle;
dom.resultCopy.textContent = resultCopy;
const offsets = ['-5px', '1px', '-2px', '4px', '-1px'];
const rotations = ['-8deg', '4deg', '-3deg', '7deg', '0deg'];
dom.resultStars.innerHTML = Array.from({ length: 5 }, (_, index) => {
const earned = index < score;
return `
`;
}).join('');
const happinessText = actualHappiness > 0 ? `心情 +${actualHappiness}` : '心情满格';
dom.resultRewards.innerHTML = `
${happinessText}
成长 +${xpGain}
星星币 +${coinGain}`;
const extras = [];
if (questResult) {
extras.push(`今日小约定完成 · 额外 +${questResult.definition.reward} 颗星星币`);
}
if (levels > 0) {
extras.push(`PICO 升到了 LV.${pad2(state.level)} · 额外 +${levels * LEVEL_COIN_REWARD} 颗星星币`);
}
dom.resultExtra.textContent = extras.join(' · ');
if (score >= 5) {
setSpeech(GAME_SPEECH.five);
} else if (score >= 2) {
setSpeech(GAME_SPEECH.some);
} else {
setSpeech(GAME_SPEECH.few);
}
sound.play('success');
window.setTimeout(() => dom.claimGameBtn.focus(), 90);
}
/* --------------------------------------------------------------------------
Wardrobe
-------------------------------------------------------------------------- */
function openWardrobe() {
setShopMessage('配饰是永久的。选一个喜欢的吧。');
renderShop();
openDialog(dom.wardrobeDialog);
sound.play('tap');
}
function handleShopSelect(id) {
const item = ACCESSORIES.find((entry) => entry.id === id);
if (!item) return;
const owned = state.owned.includes(id);
let focusId = id;
if (!owned) {
if (state.coins < item.price) {
setShopMessage(`还差 ${item.price - state.coins} 颗星星币。去陪 PICO 待一会儿,说不定就有了。`, 'warm');
const card = $(`.shop-item[data-item="${id}"]`);
if (card) {
card.classList.add('is-shaking');
window.setTimeout(() => card.classList.remove('is-shaking'), 380);
}
sound.play('tap');
return;
}
state.coins -= item.price;
state.owned.push(id);
state.equipped = id;
setShopMessage(`${item.name}已经放进衣橱,也帮 PICO 戴好啦。`, 'good');
sound.play('success');
} else if (state.equipped === id) {
state.equipped = null;
setShopMessage(`${item.name}摘下来啦。这样清清爽爽的也很好看。`, 'good');
sound.play('tap');
} else {
state.equipped = id;
setShopMessage(`换上了${item.name}。PICO 正在偷偷照镜子。`, 'good');
sound.play('quest');
}
saveState();
renderAll();
window.requestAnimationFrame(() => {
$(`[data-shop-id="${focusId}"]`)?.focus();
});
}
function removeAccessory() {
if (!state.equipped) {
setShopMessage('它现在就是最自然的样子。', 'good');
sound.play('tap');
return;
}
state.equipped = null;
setShopMessage('换回自然的样子啦。毛茸茸的,也很好看。', 'good');
renderAll();
saveState();
sound.play('tap');
}
/* --------------------------------------------------------------------------
Passive time & the pet's little life
-------------------------------------------------------------------------- */
function tick() {
const now = Date.now();
if (document.hidden) {
lastTick = now;
return;
}
const delta = clamp((now - lastTick) / 1000, 0, 8);
lastTick = now;
if (state.asleep) {
state.stats.energy = clamp(state.stats.energy + delta * 0.16, 0, 100);
state.stats.hunger = clamp(state.stats.hunger - delta * 0.0015, 0, 100);
} else {
state.stats.hunger = clamp(state.stats.hunger - delta * RATES.hunger, 0, 100);
state.stats.happiness = clamp(state.stats.happiness - delta * RATES.happiness, 0, 100);
state.stats.energy = clamp(state.stats.energy - delta * RATES.energy, 0, 100);
state.stats.cleanliness = clamp(state.stats.cleanliness - delta * RATES.cleanliness, 0, 100);
}
renderStats();
updateMood();
if (now - lastClockRefresh > 30000) {
refreshClock();
if (ensureDaily()) {
renderAll();
saveState();
if (!anyDialogOpen()) {
showToast('新的一天开始啦', '今天的小约定已经刷新,PICO 也伸了个懒腰。', 'level');
}
setSpeech('新的一天。窗外的光换了颜色,我也换了一个好心情。');
}
lastDailyCheck = now;
}
if (now - lastSave > 6000) {
saveState();
}
if (
!state.asleep &&
!anyDialogOpen() &&
now - lastSpeechAt > 28000
) {
setSpeech(getContextSpeech());
}
}
/* --------------------------------------------------------------------------
Init & events
-------------------------------------------------------------------------- */
function initialize() {
const loaded = loadState();
state = loaded.state;
const isNew = loaded.isNew;
ensureDaily();
const rawAwaySeconds = isNew ? 0 : Math.max(0, (Date.now() - state.lastSeen) / 1000);
const awaySeconds = applyAwayTime(rawAwaySeconds, isNew);
refreshClock(true);
bindEvents();
renderAll();
lastTick = Date.now();
lastClockRefresh = Date.now();
lastDailyCheck = Date.now();
if (isNew) {
setSpeech('你好呀,我是 PICO。今天也一起慢慢过吧。');
} else if (state.asleep) {
setSpeech('呼……你回来啦。我还在梦里,再等我一会儿。', '睡梦中的 PICO');
} else if (rawAwaySeconds > 600) {
const urgentNeed = state.stats.hunger < 36 || state.stats.energy < 30 || state.stats.cleanliness < 32;
setSpeech(urgentNeed ? getContextSpeech() : '你回来啦。我刚刚把窗边的云都数了一遍。');
window.setTimeout(() => {
showToast('你回来啦', `PICO 等了你 ${formatAway(rawAwaySeconds)},快去摸摸它吧。`, 'heart');
}, 900);
} else {
setSpeech(getContextSpeech());
}
// Keep the away state out of the welcome copy on the next load.
if (awaySeconds > 0 || isNew) {
saveState();
} else {
lastSave = Date.now();
}
window.setInterval(tick, 1000);
document.addEventListener('visibilitychange', () => {
if (document.hidden) {
saveState();
} else {
lastTick = Date.now();
refreshClock();
renderAll();
}
});
window.addEventListener('pagehide', saveState);
window.addEventListener('beforeunload', saveState);
}
function bindEvents() {
dom.petButton.addEventListener('click', petThePet);
dom.petHint.addEventListener('click', petThePet);
dom.actionFeed.addEventListener('click', openFoodDialog);
dom.actionPlay.addEventListener('click', openGameDialog);
dom.actionClean.addEventListener('click', cleanPet);
dom.actionSleep.addEventListener('click', toggleSleep);
dom.coinBtn.addEventListener('click', openWardrobe);
dom.wardrobeBtn.addEventListener('click', openWardrobe);
dom.soundBtn.addEventListener('click', () => {
state.sound = !state.sound;
renderSound();
if (state.sound) {
sound.ensure();
sound.play('tap');
}
saveState();
});
dom.foodGrid.addEventListener('click', (event) => {
const card = event.target.closest('[data-food]');
if (!card) return;
feedPet(card.dataset.food);
});
dom.startGameBtn.addEventListener('click', startGame);
dom.claimGameBtn.addEventListener('click', () => {
closeDialog(dom.gameDialog);
animatePet('is-petting', 500);
spawnFx('heart', 3);
});
dom.shopGrid.addEventListener('click', (event) => {
const button = event.target.closest('[data-shop-id]');
if (!button) return;
handleShopSelect(button.dataset.shopId);
});
dom.removeAccessoryBtn.addEventListener('click', removeAccessory);
for (const dialog of $$('.pico-dialog')) {
dialog.addEventListener('click', (event) => {
if (event.target === dialog) closeDialog(dialog);
});
dialog.addEventListener('close', () => {
syncBodyLock();
if (dialog === dom.gameDialog) abortGame();
});
for (const closeButton of $$('[data-close-dialog]', dialog)) {
closeButton.addEventListener('click', () => closeDialog(dialog));
}
}
dom.gameStage.addEventListener('click', (event) => {
if (!event.target.closest('.game-star')) return;
sound.ensure();
});
}
initialize();
})();