(() => { '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}
  • `; }).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 `

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