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.
1797 lines
59 KiB
JavaScript
1797 lines
59 KiB
JavaScript
(() => {
|
||
'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();
|
||
})(); |