Files
yangyixuan 71fa43ac7b feat: add PICO virtual pet
A dependency-free browser companion with live needs, a star-catching
mini game, daily goals, collectible accessories, local saving,
day/night scenery and a responsive layout.
2026-11-04 18:51:30 +08:00

1797 lines
59 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
(() => {
'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 ? '<span aria-hidden="true">✓</span>' : '<svg class="icon" viewBox="0 0 24 24" aria-hidden="true"><use href="#i-star"></use></svg>';
const stateLabel = claimed ? ',奖励已领取' : done ? ',刚刚完成' : '';
const rowClass = [
'quest',
quest.className,
done ? 'is-done' : '',
claimed ? 'is-claimed' : ''
].filter(Boolean).join(' ');
return `
<li class="${rowClass}" aria-label="${quest.title},${current}/${quest.target}${stateLabel},完成可得 ${quest.reward} 颗星星币">
<span class="quest-icon" aria-hidden="true">
<svg class="icon" viewBox="0 0 24 24"><use href="#${quest.icon}"></use></svg>
</span>
<div class="quest-main">
<div class="quest-title">
<span>${quest.title}</span>
<span class="quest-count">${current} / ${quest.target}</span>
</div>
<div class="quest-track" aria-hidden="true"><i style="width:${progress}%"></i></div>
</div>
<span class="quest-reward" title="${claimed ? '奖励已领取' : '完成奖励'}" aria-hidden="true">
${rewardMark}
<span>${claimed ? `+${quest.reward}` : `+${quest.reward}`}</span>
</span>
</li>`;
}).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
? '<span class="shop-price is-owned">已拥有</span>'
: `<span class="shop-price"><svg class="icon" viewBox="0 0 24 24" aria-hidden="true"><use href="#i-star"></use></svg>${item.price}</span>`;
return `
<article class="shop-item shop-item--${item.id}${equipped ? ' is-equipped' : ''}" data-item="${item.id}" aria-label="${item.name}${equipped ? ',当前佩戴中' : ''}">
<div class="shop-art" aria-hidden="true">
${accessoryArt(item.icon)}
${equipped ? '<span class="equip-check"><svg class="icon" viewBox="0 0 24 24"><use href="#i-check"></use></svg></span>' : ''}
</div>
<div class="shop-info">
<h3>${item.name}</h3>
<p>${item.description}</p>
<div class="shop-actions">
${priceLabel}
<button class="shop-button${equipped ? ' is-equip' : ''}" type="button" data-shop-id="${item.id}" aria-label="${buttonLabel}${item.name}">
${buttonLabel}
</button>
</div>
</div>
</article>`;
}).join('');
}
function accessoryArt(kind) {
if (kind === 'flower') {
return `
<svg viewBox="0 0 100 100">
<path d="M50 86c-1-25 0-43 2-55" fill="none" stroke="#425a43" stroke-width="3.2" stroke-linecap="round"/>
<path d="M51 67C37 68 26 59 26 47c13-2 24 5 26 18Z" fill="#799669" stroke="#425a43" stroke-width="2.2"/>
<path d="M52 58c12-2 19-12 17-22-11 1-18 9-19 19Z" fill="#9bb486" stroke="#425a43" stroke-width="2.2"/>
<g transform="translate(52 38) scale(2.25)"><use href="#daisy"></use></g>
<g transform="translate(29 49) scale(1.18) rotate(-12)"><use href="#daisy"></use></g>
<g transform="translate(74 44) scale(1.03) rotate(15)"><use href="#daisy"></use></g>
<circle cx="75" cy="74" r="3" fill="#e7b99c"/>
<circle cx="27" cy="72" r="2.2" fill="#c7d4b0"/>
</svg>`;
}
if (kind === 'scarf') {
return `
<svg viewBox="0 0 100 100">
<path d="M20 38c16 14 45 16 61 1l6 19c-20 17-54 16-73-1Z" fill="#d9826a" stroke="#3b4d3c" stroke-width="2.5" stroke-linejoin="round"/>
<path d="M28 50c12 9 30 12 46 5" fill="none" stroke="#f1b29c" stroke-width="2.4" stroke-linecap="round" opacity=".8"/>
<path d="M29 61c-7 9-8 21-1 27 8-3 13-14 12-24Z" fill="#d9826a" stroke="#3b4d3c" stroke-width="2.5" stroke-linejoin="round"/>
<path d="M42 64c-1 9 2 18 7 23 6-6 7-17 3-25Z" fill="#e69a80" stroke="#3b4d3c" stroke-width="2.5" stroke-linejoin="round"/>
<circle cx="47" cy="46" r="2.4" fill="#f5d7c7"/>
<circle cx="69" cy="43" r="2" fill="#f5d7c7"/>
<circle cx="35" cy="76" r="2" fill="#f5d7c7"/>
</svg>`;
}
return `
<svg viewBox="0 0 100 100">
<path d="M20 56c0-22 16-35 32-35 17 0 29 13 29 33 0 8-13 12-31 12-19 0-30-4-30-10Z" fill="#a66654" stroke="#3b4d3c" stroke-width="2.5" stroke-linejoin="round"/>
<path d="M27 49c9-11 27-15 42-13 8 1 14 4 19 8" fill="none" stroke="#c88a76" stroke-width="3" stroke-linecap="round"/>
<path d="M51 23c-2-6 1-11 6-12" fill="none" stroke="#3b4d3c" stroke-width="2.4" stroke-linecap="round"/>
<ellipse cx="59" cy="10" rx="6" ry="3.3" transform="rotate(-22 59 10)" fill="#809b6b" stroke="#3b4d3c" stroke-width="1.8"/>
<circle cx="34" cy="44" r="2.2" fill="#f0c6b1"/>
<circle cx="68" cy="39" r="1.6" fill="#f0c6b1"/>
</svg>`;
}
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 = `<svg class="icon" viewBox="0 0 24 24" aria-hidden="true"><use href="#${kind === 'level' ? 'i-sparkle' : 'i-star'}"></use></svg>`;
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 = '<svg viewBox="0 0 24 24" aria-hidden="true"><use href="#i-star"></use></svg>';
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 `<span class="${earned ? 'is-earned' : 'is-empty'}" style="--offset:${offsets[index]};--rotate:${rotations[index]};--delay:${index * 0.07}s">
<svg class="icon" viewBox="0 0 24 24" aria-hidden="true"><use href="#i-star"></use></svg>
</span>`;
}).join('');
const happinessText = actualHappiness > 0 ? `心情 +${actualHappiness}` : '心情满格';
dom.resultRewards.innerHTML = `
<span><svg class="icon" viewBox="0 0 24 24" aria-hidden="true"><use href="#i-heart"></use></svg>${happinessText}</span>
<span><svg class="icon" viewBox="0 0 24 24" aria-hidden="true"><use href="#i-sparkle"></use></svg>成长 +${xpGain}</span>
<span><svg class="icon" viewBox="0 0 24 24" aria-hidden="true"><use href="#i-star"></use></svg>星星币 +${coinGain}</span>`;
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();
})();