commit 71fa43ac7bdbeaaf93b33c741134f1c7e0790a76 Author: yangyixuan Date: Wed Nov 4 18:51:30 2026 +0800 feat: add PICO virtual pet A dependency-free browser companion with live needs, a star-catching mini game, daily goals, collectible accessories, local saving, day/night scenery and a responsive layout. diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..1dd3bb6 --- /dev/null +++ b/.gitignore @@ -0,0 +1,8 @@ +# Editor & OS +.DS_Store +Thumbs.db +.vscode/ +.idea/ + +# Local tooling +node_modules/ diff --git a/README.md b/README.md new file mode 100644 index 0000000..799b1d9 --- /dev/null +++ b/README.md @@ -0,0 +1,49 @@ +# pico · 一只住在云里的电子宠物 + +一个不用安装依赖、双击就能玩的网页电子宠物。 + +PICO 住在黄昏色的云端花园里。摸摸它、喂饱它、陪它追星星、哄它睡觉,它会记住你们一起过的小日子。 + +![PICO 的桌面端小屋](preview.png) + +## 打开方式 + +直接双击 `index.html` 即可。没有构建步骤,也不需要联网。 + +如果浏览器对本地文件的存储有限制,可以在本目录下运行一个静态服务器: + +```powershell +python -m http.server 8000 +``` + +然后打开 `http://localhost:8000`。 + +## 可以做什么 + +- **照料状态**:饱食、心情、精力、清洁会随真实时间缓慢变化。离开一阵子再回来,PICO 也在过自己的时间。 +- **准备一餐**:草莓布丁、星星饼干、云朵饭团,每种食物会影响不同的状态。 +- **追星星小游戏**:15 秒内接住五颗飘过草地的星星,换取心情、成长值和星星币。 +- **小衣橱**:用星星币兑换晨露花环、莓果围巾和橡果贝雷帽;配饰是永久的,可随时更换。 +- **今天的小约定**:每天刷新的三个小目标,完成后自动领取奖励。 +- **睡觉与成长**:睡觉会恢复精力;照顾和游戏会积累成长值,升级还会收到额外星星币。 +- **会变化的天空**:界面会跟随本地时间切换清晨、白天、黄昏和夜晚;不同心情下,PICO 的表情和说的话也会变化。 +- **安静的陪伴**:合成的小音效、可关闭;所有记录保存在本地浏览器中。 + +## 小提示 + +- 点击 PICO 或右下角的「摸摸它」都可以互动。 +- 睡觉时,喂食、游戏和洗澡会暂时收起;按「叫醒 PICO」即可继续。 +- 存档键名为 `pico.pet.v1`。清除浏览器中此页面的站点数据,就会从全新的 PICO 开始。 + +## 文件 + +```text +pico-pet/ +├─ index.html 页面结构与内联 SVG 插画 +├─ styles.css 视觉、动画与响应式布局 +├─ app.js 状态、存档、小游戏与交互 +├─ preview.png 桌面端预览 +└─ preview-mobile.png 手机端整页预览 +``` + +不依赖任何外部字体、图片或 JavaScript 库。所有场景与角色均由内联 SVG 和 CSS 绘制。 \ No newline at end of file diff --git a/app.js b/app.js new file mode 100644 index 0000000..51531ce --- /dev/null +++ b/app.js @@ -0,0 +1,1797 @@ +(() => { + '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(); +})(); \ No newline at end of file diff --git a/index.html b/index.html new file mode 100644 index 0000000..3440473 --- /dev/null +++ b/index.html @@ -0,0 +1,753 @@ + + + + + + + + pico · 一只住在云里的电子宠物 + + + + + + + + +
    +
    +
    + +
    + pico + 住在云里的电子宠物 +
    +
    + + + +
    + + +
    +
    + +
    +
    + + + + + + +
    +
    +
    + + PICO'S GARDEN +
    +
    + + 11月4日 · 黄昏 +
    +
    + +
    + PICO 说 +

    今天也要一起发呆吗?

    +
    + +
    + + + +
    + +
    + +
    +
    +

    PICO

    + LV.01 +
    +

    相遇第 1 天 · 心情很好

    +
    +
    + + +
    + + + + +
    + + +
    +
    + + +
    +
    +
    +

    PICO'S LITTLE PANTRY

    +

    今天想吃点什么?

    +
    + +
    + +
    + + + + + +
    + +

    + + 无糖,无添加,只有一点点魔法。 +

    +
    +
    + + +
    +
    +
    +

    A TINY GAME FOR TWO

    +

    和 PICO 一起,追星星

    +
    + +
    + +
    + +

    15 秒,接住五颗小星星

    +

    星星会从草地的光里冒出来。点中它们,把亮晶晶的心情带回去给 PICO。

    +
    + 心情 + 成长值 + 星星币 +
    + +
    + + + + +
    +
    + + +
    +
    +
    +

    PICO'S TINY WARDROBE

    +

    给小小的它,挑一点点可爱

    +
    + +
    + +
    +
    + 口袋里的星星币 + + + 18 + +
    +

    照顾 PICO、完成今日小约定,就能收集更多星星。

    +
    + +
    + +
    + +

    配饰是永久的。选一个喜欢的吧。

    +
    +
    +
    + +
    + + + + + \ No newline at end of file diff --git a/preview-mobile.png b/preview-mobile.png new file mode 100644 index 0000000..926fe21 Binary files /dev/null and b/preview-mobile.png differ diff --git a/preview.png b/preview.png new file mode 100644 index 0000000..4551de9 Binary files /dev/null and b/preview.png differ diff --git a/styles.css b/styles.css new file mode 100644 index 0000000..c1a94ac --- /dev/null +++ b/styles.css @@ -0,0 +1,4041 @@ +/* --------------------------------------------- + pico · a tiny digital companion + --------------------------------------------- */ + +:root { + --bg: #ece7dc; + --surface: #fbf8f1; + --surface-strong: #fffdf8; + --surface-soft: #f3efe4; + --ink: #293b2f; + --ink-soft: #667368; + --ink-faint: #929a90; + --line: rgba(44, 64, 49, .11); + --line-strong: rgba(44, 64, 49, .19); + --moss: #708a68; + --moss-deep: #385240; + --sage: #cbd9bd; + --sage-soft: #e8eddb; + --peach: #e4a184; + --terracotta: #d17a5c; + --butter: #edcc80; + --blue-soft: #d7e3df; + --radius: 26px; + --shadow: 0 24px 60px rgba(48, 62, 46, .09); + --font: "Segoe UI Variable Text", "Aptos", "Segoe UI", "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei UI", "Microsoft YaHei", sans-serif; + --serif: "Iowan Old Style", "Palatino Linotype", "Book Antiqua", Georgia, "Songti SC", serif; +} + +* { + box-sizing: border-box; +} + +html { + color-scheme: light; + scrollbar-color: rgba(69, 86, 68, .28) transparent; +} + +body { + margin: 0; + min-width: 300px; + min-height: 100vh; + min-height: 100dvh; + overflow-x: hidden; + background: + radial-gradient(ellipse at 0% -10%, rgba(203, 217, 189, .7), transparent 42%), + radial-gradient(ellipse at 102% 102%, rgba(231, 205, 182, .48), transparent 46%), + var(--bg); + color: var(--ink); + font-family: var(--font); + -webkit-font-smoothing: antialiased; + text-rendering: optimizeLegibility; +} + +body.modal-open { + overflow: hidden; +} + +button { + border: 0; + color: inherit; + font: inherit; + cursor: pointer; + -webkit-tap-highlight-color: transparent; +} + +button:disabled { + cursor: not-allowed; +} + +h1, h2, h3, p { + margin-top: 0; +} + +::selection { + background: rgba(209, 122, 92, .23); +} + +:focus-visible { + outline: 2px solid #587156; + outline-offset: 3px; +} + +[hidden] { + display: none !important; +} + +.svg-defs { + position: absolute; + width: 0; + height: 0; + overflow: hidden; +} + +.icon { + width: 18px; + height: 18px; + flex: none; + fill: none; + stroke: currentColor; + stroke-width: 1.8; + stroke-linecap: round; + stroke-linejoin: round; +} + +.paper-grain { + position: fixed; + z-index: 80; + inset: 0; + pointer-events: none; + opacity: .035; + background-image: url("data:image/svg+xml,%3Csvg viewBox='0 0 180 180' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='.88' numOctaves='4' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)' opacity='.7'/%3E%3C/svg%3E"); + mix-blend-mode: multiply; +} + +.app { + width: min(1240px, calc(100% - 48px)); + margin: 0 auto; + padding: 20px 0; +} + +/* --------------------------------------------- + Header + --------------------------------------------- */ + +.topbar { + display: flex; + align-items: center; + justify-content: space-between; + gap: 20px; + padding-bottom: 16px; +} + +.brand { + display: flex; + align-items: center; + gap: 11px; + min-width: max-content; +} + +.brand-mark { + display: grid; + place-items: center; + width: 44px; + height: 44px; + border: 1px solid rgba(53, 77, 57, .13); + border-radius: 15px; + background: + radial-gradient(circle at 30% 20%, rgba(255, 255, 255, .9), transparent 45%), + linear-gradient(145deg, #edf0dc, #dce7ce); + box-shadow: inset 0 1px 0 rgba(255, 255, 255, .9), 0 6px 16px rgba(56, 74, 52, .06); +} + +.brand-mark svg { + width: 35px; + height: 35px; +} + +.brand-copy { + display: flex; + flex-direction: column; + gap: 1px; +} + +.brand-name { + font-family: var(--serif); + font-size: 31px; + font-style: italic; + font-weight: 500; + letter-spacing: -.045em; + line-height: .85; +} + +.brand-sub { + color: var(--ink-soft); + font-size: 9.5px; + letter-spacing: .12em; +} + +.header-note { + display: flex; + align-items: center; + gap: 12px; + margin: 0; + color: #8a9389; + font-size: 8.5px; + letter-spacing: .19em; + white-space: nowrap; +} + +.header-note span { + width: 27px; + height: 1px; + background: rgba(63, 78, 59, .24); +} + +.header-actions { + display: flex; + align-items: center; + gap: 8px; +} + +.coin-menu { + display: flex; + align-items: center; + gap: 6px; + height: 41px; + padding: 0 13px 0 8px; + border: 1px solid var(--line); + border-radius: 999px; + background: rgba(255, 253, 247, .6); + box-shadow: inset 0 1px 0 rgba(255, 255, 255, .7); + transition: background .2s ease, border-color .2s ease, transform .2s ease; +} + +.coin-menu:hover { + transform: translateY(-1px); + border-color: rgba(62, 83, 64, .22); + background: rgba(255, 253, 247, .9); +} + +.coin-star { + display: grid; + place-items: center; + width: 25px; + height: 25px; + border-radius: 50%; + color: #a87b32; + background: #f6e7b8; +} + +.coin-star .icon { + width: 14px; + height: 14px; + fill: currentColor; + stroke-width: 1.2; +} + +.coin-value { + font-family: var(--serif); + font-size: 19px; + font-weight: 600; + letter-spacing: -.03em; +} + +.coin-unit { + color: var(--ink-soft); + font-size: 10px; +} + +.icon-button { + display: grid; + place-items: center; + width: 41px; + height: 41px; + border: 1px solid var(--line); + border-radius: 50%; + background: rgba(255, 253, 247, .6); + box-shadow: inset 0 1px 0 rgba(255, 255, 255, .7); + transition: background .2s ease, transform .2s ease; +} + +.icon-button:hover { + transform: translateY(-1px); + background: rgba(255, 253, 247, .92); +} + +.icon-button .icon { + width: 18px; + height: 18px; +} + +.sound-off-icon { + display: none; +} + +#soundBtn.is-muted .sound-on-icon { + display: none; +} + +#soundBtn.is-muted .sound-off-icon { + display: block; + color: var(--ink-faint); +} + +/* --------------------------------------------- + Layout + --------------------------------------------- */ + +.layout { + display: grid; + grid-template-columns: minmax(0, 1.58fr) minmax(375px, .92fr); + gap: 18px; + height: calc(100vh - 108px); + height: calc(100dvh - 108px); + min-height: 715px; + max-height: 840px; +} + +/* --------------------------------------------- + Garden habitat + --------------------------------------------- */ + +.habitat { + --sky-top: #cbe3dc; + --sky-middle: #dfead8; + --sky-bottom: #f3e7ce; + --hill-back: #b8cdb0; + --hill-middle: #96b28f; + --hill-front: #779576; + --hill-front-deep: #607f63; + --cloud: #fffbf0; + --scene-ink: #2f4938; + position: relative; + min-width: 0; + min-height: 0; + overflow: hidden; + isolation: isolate; + border: 1px solid rgba(255, 255, 255, .45); + border-radius: 32px; + background: linear-gradient(178deg, var(--sky-top) 0%, var(--sky-middle) 48%, var(--sky-bottom) 100%); + box-shadow: var(--shadow), inset 0 1px 0 rgba(255, 255, 255, .48); + transition: filter .6s ease; +} + +.habitat[data-phase="morning"] { + --sky-top: #d2e7e2; + --sky-middle: #e7edda; + --sky-bottom: #f8e7ce; + --hill-back: #c3d5b9; + --hill-middle: #a0b997; + --hill-front: #7e9a7a; + --hill-front-deep: #668267; +} + +.habitat[data-phase="day"] { + --sky-top: #cbe3dc; + --sky-middle: #dfead8; + --sky-bottom: #f3e7ce; +} + +.habitat[data-phase="dusk"] { + --sky-top: #ddcfcf; + --sky-middle: #edd7bd; + --sky-bottom: #f5ddc1; + --hill-back: #c2bba0; + --hill-middle: #9eaa88; + --hill-front: #788f70; + --hill-front-deep: #627b61; + --cloud: #fff3dc; +} + +.habitat[data-phase="night"] { + --sky-top: #22383f; + --sky-middle: #344d4c; + --sky-bottom: #55645a; + --hill-back: #536d5d; + --hill-middle: #3e5b4e; + --hill-front: #304b41; + --hill-front-deep: #263e38; + --cloud: #dce5dc; + --scene-ink: #f5f1dd; + background: linear-gradient(178deg, var(--sky-top) 0%, var(--sky-middle) 50%, var(--sky-bottom) 100%); +} + +.sky, +.hills { + position: absolute; + inset: 0; +} + +.sky { + z-index: 0; + pointer-events: none; +} + +.hills { + z-index: 1; + pointer-events: none; +} + +.habitat-grain { + position: absolute; + z-index: 10; + inset: 0; + pointer-events: none; + opacity: .06; + mix-blend-mode: soft-light; + background-image: url("data:image/svg+xml,%3Csvg viewBox='0 0 160 160' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='.94' numOctaves='3' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)' opacity='.8'/%3E%3C/svg%3E"); +} + +.celestial { + position: absolute; + border-radius: 50%; + transition: opacity .9s ease, transform .9s ease; +} + +.sun { + top: 12.5%; + right: 10%; + width: clamp(96px, 12vw, 148px); + aspect-ratio: 1; + background: radial-gradient(circle at 39% 34%, #fffce9 0 17%, #f6dfa6 56%, #eec97e 100%); + box-shadow: + 0 0 0 18px rgba(247, 223, 166, .07), + 0 0 70px 34px rgba(246, 214, 143, .24); + opacity: .96; + animation: sun-breathe 9s ease-in-out infinite; +} + +.sun::after { + content: ""; + position: absolute; + inset: -8%; + border: 1px solid rgba(255, 247, 218, .32); + border-radius: 50%; +} + +.moon { + top: 13%; + right: 11.5%; + width: clamp(58px, 7.2vw, 82px); + aspect-ratio: 1; + background: #f5eccb; + filter: drop-shadow(0 0 28px rgba(246, 234, 192, .42)); + -webkit-mask: radial-gradient(circle at 68% 28%, transparent 0 43%, #000 44%); + mask: radial-gradient(circle at 68% 28%, transparent 0 43%, #000 44%); + opacity: 0; +} + +.habitat[data-phase="night"] .sun { + visibility: hidden; + opacity: 0; + transform: translateY(30px); + animation: none; +} + +.habitat[data-phase="night"] .moon { + opacity: .95; +} + +.stars { + position: absolute; + inset: 0; + opacity: 0; + transition: opacity 1.2s ease; +} + +.habitat[data-phase="night"] .stars { + opacity: 1; +} + +.habitat[data-phase="dusk"] .stars { + opacity: .2; +} + +.stars i { + position: absolute; + left: var(--x); + top: var(--y); + width: 3px; + height: 3px; + border-radius: 50%; + background: #f8f0d3; + box-shadow: 0 0 8px rgba(249, 239, 202, .8); + animation: star-twinkle 3.2s ease-in-out infinite; + animation-delay: var(--d); +} + +.stars i:nth-child(3n) { + width: 2px; + height: 2px; + opacity: .6; +} + +.stars i:nth-child(4n) { + width: 4px; + height: 4px; +} + +.cloud { + position: absolute; + height: 36px; + border-radius: 999px; + background: var(--cloud); + opacity: .55; + filter: blur(.15px); + transition: opacity 1s ease; + animation: cloud-drift 18s ease-in-out infinite alternate; +} + +.cloud::before, +.cloud::after { + content: ""; + position: absolute; + border-radius: 50%; + background: inherit; +} + +.cloud::before { + width: 48%; + aspect-ratio: 1; + left: 14%; + bottom: 4px; +} + +.cloud::after { + width: 62%; + aspect-ratio: 1; + right: 9%; + bottom: 1px; +} + +.cloud--one { + top: 22%; + left: -4%; + width: clamp(115px, 15vw, 180px); +} + +.cloud--two { + top: 34%; + right: 2%; + width: clamp(90px, 11vw, 135px); + height: 29px; + opacity: .38; + animation-delay: -7s; + animation-duration: 23s; +} + +.cloud--three { + top: 45%; + left: 25%; + width: clamp(62px, 8vw, 100px); + height: 22px; + opacity: .28; + animation-delay: -11s; + animation-duration: 27s; +} + +.habitat[data-phase="night"] .cloud { + opacity: .075; +} + +.habitat[data-phase="dusk"] .cloud { + opacity: .4; +} + +.birds { + position: absolute; + left: 41%; + top: 17%; + display: flex; + gap: 22px; + opacity: .48; + transition: opacity .8s ease; +} + +.birds i { + width: 15px; + height: 6px; + border-top: 1.4px solid rgba(59, 78, 62, .8); + border-radius: 50%; + transform: rotate(-8deg); +} + +.birds i:nth-child(2) { + transform: translateY(-11px) scale(.75) rotate(-8deg); +} + +.habitat[data-phase="night"] .birds { + opacity: 0; +} + +.motes { + position: absolute; + inset: 0; +} + +.motes i { + position: absolute; + left: var(--mx); + top: var(--my); + width: 3px; + height: 3px; + border-radius: 50%; + background: rgba(255, 251, 229, .75); + box-shadow: 0 0 7px rgba(255, 248, 217, .44); + animation: mote-drift 8s ease-in-out infinite alternate; + animation-delay: var(--md); +} + +.habitat[data-phase="night"] .motes { + opacity: .1; +} + +.hill { + position: absolute; + border-radius: 55% 55% 0 0 / 100% 100% 0 0; + transition: background 1.1s ease; +} + +.hill--back { + left: -19%; + bottom: 24%; + width: 79%; + height: 48%; + background: var(--hill-back); + opacity: .56; + transform: rotate(-2deg); +} + +.hill--middle { + right: -22%; + bottom: 17%; + width: 86%; + height: 44%; + background: var(--hill-middle); + opacity: .58; + transform: rotate(2.5deg); +} + +.hill--front { + left: -14%; + bottom: -24%; + width: 128%; + height: 69%; + background: linear-gradient(180deg, var(--hill-front) 0%, var(--hill-front-deep) 100%); + border-radius: 52% 48% 0 0 / 86% 88% 0 0; +} + +.hill--front::after { + content: ""; + position: absolute; + inset: 15% 8% auto; + height: 28%; + border-radius: 50%; + background: rgba(255, 255, 255, .045); + transform: rotate(-2deg); +} + +.garden-plant { + position: absolute; + bottom: -2%; + opacity: .9; + filter: drop-shadow(0 8px 14px rgba(53, 77, 52, .06)); + transition: filter 1s ease; +} + +.garden-plant--left { + left: -1.5%; + width: clamp(145px, 19vw, 218px); + transform-origin: left bottom; + animation: plant-sway 8s ease-in-out infinite; +} + +.garden-plant--right { + right: -1%; + width: clamp(125px, 16vw, 188px); +} + +.habitat[data-phase="night"] .garden-plant { + filter: brightness(.52) saturate(.75); +} + +.meadow-flowers { + position: absolute; + left: 42%; + bottom: 7%; + width: clamp(150px, 20vw, 230px); + opacity: .86; +} + +.fireflies { + position: absolute; + inset: 0; + opacity: 0; + transition: opacity 1.2s ease; +} + +.habitat[data-phase="night"] .fireflies, +.habitat[data-phase="dusk"] .fireflies { + opacity: 1; +} + +.fireflies i { + position: absolute; + left: var(--fx); + top: var(--fy); + width: 4px; + height: 4px; + border-radius: 50%; + background: #f5e8a8; + box-shadow: 0 0 13px 4px rgba(246, 226, 139, .34); + animation: firefly 4.5s ease-in-out infinite; + animation-delay: var(--fd); +} + +.sleep-veil { + position: absolute; + z-index: 2; + inset: 0; + pointer-events: none; + opacity: 0; + background: linear-gradient(180deg, rgba(27, 48, 40, .45), rgba(27, 48, 40, .07) 62%, rgba(27, 48, 40, .1)); + transition: opacity .7s ease; +} + +.habitat.is-sleeping .sleep-veil { + opacity: .7; +} + +.habitat.is-sleeping .garden-plant, +.habitat.is-sleeping .meadow-flowers { + filter: brightness(.72) saturate(.8); +} + +.scene { + position: absolute; + z-index: 3; + inset: 0; + pointer-events: none; +} + +.scene-meta { + position: absolute; + z-index: 5; + top: 22px; + left: 22px; + right: 22px; + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; + pointer-events: none; +} + +.scene-badge, +.date-badge { + display: inline-flex; + align-items: center; + gap: 8px; + height: 31px; + border: 1px solid rgba(255, 252, 240, .31); + border-radius: 999px; + background: rgba(255, 251, 240, .36); + color: var(--scene-ink); + font-size: 8.5px; + font-weight: 650; + letter-spacing: .14em; + padding: 0 12px; + -webkit-backdrop-filter: blur(12px); + backdrop-filter: blur(12px); +} + +.scene-badge-dot { + width: 5px; + height: 5px; + border-radius: 50%; + background: #718c66; + box-shadow: 0 0 0 3px rgba(113, 140, 102, .12); +} + +.date-badge { + gap: 6px; + letter-spacing: .035em; + font-size: 10px; + font-weight: 500; +} + +.date-badge .icon { + width: 14px; + height: 14px; + stroke-width: 1.7; +} + +.habitat[data-phase="night"] .scene-badge, +.habitat[data-phase="night"] .date-badge { + border-color: rgba(255, 252, 240, .12); + background: rgba(24, 43, 38, .26); + color: #eae7d3; +} + +.habitat[data-phase="night"] .scene-badge-dot { + background: #e6d795; + box-shadow: 0 0 0 3px rgba(230, 215, 149, .12); +} + +.speech-bubble { + position: absolute; + z-index: 5; + top: 22%; + left: 5.2%; + width: min(236px, 32%); + min-width: 190px; + padding: 13px 15px 14px; + border: 1px solid rgba(76, 93, 72, .12); + border-radius: 17px 17px 17px 5px; + background: rgba(255, 251, 242, .93); + box-shadow: 0 14px 34px rgba(45, 65, 46, .11), inset 0 1px 0 rgba(255, 255, 255, .8); + color: var(--ink); + -webkit-backdrop-filter: blur(12px); + backdrop-filter: blur(12px); + pointer-events: none; +} + +.speech-bubble::after { + content: ""; + position: absolute; + right: 27px; + bottom: -7px; + width: 13px; + height: 13px; + border-right: 1px solid rgba(76, 93, 72, .12); + border-bottom: 1px solid rgba(76, 93, 72, .12); + border-radius: 0 0 3px 0; + background: rgba(255, 251, 242, .95); + transform: rotate(45deg); +} + +.speech-bubble.is-speaking { + animation: speech-pop .4s cubic-bezier(.2, .8, .2, 1); +} + +.speech-label { + display: block; + color: #9a6b54; + font-size: 8px; + font-weight: 700; + letter-spacing: .15em; +} + +.speech-bubble p { + margin: 4px 0 0; + font-size: 13px; + line-height: 1.68; + letter-spacing: .01em; +} + +.pet-area { + position: absolute; + z-index: 4; + left: 51%; + bottom: 3.2%; + width: clamp(285px, 43%, 415px); + transform: translateX(-50%); + pointer-events: none; +} + +.pet-button { + position: relative; + z-index: 2; + display: block; + width: 100%; + padding: 0; + background: transparent; + border: 0; + border-radius: 42%; + pointer-events: auto; +} + +.pet-art { + width: 100%; + transform-origin: center 88%; + transition: transform .28s cubic-bezier(.2, .8, .2, 1); +} + +.pet-button:hover .pet-art { + transform: translateY(-4px); +} + +.pet-button:active .pet-art { + transform: translateY(-1px) scale(.985); +} + +.pet-svg { + display: block; + width: 100%; + height: auto; + overflow: visible; + animation: pet-float 5.8s ease-in-out infinite; + transform-origin: center 88%; +} + +.pet-art-inner { + transform-box: fill-box; + transform-origin: 50% 85%; + transition: transform .6s ease, opacity .4s ease; +} + +.pet-button .eye { + transform-box: fill-box; + transform-origin: center center; + animation: eye-blink 6.4s infinite; +} + +.pet-button .eye:nth-child(2) { + animation-delay: .05s; +} + +.eyes-closed { + opacity: 0; + transition: opacity .2s ease; +} + +.mouth { + opacity: 0; + transition: opacity .18s ease; +} + +.mouth--happy { + opacity: 1; +} + +.pet-button[data-mood="hungry"] .mouth--happy, +.pet-button[data-mood="sleepy"] .mouth--happy, +.pet-button[data-mood="neutral"] .mouth--happy { + opacity: 0; +} + +.pet-button[data-mood="hungry"] .mouth--neutral, +.pet-button[data-mood="sleepy"] .mouth--neutral, +.pet-button[data-mood="neutral"] .mouth--neutral { + opacity: 1; +} + +.pet-button[data-mood="sad"] .mouth--happy { + opacity: 0; +} + +.pet-button[data-mood="sad"] .mouth--sad { + opacity: 1; +} + +.pet-button[data-mood="sad"] .pet-art-inner { + transform: rotate(-1.7deg) translateY(4px); +} + +.pet-button[data-mood="sad"] .ears { + transform: translateY(4px) rotate(-1.5deg); + transform-box: fill-box; + transform-origin: center top; +} + +.tail { + transform-box: fill-box; + transform-origin: left center; + animation: tail-sway 4.6s ease-in-out infinite; +} + +.ears { + transform-box: fill-box; + transform-origin: center bottom; + animation: ears-listen 9s ease-in-out infinite; +} + +.sprout { + transform-box: fill-box; + transform-origin: center bottom; + animation: sprout-sway 6.4s ease-in-out infinite; + transition: opacity .3s ease; +} + +.accessory { + opacity: 0; + visibility: hidden; + transform-box: fill-box; + transform-origin: center; + transition: opacity .28s ease, visibility .28s ease; +} + +.accessory.is-on { + opacity: 1; + visibility: visible; +} + +.pet-button.look--beret .sprout { + opacity: 0; +} + +.pet-button.is-sleeping .pet-svg { + animation-duration: 8s; +} + +.pet-button.is-sleeping .pet-art-inner { + transform: translateY(5px) rotate(1.4deg); +} + +.pet-button.is-sleeping .eyes-open { + opacity: 0; +} + +.pet-button.is-sleeping .eyes-closed { + opacity: 1; +} + +.pet-button.is-sleeping .eye { + animation: none; +} + +.pet-button.is-sleeping .tail { + animation-duration: 7s; +} + +.pet-button.is-sleeping .mouth--happy, +.pet-button.is-sleeping .mouth--sad { + opacity: 0; +} + +.pet-button.is-sleeping .mouth--neutral { + opacity: 1; +} + +.pet-button.is-eating .pet-art { + animation: munch .22s ease-in-out 5; +} + +.pet-button.is-cleaning .pet-art { + animation: clean-wiggle .2s ease-in-out 7; +} + +.pet-button.is-petting .pet-art { + animation: pet-squish .42s cubic-bezier(.2, .8, .2, 1); +} + +.zzz { + position: absolute; + z-index: 5; + top: 17%; + right: 8%; + display: flex; + flex-direction: column; + align-items: center; + gap: 1px; + color: #f9f1db; + opacity: 0; + pointer-events: none; + text-shadow: 0 2px 10px rgba(44, 62, 45, .2); + transition: opacity .4s ease; +} + +.zzz i { + display: block; + font-family: var(--serif); + font-size: 21px; + font-style: italic; + font-weight: 600; + line-height: .9; + opacity: 0; + animation: zzz-rise 3s ease-in-out infinite; +} + +.zzz i:nth-child(2) { + font-size: 27px; + animation-delay: .7s; +} + +.zzz i:nth-child(3) { + font-size: 34px; + animation-delay: 1.4s; +} + +.habitat.is-sleeping .zzz { + opacity: 1; +} + +.nameplate { + position: absolute; + z-index: 6; + left: 22px; + bottom: 21px; + display: flex; + align-items: center; + gap: 10px; + padding: 9px 13px 9px 9px; + border: 1px solid rgba(255, 255, 255, .45); + border-radius: 16px; + background: rgba(255, 252, 244, .74); + box-shadow: 0 10px 26px rgba(48, 65, 48, .08), inset 0 1px 0 rgba(255, 255, 255, .65); + color: var(--ink); + -webkit-backdrop-filter: blur(14px); + backdrop-filter: blur(14px); + pointer-events: none; +} + +.nameplate-mark { + display: grid; + place-items: center; + width: 33px; + height: 33px; + border-radius: 50%; + background: #eaf0dd; +} + +.nameplate-mark svg { + width: 26px; + height: 26px; +} + +.nameplate-copy { + min-width: 0; +} + +.nameplate-title { + display: flex; + align-items: center; + gap: 7px; +} + +.nameplate-title h1 { + margin: 0; + font-family: var(--serif); + font-size: 13px; + font-weight: 700; + letter-spacing: .05em; + line-height: 1.1; +} + +.nameplate-title span { + padding: 2px 5px; + border-radius: 5px; + background: #e4ead7; + color: #61785c; + font-size: 7px; + font-weight: 700; + letter-spacing: .08em; +} + +.nameplate-copy p { + margin: 3px 0 0; + color: var(--ink-soft); + font-size: 9px; + white-space: nowrap; +} + +.pet-hint { + position: absolute; + z-index: 6; + right: 21px; + bottom: 21px; + display: flex; + align-items: center; + gap: 6px; + height: 36px; + padding: 0 14px 0 11px; + border: 1px solid rgba(255, 255, 255, .45); + border-radius: 999px; + background: rgba(255, 252, 244, .67); + box-shadow: 0 8px 20px rgba(48, 65, 48, .06); + color: var(--ink); + font-size: 10px; + font-weight: 600; + letter-spacing: .04em; + -webkit-backdrop-filter: blur(12px); + backdrop-filter: blur(12px); + transition: background .2s ease, transform .2s ease; + pointer-events: auto; +} + +.pet-hint:hover { + transform: translateY(-2px); + background: rgba(255, 252, 244, .94); +} + +.pet-hint .icon { + width: 14px; + height: 14px; + color: #8c6d4a; +} + +.fx-layer, +.bubble-layer { + position: absolute; + z-index: 7; + inset: 0; + overflow: hidden; + pointer-events: none; +} + +.fx { + position: absolute; + opacity: 0; + transform: translate(-50%, -50%); + font-family: var(--serif); + font-weight: 600; + animation: fx-rise 1.5s cubic-bezier(.2, .7, .3, 1) forwards; + animation-delay: var(--delay, 0s); + text-shadow: 0 2px 8px rgba(75, 57, 42, .12); +} + +.fx--heart { + color: #d97d67; + font-size: var(--size, 16px); +} + +.fx--sparkle { + color: #f3d586; + font-size: var(--size, 18px); +} + +.fx--crumb { + width: var(--size, 6px); + height: var(--size, 6px); + border-radius: 50%; + background: var(--crumb, #deb17d); +} + +.soap-bubble { + position: absolute; + width: var(--size, 24px); + height: var(--size, 24px); + border: 1px solid rgba(255, 255, 255, .76); + border-radius: 50%; + background: + radial-gradient(circle at 30% 27%, rgba(255, 255, 255, .95) 0 10%, rgba(255, 255, 255, .08) 30%, transparent 60%), + rgba(210, 237, 230, .15); + box-shadow: inset -3px -4px 7px rgba(133, 183, 172, .15), 0 0 12px rgba(255, 255, 255, .25); + animation: bubble-rise 2.2s ease-in forwards; + animation-delay: var(--delay, 0s); +} + +/* --------------------------------------------- + Dashboard + --------------------------------------------- */ + +.dashboard { + display: flex; + flex-direction: column; + gap: 16px; + min-width: 0; + min-height: 0; +} + +.panel { + position: relative; + border: 1px solid rgba(54, 72, 55, .1); + border-radius: var(--radius); + background: + linear-gradient(180deg, rgba(255, 253, 248, .96), rgba(249, 246, 237, .94)); + box-shadow: + 0 16px 42px rgba(55, 68, 54, .055), + inset 0 1px 0 rgba(255, 255, 255, .9); +} + +.care-panel { + display: flex; + flex-direction: column; + padding: 22px; +} + +.care-head { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 14px; +} + +.eyebrow { + display: flex; + align-items: center; + gap: 6px; + margin: 0; + color: #7d897d; + font-size: 8.5px; + font-weight: 700; + letter-spacing: .13em; + line-height: 1.4; + text-transform: uppercase; +} + +#moodLabel { + font-size: 10px; + font-weight: 600; + letter-spacing: .055em; + text-transform: none; +} + +.live-dot { + width: 5px; + height: 5px; + border-radius: 50%; + background: #78956b; + box-shadow: 0 0 0 3px rgba(120, 149, 107, .12); + animation: live-pulse 3s ease-in-out infinite; +} + +.care-heading { + min-width: 0; +} + +.care-heading h2 { + margin: 6px 0 0; + font-size: clamp(20px, 1.8vw, 24px); + font-weight: 680; + letter-spacing: -.035em; + line-height: 1.32; +} + +.level-badge { + --progress: 26%; + position: relative; + display: flex; + flex: none; + flex-direction: column; + align-items: center; + justify-content: center; + width: 68px; + height: 64px; + overflow: hidden; + border: 1px solid rgba(56, 77, 55, .1); + border-radius: 16px; + background: + radial-gradient(circle at 50% 0%, rgba(255, 255, 255, .7), transparent 60%), + #f0f1e3; + color: #435b44; +} + +.level-word { + color: #899484; + font-size: 7px; + font-weight: 700; + letter-spacing: .18em; + line-height: 1; +} + +.level-badge strong { + margin-top: 2px; + font-family: var(--serif); + font-size: 24px; + font-weight: 600; + letter-spacing: -.04em; + line-height: .95; +} + +.level-xp { + margin-top: 4px; + color: #8a9487; + font-size: 8px; + letter-spacing: .015em; +} + +.level-badge::after { + content: ""; + position: absolute; + left: 0; + bottom: 0; + width: var(--progress); + height: 3px; + border-radius: 0 3px 3px 0; + background: var(--moss); + transition: width .55s cubic-bezier(.2, .8, .2, 1); +} + +.care-copy { + min-height: 36px; + margin: 9px 0 15px; + color: var(--ink-soft); + font-size: 12px; + line-height: 1.68; +} + +.stats-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 8px; + margin-bottom: 13px; +} + +.stat { + --stat-color: #d58a6a; + min-width: 0; + padding: 8px 10px 9px; + border: 1px solid transparent; + border-radius: 13px; + background: rgba(243, 240, 230, .64); + transition: background .2s ease, border-color .2s ease; +} + +.stat:hover { + border-color: rgba(59, 78, 57, .08); + background: rgba(246, 243, 234, .92); +} + +.stat[data-stat="hunger"] { + --stat-color: #de9a75; +} + +.stat[data-stat="happiness"] { + --stat-color: #d9b55c; +} + +.stat[data-stat="energy"] { + --stat-color: #829f76; +} + +.stat[data-stat="cleanliness"] { + --stat-color: #82a9ad; +} + +.stat-head { + display: flex; + align-items: center; + gap: 6px; +} + +.stat-icon { + display: grid; + place-items: center; + width: 18px; + height: 18px; + border-radius: 6px; + color: var(--stat-color); + background: rgba(255, 255, 255, .56); +} + +.stat-icon .icon { + width: 12px; + height: 12px; + stroke-width: 1.9; +} + +.stat-name { + color: #697569; + font-size: 10.5px; +} + +.stat-value { + display: flex; + align-items: baseline; + gap: 1px; + margin-left: auto; + color: var(--ink); +} + +.stat-value b { + font-size: 13px; + font-weight: 700; + font-variant-numeric: tabular-nums; +} + +.stat-value small { + color: #98a095; + font-size: 7px; +} + +.stat-track { + position: relative; + height: 3.5px; + margin-top: 7px; + overflow: hidden; + border-radius: 999px; + background: rgba(70, 89, 66, .095); +} + +.stat-track i { + display: block; + width: 0; + height: 100%; + border-radius: inherit; + background: var(--stat-color); + transition: width .5s linear, background .3s ease; +} + +.stat.is-low .stat-value b { + color: #b95f48; +} + +.stat.is-low .stat-track i { + animation: low-pulse 2.5s ease-in-out infinite; +} + +.actions-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 8px; + margin-bottom: 13px; +} + +.care-action { + --action-tint: #f8e8dc; + --action-color: #b96348; + position: relative; + display: flex; + align-items: center; + gap: 9px; + min-width: 0; + min-height: 56px; + padding: 9px 10px; + overflow: hidden; + border: 1px solid rgba(54, 72, 55, .1); + border-radius: 14px; + background: rgba(255, 253, 247, .55); + text-align: left; + transition: transform .18s ease, border-color .18s ease, background .18s ease, box-shadow .18s ease; +} + +.care-action--play { + --action-tint: #f7eeca; + --action-color: #a7802f; +} + +.care-action--clean { + --action-tint: #e3eeed; + --action-color: #638f90; +} + +.care-action--sleep { + --action-tint: #e8e9df; + --action-color: #617d5d; +} + +.care-action:hover:not(:disabled) { + transform: translateY(-2px); + border-color: rgba(53, 79, 57, .17); + background: #fffdf8; + box-shadow: 0 8px 18px rgba(54, 68, 52, .06); +} + +.care-action:active:not(:disabled) { + transform: translateY(0); +} + +.care-action:disabled { + opacity: .48; + filter: grayscale(.2); +} + +.care-action.is-resting { + border-color: transparent; + background: #3c5543; + color: #f8f4e7; +} + +.care-action.is-resting .action-icon { + background: rgba(255, 255, 255, .13); + color: #f1d8a2; +} + +.care-action.is-resting .action-copy small { + color: rgba(248, 244, 231, .64); +} + +.care-action.is-resting .action-arrow { + color: rgba(255, 255, 255, .7); + opacity: .6; +} + +.action-icon { + display: grid; + flex: none; + place-items: center; + width: 32px; + height: 32px; + border-radius: 10px; + background: var(--action-tint); + color: var(--action-color); +} + +.action-icon .icon { + width: 16px; + height: 16px; +} + +.action-copy { + display: flex; + flex-direction: column; + gap: 2px; + min-width: 0; +} + +.action-copy strong { + overflow: hidden; + font-size: 12.5px; + font-weight: 650; + letter-spacing: -.01em; + white-space: nowrap; + text-overflow: ellipsis; +} + +.action-copy small { + overflow: hidden; + color: #919a8f; + font-size: 9.5px; + line-height: 1.3; + white-space: nowrap; + text-overflow: ellipsis; +} + +.action-arrow { + position: absolute; + top: 7px; + right: 8px; + color: #8b9688; + font-size: 9px; + opacity: .35; + transition: opacity .2s ease, transform .2s ease; +} + +.care-action:hover .action-arrow { + opacity: .7; + transform: translate(1px, -1px); +} + +.care-foot { + display: flex; + align-items: center; + justify-content: space-between; + gap: 10px; + padding-top: 12px; + border-top: 1px solid var(--line); +} + +.care-count { + overflow: hidden; + color: #8a9487; + font-size: 10px; + line-height: 1.4; + white-space: nowrap; + text-overflow: ellipsis; +} + +.text-button { + display: inline-flex; + flex: none; + align-items: center; + gap: 4px; + padding: 0; + background: transparent; + color: #5f745b; + font-size: 10.5px; + font-weight: 650; + letter-spacing: .005em; +} + +.text-button .icon { + width: 13px; + height: 13px; + transition: transform .2s ease; +} + +.text-button:hover .icon { + transform: translateX(2px); +} + +.quest-panel { + display: flex; + flex: 1; + flex-direction: column; + min-height: 0; + padding: 17px 20px 15px; + overflow: hidden; +} + +.quest-head { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 12px; +} + +.quest-head .eyebrow { + margin-bottom: 4px; + font-size: 8.2px; +} + +.quest-head h2 { + margin: 0; + font-size: 17px; + font-weight: 680; + letter-spacing: -.025em; +} + +.refresh-note { + flex: none; + margin-top: 3px; + padding: 4px 7px; + border-radius: 999px; + background: rgba(230, 234, 218, .7); + color: #83907f; + font-size: 8px; + letter-spacing: .02em; +} + +.quest-list { + display: flex; + flex-direction: column; + margin: 12px 0 0; + padding: 0; + list-style: none; +} + +.quest { + --quest-color: #d88266; + display: grid; + grid-template-columns: auto minmax(0, 1fr) auto; + align-items: center; + gap: 9px; + padding: 9px 0; + border-top: 1px solid rgba(55, 72, 54, .08); +} + +.quest:first-child { + border-top: 0; + padding-top: 3px; +} + +.quest--feeds { + --quest-color: #c29b4c; +} + +.quest--plays { + --quest-color: #789271; +} + +.quest-icon { + display: grid; + place-items: center; + width: 28px; + height: 28px; + border-radius: 9px; + background: #f5e9df; + color: var(--quest-color); + transition: background .2s ease, color .2s ease; +} + +.quest--feeds .quest-icon { + background: #f6efda; +} + +.quest--plays .quest-icon { + background: #eaf0e2; +} + +.quest-icon .icon { + width: 14px; + height: 14px; +} + +.quest-main { + min-width: 0; +} + +.quest-title { + display: flex; + align-items: baseline; + justify-content: space-between; + gap: 8px; + color: #455246; + font-size: 11px; +} + +.quest-count { + flex: none; + color: #9aa296; + font-size: 9px; + font-variant-numeric: tabular-nums; +} + +.quest-track { + height: 3px; + margin-top: 5px; + overflow: hidden; + border-radius: 99px; + background: rgba(69, 88, 65, .08); +} + +.quest-track i { + display: block; + height: 100%; + border-radius: inherit; + background: var(--quest-color); + transition: width .4s ease; +} + +.quest-reward { + display: inline-flex; + align-items: center; + gap: 2px; + min-width: 36px; + justify-content: flex-end; + color: #9b834f; + font-size: 9px; + font-variant-numeric: tabular-nums; +} + +.quest-reward .icon { + width: 11px; + height: 11px; + fill: currentColor; + stroke-width: 1; +} + +.quest.is-done .quest-icon { + background: #e4ecd9; + color: #5e7b55; +} + +.quest.is-done .quest-title { + color: #788777; +} + +.quest.is-done .quest-track i { + background: #93a986; +} + +.quest.is-done .quest-reward { + color: #65805b; +} + +.quest.is-claimed .quest-reward { + color: #93a08b; +} + +.quest-whisper { + position: relative; + display: flex; + flex: none; + align-items: center; + gap: 10px; + min-height: 62px; + margin-top: auto; + padding: 10px 12px; + overflow: hidden; + border: 1px solid rgba(87, 108, 76, .09); + border-radius: 14px; + background: + radial-gradient(circle at 93% 10%, rgba(245, 230, 186, .5), transparent 38%), + linear-gradient(135deg, rgba(242, 244, 232, .9), rgba(249, 246, 236, .75)); +} + +.whisper-mark { + display: grid; + flex: none; + place-items: center; + width: 35px; + height: 35px; + border-radius: 50%; + background: rgba(255, 255, 255, .62); +} + +.whisper-mark svg { + width: 25px; + height: 25px; +} + +.whisper-copy { + display: flex; + min-width: 0; + flex-direction: column; + gap: 2px; +} + +.whisper-label { + color: #98a18f; + font-size: 6.8px; + font-weight: 700; + letter-spacing: .11em; +} + +.whisper-text { + color: #687766; + font-family: var(--serif); + font-size: 10.5px; + letter-spacing: .01em; + line-height: 1.45; +} + +.whisper-spark { + position: absolute; + top: 9px; + right: 12px; + color: #c8ab68; + font-family: var(--serif); + font-size: 12px; + opacity: .7; +} + +.quest-foot { + display: flex; + flex-wrap: wrap; + align-items: center; + justify-content: space-between; + gap: 4px 10px; + margin-top: 10px; + padding-top: 10px; + border-top: 1px solid var(--line); +} + +.quest-foot p { + display: flex; + align-items: center; + gap: 4px; + margin: 0; + color: #8b9487; + font-size: 8.8px; + line-height: 1.5; +} + +.quest-foot .icon { + width: 10px; + height: 10px; + color: #b39a61; +} + +.save-note { + color: #a3aaa0; + font-size: 8px; + white-space: nowrap; +} + +@media (max-height: 899px) and (min-width: 1025px) { + .quest-whisper { + display: none; + } +} + +/* --------------------------------------------- + Dialogs, general + --------------------------------------------- */ + +.pico-dialog { + width: min(700px, calc(100vw - 32px)); + max-width: none; + max-height: calc(100vh - 32px); + max-height: calc(100dvh - 32px); + margin: auto; + padding: 0; + overflow: visible; + border: 0; + border-radius: 28px; + background: transparent; + color: var(--ink); +} + +.pico-dialog::backdrop { + background: rgba(26, 39, 31, .46); + -webkit-backdrop-filter: blur(8px); + backdrop-filter: blur(8px); + animation: backdrop-in .3s ease; +} + +.pico-dialog[open] { + animation: dialog-in .34s cubic-bezier(.16, .86, .28, 1); +} + +.dialog-shell { + max-height: calc(100vh - 32px); + max-height: calc(100dvh - 32px); + padding: 25px; + overflow: auto; + overscroll-behavior: contain; + border: 1px solid rgba(255, 255, 255, .65); + border-radius: 28px; + background: + radial-gradient(ellipse at 100% 0%, rgba(228, 236, 214, .46), transparent 38%), + var(--surface-strong); + box-shadow: 0 34px 90px rgba(25, 40, 27, .28), inset 0 1px 0 rgba(255, 255, 255, .9); +} + +.dialog-head { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 18px; + margin-bottom: 20px; +} + +.dialog-head h2 { + margin: 5px 0 0; + font-size: 25px; + font-weight: 680; + letter-spacing: -.04em; + line-height: 1.3; +} + +.dialog-close { + display: grid; + flex: none; + place-items: center; + width: 35px; + height: 35px; + border: 1px solid var(--line); + border-radius: 50%; + background: rgba(255, 255, 255, .55); + color: #6b786b; + transition: transform .2s ease, background .2s ease; +} + +.dialog-close:hover { + transform: rotate(6deg); + background: #fff; +} + +.dialog-close .icon { + width: 15px; + height: 15px; +} + +.dialog-note { + display: flex; + align-items: center; + justify-content: center; + gap: 5px; + margin: 17px 0 1px; + color: #929b8f; + font-size: 10px; +} + +.dialog-note .icon { + width: 12px; + height: 12px; + color: #b9a26e; +} + +.primary-button { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 8px; + min-height: 45px; + padding: 0 20px; + border-radius: 999px; + background: var(--moss-deep); + color: #fbf7eb; + font-size: 12px; + font-weight: 650; + box-shadow: 0 10px 22px rgba(44, 70, 48, .16); + transition: transform .2s ease, background .2s ease, box-shadow .2s ease; +} + +.primary-button:hover { + transform: translateY(-2px); + background: #2c4635; + box-shadow: 0 14px 28px rgba(44, 70, 48, .22); +} + +.primary-button:active { + transform: translateY(0); +} + +.primary-button .icon { + width: 15px; + height: 15px; +} + +/* --------------------------------------------- + Food dialog + --------------------------------------------- */ + +.food-grid { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 11px; +} + +.food-card { + display: flex; + align-items: center; + flex-direction: column; + min-width: 0; + padding: 14px 12px 13px; + border: 1px solid rgba(55, 74, 55, .1); + border-radius: 20px; + background: rgba(252, 249, 242, .74); + text-align: center; + transition: transform .2s ease, border-color .2s ease, background .2s ease, box-shadow .2s ease; +} + +.food-card:hover { + transform: translateY(-4px); + border-color: rgba(55, 78, 55, .18); + background: #fffdf7; + box-shadow: 0 14px 26px rgba(57, 71, 54, .07); +} + +.food-card:active { + transform: translateY(-1px); +} + +.food-art { + display: grid; + place-items: center; + width: 94px; + height: 94px; + margin-bottom: 10px; + border-radius: 50%; +} + +.food-art--pudding { + background: #f8e7de; +} + +.food-art--cookie { + background: #f7efd9; +} + +.food-art--onigiri { + background: #e9f0df; +} + +.food-art svg { + width: 83px; + height: 83px; + overflow: visible; +} + +.food-copy { + display: flex; + align-items: center; + flex-direction: column; + gap: 4px; +} + +.food-copy strong { + font-size: 13px; + font-weight: 680; + letter-spacing: -.01em; +} + +.food-copy span { + color: #8b9589; + font-size: 9.5px; + line-height: 1.4; +} + +.food-effects { + display: flex; + flex-wrap: wrap; + justify-content: center; + gap: 4px; + margin-top: 9px; +} + +.food-effects i { + padding: 3px 6px; + border-radius: 999px; + background: #edf0e3; + color: #758470; + font-size: 8px; + font-style: normal; + white-space: nowrap; +} + +/* --------------------------------------------- + Game dialog + --------------------------------------------- */ + +.game-dialog { + width: min(660px, calc(100vw - 32px)); +} + +.game-shell { + padding: 23px; +} + +.dialog-head--game { + margin-bottom: 8px; +} + +.game-intro { + padding: 3px 24px 9px; + text-align: center; +} + +.game-hero { + position: relative; + display: grid; + place-items: center; + width: 100%; + height: 145px; + margin: 0 auto 4px; +} + +.game-hero::after { + content: ""; + position: absolute; + left: 50%; + bottom: 19px; + width: 110px; + height: 16px; + border-radius: 50%; + background: rgba(73, 91, 65, .09); + filter: blur(5px); + transform: translateX(-50%); +} + +.hero-orbit { + position: absolute; + top: 50%; + left: 50%; + border: 1px dashed rgba(114, 132, 103, .25); + border-radius: 50%; + transform: translate(-50%, -50%) rotate(-12deg); +} + +.hero-orbit--one { + width: 245px; + height: 112px; +} + +.hero-orbit--two { + width: 180px; + height: 82px; + transform: translate(-50%, -50%) rotate(31deg); + opacity: .7; +} + +.hero-star { + position: relative; + z-index: 2; + display: grid; + place-items: center; + width: 82px; + height: 82px; + border: 1px solid rgba(255, 252, 224, .8); + border-radius: 26px; + background: linear-gradient(145deg, #f8e8b4, #e6bd74); + box-shadow: 0 18px 38px rgba(159, 123, 56, .18), inset 0 1px 0 rgba(255, 255, 255, .6); + transform: rotate(-8deg); + animation: hero-float 4.2s ease-in-out infinite; +} + +.hero-star svg { + width: 39px; + height: 39px; + fill: #fff9e6; + stroke: #665334; + stroke-width: 1.1; + stroke-linejoin: round; +} + +.hero-dot { + position: absolute; + display: block; + border-radius: 50%; + background: #e4d28e; + box-shadow: 0 0 0 4px rgba(228, 210, 142, .13); +} + +.hero-dot--one { + top: 27px; + left: 25%; + width: 7px; + height: 7px; +} + +.hero-dot--two { + right: 23%; + top: 47px; + width: 4px; + height: 4px; + background: #d8a88f; + box-shadow: 0 0 0 3px rgba(216, 168, 143, .12); +} + +.hero-dot--three { + left: 33%; + bottom: 31px; + width: 4px; + height: 4px; + background: #95ab81; + box-shadow: 0 0 0 3px rgba(149, 171, 129, .13); +} + +.game-intro h3 { + margin: 1px 0 8px; + font-size: 21px; + font-weight: 680; + letter-spacing: -.035em; +} + +.game-intro > p { + max-width: 390px; + margin: 0 auto 14px; + color: #7e897e; + font-size: 12px; + line-height: 1.7; +} + +.game-rewards { + display: flex; + justify-content: center; + gap: 7px; + margin-bottom: 17px; +} + +.game-rewards span { + display: inline-flex; + align-items: center; + gap: 4px; + padding: 5px 9px; + border: 1px solid rgba(62, 81, 58, .08); + border-radius: 999px; + background: #f2f2e6; + color: #768270; + font-size: 9px; +} + +.game-rewards .icon { + width: 11px; + height: 11px; +} + +.game-play { + padding-top: 2px; +} + +.game-hud { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 9px; + padding: 0 3px; +} + +.game-score { + color: #667467; + font-size: 10px; +} + +.game-score strong { + color: var(--ink); + font-family: var(--serif); + font-size: 20px; + font-weight: 600; +} + +.game-score span { + color: #9aa298; +} + +.game-time { + display: inline-flex; + align-items: center; + gap: 4px; + padding: 5px 9px; + border-radius: 999px; + background: #f2efe4; + color: #7c897b; + font-size: 10px; + font-variant-numeric: tabular-nums; +} + +.game-time .icon { + width: 12px; + height: 12px; +} + +.game-stage { + position: relative; + height: clamp(305px, 40vh, 375px); + overflow: hidden; + isolation: isolate; + border-radius: 20px; + background: + radial-gradient(ellipse at 50% 25%, rgba(139, 170, 121, .2), transparent 58%), + linear-gradient(155deg, #203a36 0%, #3a5548 48%, #7d8b64 100%); + box-shadow: inset 0 1px 0 rgba(255, 255, 255, .12); + cursor: crosshair; +} + +.game-stage::before { + content: ""; + position: absolute; + inset: 0; + background-image: radial-gradient(rgba(249, 241, 208, .7) .75px, transparent .75px); + background-size: 43px 41px; + opacity: .25; + mask-image: linear-gradient(to bottom, #000, transparent 78%); + -webkit-mask-image: linear-gradient(to bottom, #000, transparent 78%); +} + +.stage-glow { + position: absolute; + border-radius: 50%; + filter: blur(32px); + opacity: .28; +} + +.stage-glow--one { + top: 9%; + left: 13%; + width: 42%; + aspect-ratio: 1; + background: #d6c78f; +} + +.stage-glow--two { + right: 4%; + bottom: 18%; + width: 42%; + aspect-ratio: 1; + background: #8fa878; + opacity: .25; +} + +.stage-hill { + position: absolute; + border-radius: 50% 50% 0 0 / 100% 100% 0 0; +} + +.stage-hill--one { + right: -18%; + bottom: -22%; + width: 93%; + height: 62%; + background: #4b654c; +} + +.stage-hill--two { + left: -23%; + bottom: -26%; + width: 91%; + height: 53%; + background: #344d3f; +} + +.game-stage-tip { + position: absolute; + right: 0; + bottom: 13px; + left: 0; + z-index: 2; + margin: 0; + color: rgba(244, 240, 221, .62); + font-size: 8.5px; + letter-spacing: .08em; + text-align: center; + pointer-events: none; +} + +.game-star { + position: absolute; + z-index: 4; + display: grid; + place-items: center; + width: 62px; + height: 62px; + padding: 0; + border: 0; + border-radius: 50%; + background: transparent; + transform: translate(-50%, -50%); + animation: game-star-in .24s cubic-bezier(.2, .8, .2, 1) both; + -webkit-tap-highlight-color: transparent; +} + +.game-star svg { + width: 40px; + height: 40px; + overflow: visible; + fill: #f6e3a5; + stroke: #fffbdc; + stroke-width: .9; + filter: drop-shadow(0 0 12px rgba(249, 225, 142, .9)); + transition: transform .16s ease; +} + +.game-star::before { + content: ""; + position: absolute; + inset: 1px; + border: 1px solid rgba(249, 235, 178, .22); + border-radius: 50%; + animation: game-star-pulse 1.4s ease-out infinite; +} + +.game-star:hover svg, +.game-star:focus-visible svg { + transform: scale(1.14); +} + +.game-star.is-caught { + pointer-events: none; + animation: game-star-caught .35s ease forwards; +} + +.game-star.is-gone { + pointer-events: none; + animation: game-star-gone .3s ease forwards; +} + +.game-result { + padding: 7px 20px 4px; + text-align: center; +} + +.result-stars { + display: flex; + justify-content: center; + gap: 4px; + height: 35px; + margin-bottom: 5px; +} + +.result-stars span { + display: inline-flex; + transform: translateY(var(--offset, 0)) rotate(var(--rotate, 0deg)); +} + +.result-stars .icon { + width: 24px; + height: 24px; + color: #e3c477; + fill: #f2dda1; + stroke: #ad8e4d; + stroke-width: 1.1; +} + +.result-stars span.is-empty .icon { + fill: transparent; + stroke: #d4d5c9; + opacity: .55; +} + +.result-stars span.is-earned { + animation: result-star-pop .4s cubic-bezier(.2, .8, .2, 1) both; + animation-delay: var(--delay, 0s); +} + +.game-result h3 { + margin: 3px 0 7px; + font-size: 22px; + font-weight: 680; + letter-spacing: -.035em; +} + +.game-result > p:not(.eyebrow):not(.result-extra) { + max-width: 400px; + margin: 0 auto 13px; + color: #7d887c; + font-size: 12px; + line-height: 1.7; +} + +.result-rewards { + display: flex; + flex-wrap: wrap; + justify-content: center; + gap: 6px; + margin-bottom: 11px; +} + +.result-rewards span { + display: inline-flex; + align-items: center; + gap: 4px; + padding: 5px 9px; + border-radius: 999px; + background: #eef1e2; + color: #667961; + font-size: 9.5px; +} + +.result-rewards .icon { + width: 11px; + height: 11px; +} + +.result-extra { + min-height: 16px; + margin: 0 0 12px; + color: #9a7b4d; + font-size: 10px; + line-height: 1.5; +} + +/* --------------------------------------------- + Wardrobe dialog + --------------------------------------------- */ + +.wardrobe-dialog { + width: min(720px, calc(100vw - 32px)); +} + +.wallet-row { + display: flex; + align-items: center; + justify-content: space-between; + gap: 22px; + margin-bottom: 14px; + padding: 13px 16px; + border: 1px solid rgba(70, 88, 59, .08); + border-radius: 16px; + background: + radial-gradient(circle at 0% 0%, rgba(255, 255, 255, .75), transparent 55%), + #eef1e3; +} + +.wallet-copy { + display: flex; + flex: none; + align-items: center; + gap: 9px; +} + +.wallet-copy > span { + color: #7b8878; + font-size: 9px; +} + +.wallet-copy strong { + display: inline-flex; + align-items: center; + gap: 3px; + font-family: var(--serif); + font-size: 22px; + font-weight: 600; + letter-spacing: -.03em; +} + +.coin-inline { + width: 15px; + height: 15px; + fill: #d3ab58; + stroke: #a27b36; + stroke-width: 1.2; +} + +.wallet-row p { + max-width: 300px; + margin: 0; + color: #8b9587; + font-size: 9.5px; + line-height: 1.55; + text-align: right; +} + +.shop-grid { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 10px; +} + +.shop-item { + --item-tint: #f5ead0; + position: relative; + display: flex; + flex-direction: column; + min-width: 0; + overflow: hidden; + border: 1px solid rgba(52, 72, 52, .1); + border-radius: 19px; + background: rgba(252, 250, 243, .8); + transition: border-color .2s ease, transform .2s ease, box-shadow .2s ease; +} + +.shop-item--scarf { + --item-tint: #f4dfd6; +} + +.shop-item--beret { + --item-tint: #e6e7d3; +} + +.shop-item:hover { + transform: translateY(-2px); + border-color: rgba(52, 74, 52, .17); + box-shadow: 0 10px 24px rgba(54, 68, 51, .06); +} + +.shop-item.is-equipped { + border-color: rgba(82, 111, 74, .44); + box-shadow: 0 0 0 2px rgba(120, 145, 103, .09); +} + +.shop-art { + position: relative; + display: grid; + place-items: center; + height: 122px; + background: + radial-gradient(circle at 35% 24%, rgba(255, 255, 255, .9), transparent 46%), + var(--item-tint); +} + +.shop-art::after { + content: ""; + position: absolute; + left: 50%; + bottom: 15px; + width: 66px; + height: 10px; + border-radius: 50%; + background: rgba(55, 71, 45, .08); + filter: blur(4px); + transform: translateX(-50%); +} + +.shop-art svg { + position: relative; + z-index: 1; + width: 76px; + height: 76px; + filter: drop-shadow(0 7px 9px rgba(49, 64, 43, .1)); +} + +.equip-check { + position: absolute; + z-index: 2; + top: 9px; + right: 9px; + display: grid; + place-items: center; + width: 22px; + height: 22px; + border-radius: 50%; + background: #55714f; + color: #fffdf5; + box-shadow: 0 4px 10px rgba(49, 72, 45, .18); +} + +.equip-check .icon { + width: 11px; + height: 11px; + stroke-width: 2.4; +} + +.shop-info { + display: flex; + flex: 1; + flex-direction: column; + padding: 11px 12px 12px; +} + +.shop-info h3 { + margin: 0; + font-size: 12.5px; + font-weight: 680; +} + +.shop-info p { + min-height: 27px; + margin: 3px 0 10px; + color: #8c9589; + font-size: 9px; + line-height: 1.55; +} + +.shop-actions { + display: flex; + align-items: center; + justify-content: space-between; + gap: 7px; + margin-top: auto; +} + +.shop-price { + display: inline-flex; + align-items: center; + gap: 2px; + color: #9b814a; + font-size: 9.5px; +} + +.shop-price.is-owned { + color: #7f8d7a; +} + +.shop-price .icon { + width: 10px; + height: 10px; + fill: currentColor; + stroke-width: 1; +} + +.shop-button { + min-height: 28px; + padding: 0 10px; + border: 1px solid rgba(61, 83, 59, .12); + border-radius: 999px; + background: #f1f1e4; + color: #5f735a; + font-size: 9.5px; + font-weight: 650; + white-space: nowrap; + transition: background .18s ease, transform .18s ease; +} + +.shop-button:hover { + transform: translateY(-1px); + background: #e8ecdb; +} + +.shop-button.is-equip { + background: #54704f; + color: #fdfaef; +} + +.shop-button.is-equip:hover { + background: #435e40; +} + +.shop-button:disabled { + opacity: .52; + transform: none; +} + +.shop-foot { + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; + margin-top: 16px; + padding-top: 13px; + border-top: 1px solid var(--line); +} + +.text-button--plain { + color: #7b8876; + font-weight: 500; +} + +.text-button--plain .icon { + width: 15px; + height: 15px; +} + +#shopMessage { + margin: 0; + color: #959d92; + font-size: 8.5px; + line-height: 1.5; + text-align: right; +} + +#shopMessage.is-warm { + color: #ae724f; +} + +#shopMessage.is-good { + color: #5d7c54; +} + +.shop-item.is-shaking { + animation: item-shake .35s ease; +} + +/* --------------------------------------------- + Toasts & confetti + --------------------------------------------- */ + +.toast-stack { + position: fixed; + z-index: 100; + right: 24px; + bottom: 22px; + display: flex; + flex-direction: column; + gap: 8px; + width: min(310px, calc(100vw - 32px)); + pointer-events: none; +} + +body.modal-open .toast-stack { + visibility: hidden; + opacity: 0; +} + +.toast { + display: grid; + grid-template-columns: auto minmax(0, 1fr); + align-items: start; + gap: 9px; + padding: 12px 13px; + border: 1px solid rgba(255, 255, 255, .1); + border-radius: 14px; + background: rgba(45, 65, 51, .96); + color: #f9f5e8; + box-shadow: 0 16px 36px rgba(26, 39, 28, .2); + -webkit-backdrop-filter: blur(10px); + backdrop-filter: blur(10px); + animation: toast-in .34s cubic-bezier(.2, .8, .2, 1); +} + +.toast.is-leaving { + animation: toast-out .25s ease forwards; +} + +.toast-icon { + display: grid; + place-items: center; + width: 23px; + height: 23px; + border-radius: 8px; + background: rgba(247, 231, 177, .14); + color: #eedb9d; +} + +.toast-icon .icon { + width: 12px; + height: 12px; + fill: currentColor; + stroke-width: 1.3; +} + +.toast-copy strong { + display: block; + margin-bottom: 2px; + font-size: 11.5px; + font-weight: 650; + line-height: 1.45; +} + +.toast-copy span { + display: block; + color: rgba(249, 245, 232, .68); + font-size: 9.5px; + line-height: 1.55; +} + +.toast--level .toast-icon { + background: rgba(241, 207, 119, .18); + color: #f4d98f; +} + +.celebration-layer { + position: fixed; + z-index: 101; + inset: 0; + overflow: hidden; + pointer-events: none; +} + +.confetti { + position: absolute; + top: -18px; + width: 7px; + height: 12px; + border-radius: 2px; + background: var(--confetti-color); + opacity: 0; + animation: confetti-fall var(--fall-time, 2.4s) cubic-bezier(.2, .6, .4, 1) forwards; + animation-delay: var(--fall-delay, 0s); +} + +.confetti.is-round { + width: 6px; + height: 6px; + border-radius: 50%; +} + +/* --------------------------------------------- + Responsive + --------------------------------------------- */ + +@media (max-width: 1180px) { + .header-note { + display: none; + } + + .layout { + grid-template-columns: minmax(0, 1.45fr) minmax(360px, .95fr); + } + + .speech-bubble { + width: min(220px, 34%); + } + + .action-copy small { + font-size: 8px; + } +} + +@media (max-width: 1024px) { + .app { + width: min(900px, calc(100% - 32px)); + padding: 17px 0 24px; + } + + .layout { + grid-template-columns: minmax(0, 1fr); + height: auto; + max-height: none; + min-height: 0; + gap: 14px; + } + + .habitat { + height: clamp(520px, 63vw, 650px); + min-height: 520px; + } + + .pet-area { + width: clamp(290px, 44%, 390px); + } + + .speech-bubble { + width: min(240px, 34%); + } + + .dashboard { + display: grid; + grid-template-columns: minmax(0, 1.1fr) minmax(0, .9fr); + align-items: stretch; + gap: 14px; + } + + .quest-panel { + min-height: 100%; + } + + .care-copy { + min-height: 0; + } + + .quest-list { + flex: 1; + } + + .quest { + padding: 11px 0; + } +} + +@media (max-width: 760px) { + .app { + width: calc(100% - 24px); + padding: 12px 0 22px; + } + + .topbar { + padding-bottom: 11px; + gap: 10px; + } + + .brand-mark { + width: 39px; + height: 39px; + border-radius: 13px; + } + + .brand-mark svg { + width: 31px; + height: 31px; + } + + .brand { + gap: 8px; + } + + .brand-name { + font-size: 27px; + } + + .brand-sub { + font-size: 8px; + letter-spacing: .08em; + } + + .coin-menu { + height: 37px; + padding-right: 10px; + } + + .coin-star { + width: 22px; + height: 22px; + } + + .coin-value { + font-size: 17px; + } + + .coin-unit { + display: none; + } + + .icon-button { + width: 37px; + height: 37px; + } + + .layout { + gap: 12px; + } + + .habitat { + height: clamp(470px, 128vw, 570px); + min-height: 470px; + border-radius: 25px; + } + + .scene-meta { + top: 15px; + left: 15px; + right: 15px; + gap: 8px; + } + + .scene-badge, + .date-badge { + height: 27px; + padding: 0 9px; + font-size: 7px; + letter-spacing: .1em; + } + + .date-badge { + font-size: 8.5px; + letter-spacing: 0; + } + + .date-badge .icon { + width: 12px; + height: 12px; + } + + .speech-bubble { + top: 17.5%; + left: 5.5%; + width: 54%; + min-width: 0; + padding: 10px 12px 11px; + border-radius: 14px 14px 14px 4px; + } + + .speech-bubble::after { + right: 22px; + } + + .speech-bubble p { + margin-top: 3px; + font-size: 11px; + line-height: 1.6; + } + + .speech-label { + font-size: 7px; + } + + .sun { + top: 12%; + right: 7%; + width: 95px; + } + + .moon { + top: 12.5%; + right: 8%; + width: 58px; + } + + .cloud--one { + top: 27%; + left: -9%; + width: 125px; + opacity: .4; + } + + .cloud--two { + top: 42%; + right: -7%; + width: 92px; + opacity: .3; + } + + .cloud--three { + display: none; + } + + .birds { + left: 43%; + top: 20%; + opacity: .35; + } + + .bird:nth-child(2) { + display: none; + } + + .pet-area { + left: 52%; + bottom: 3%; + width: clamp(255px, 66vw, 340px); + } + + .nameplate { + left: 13px; + bottom: 14px; + gap: 7px; + padding: 7px 10px 7px 7px; + border-radius: 13px; + } + + .nameplate-mark { + width: 29px; + height: 29px; + } + + .nameplate-mark svg { + width: 23px; + height: 23px; + } + + .nameplate-title h1 { + font-size: 11px; + } + + .nameplate-copy p { + font-size: 8px; + } + + .nameplate-title span { + font-size: 6.5px; + } + + .pet-hint { + right: 13px; + bottom: 14px; + height: 32px; + padding: 0 11px 0 9px; + font-size: 9px; + } + + .garden-plant--left { + left: -9%; + width: clamp(130px, 37vw, 190px); + opacity: .75; + } + + .garden-plant--right { + right: -9%; + width: clamp(112px, 31vw, 165px); + opacity: .8; + } + + .meadow-flowers { + left: 41%; + bottom: 4%; + width: 160px; + opacity: .65; + } + + .hill--back { + bottom: 26%; + } + + .hill--middle { + bottom: 19%; + } + + .hill--front { + bottom: -32%; + height: 75%; + } + + .dashboard { + display: flex; + flex-direction: column; + gap: 12px; + } + + .care-panel { + padding: 18px; + border-radius: 23px; + } + + .care-heading h2 { + margin-top: 5px; + font-size: 21px; + } + + .level-badge { + width: 62px; + height: 59px; + border-radius: 14px; + } + + .level-badge strong { + font-size: 22px; + } + + .care-copy { + margin: 8px 0 13px; + font-size: 11px; + } + + .stats-grid { + gap: 7px; + margin-bottom: 11px; + } + + .stat { + padding: 8px 9px; + } + + .stat-name { + font-size: 9.5px; + } + + .stat-value b { + font-size: 12px; + } + + .actions-grid { + gap: 7px; + margin-bottom: 11px; + } + + .care-action { + min-height: 55px; + padding: 8px; + gap: 8px; + } + + .action-icon { + width: 31px; + height: 31px; + } + + .action-copy strong { + font-size: 11.5px; + } + + .action-copy small { + font-size: 8.5px; + } + + .care-foot { + padding-top: 10px; + } + + .care-count { + font-size: 9px; + } + + .text-button { + font-size: 10px; + } + + .quest-panel { + min-height: 0; + padding: 16px 17px 14px; + border-radius: 23px; + } + + .quest-head h2 { + font-size: 16px; + } + + .quest-title { + font-size: 10.5px; + } + + .quest-whisper { + margin-top: 12px; + } + + .quest { + padding: 9px 0; + } + + .quest-foot { + padding-top: 9px; + } + + .save-note { + white-space: normal; + } + + .dialog-shell { + padding: 19px; + border-radius: 23px; + } + + .dialog-head { + margin-bottom: 16px; + } + + .dialog-head h2 { + font-size: 21px; + } + + .food-grid { + gap: 8px; + } + + .food-card { + padding: 10px 7px 11px; + border-radius: 16px; + } + + .food-art { + width: 72px; + height: 72px; + margin-bottom: 7px; + } + + .food-art svg { + width: 66px; + height: 66px; + } + + .food-copy strong { + font-size: 12px; + } + + .food-copy span { + font-size: 8.5px; + } + + .food-effects { + gap: 3px; + margin-top: 6px; + } + + .food-effects i { + padding: 2px 4px; + font-size: 7.5px; + } + + .dialog-note { + margin-top: 13px; + font-size: 9.5px; + } + + .game-shell { + padding: 17px; + } + + .game-intro { + padding: 0 2px 5px; + } + + .game-hero { + height: 120px; + } + + .game-intro h3 { + font-size: 19px; + } + + .game-intro > p { + font-size: 11.5px; + } + + .game-stage { + height: clamp(290px, 48vh, 340px); + } + + .game-result { + padding: 4px 2px; + } + + .game-result h3 { + font-size: 20px; + } + + .wallet-row { + align-items: flex-start; + flex-direction: column; + gap: 7px; + padding: 11px 13px; + } + + .wallet-row p { + max-width: none; + text-align: left; + } + + .shop-grid { + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 7px; + } + + .shop-art { + height: 94px; + } + + .shop-art svg { + width: 60px; + height: 60px; + } + + .shop-info { + padding: 9px; + } + + .shop-info h3 { + font-size: 11px; + } + + .shop-info p { + min-height: 33px; + font-size: 8.5px; + } + + .shop-button { + padding: 0 7px; + font-size: 8.5px; + } + + .shop-price { + font-size: 9px; + } + + .shop-actions { + gap: 3px; + } + + .shop-foot { + align-items: flex-start; + flex-direction: column; + gap: 7px; + } + + #shopMessage { + min-height: 12px; + text-align: left; + } + + .toast-stack { + right: 14px; + bottom: 14px; + } +} + +@media (max-width: 560px) { + .brand-sub { + display: none; + } + + .brand-name { + font-size: 28px; + } + + .habitat { + height: clamp(445px, 124vw, 540px); + min-height: 445px; + } + + .speech-bubble { + width: 57%; + top: 16.5%; + } + + .sun { + width: 81px; + top: 13.5%; + } + + .moon { + width: 49px; + top: 14%; + } + + .scene-badge { + letter-spacing: .08em; + } + + .pet-area { + bottom: 2.5%; + width: clamp(245px, 68vw, 315px); + } + + .nameplate { + left: 10px; + bottom: 11px; + } + + .pet-hint { + right: 10px; + bottom: 11px; + } + + .garden-plant--left { + width: 125px; + } + + .garden-plant--right { + width: 108px; + } + + .meadow-flowers { + width: 130px; + left: 38%; + } + + .stats-grid { + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 6px; + } + + .stat { + padding: 7px 8px 8px; + } + + .actions-grid { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + + .care-action { + min-height: 53px; + padding: 8px; + } + + .action-copy strong { + font-size: 11.5px; + } + + .action-copy small { + font-size: 8px; + } + + .food-grid { + grid-template-columns: 1fr; + gap: 7px; + } + + .food-card { + display: grid; + grid-template-columns: 62px minmax(0, 1fr); + grid-template-rows: auto auto; + column-gap: 10px; + padding: 9px 11px; + text-align: left; + } + + .food-art { + grid-row: 1 / 3; + width: 58px; + height: 58px; + margin: 0; + } + + .food-art svg { + width: 53px; + height: 53px; + } + + .food-copy { + align-items: flex-start; + gap: 1px; + } + + .food-effects { + justify-content: flex-start; + margin-top: 4px; + } + + .dialog-note { + align-items: flex-start; + text-align: center; + } + + .game-hero { + height: 105px; + } + + .hero-star { + width: 66px; + height: 66px; + border-radius: 21px; + } + + .hero-star svg { + width: 31px; + height: 31px; + } + + .hero-orbit--one { + width: 205px; + height: 96px; + } + + .hero-orbit--two { + width: 150px; + height: 69px; + } + + .game-hero::after { + bottom: 8px; + } + + .game-stage { + height: 55vw; + min-height: 275px; + } + + .game-rewards { + margin-bottom: 14px; + } + + .game-star { + width: 56px; + height: 56px; + } + + .game-star svg { + width: 35px; + height: 35px; + } + + .wallet-copy > span { + font-size: 8.5px; + } + + .shop-grid { + grid-template-columns: 1fr; + gap: 7px; + } + + .shop-item { + display: grid; + grid-template-columns: 90px minmax(0, 1fr); + align-items: stretch; + } + + .shop-art { + height: 100%; + min-height: 96px; + } + + .shop-art::after { + bottom: 13px; + width: 50px; + } + + .shop-art svg { + width: 59px; + height: 59px; + } + + .shop-info { + padding: 10px 12px; + } + + .shop-info p { + min-height: 0; + margin-bottom: 7px; + } + + .equip-check { + top: 7px; + right: 7px; + } + + .result-stars .icon { + width: 21px; + height: 21px; + } + + .game-result h3 { + font-size: 19px; + } +} + +@media (max-width: 380px) { + .app { + width: calc(100% - 18px); + } + + .brand-mark { + width: 35px; + height: 35px; + } + + .brand-mark svg { + width: 28px; + height: 28px; + } + + .coin-menu { + padding-left: 6px; + padding-right: 8px; + } + + .care-panel { + padding: 15px; + } + + .care-heading h2 { + font-size: 19px; + } + + .level-badge { + width: 56px; + height: 55px; + } + + .level-badge strong { + font-size: 20px; + } + + .scene-badge { + padding: 0 8px; + font-size: 6.5px; + } + + .date-badge { + padding: 0 8px; + font-size: 7.5px; + } + + .speech-bubble { + top: 16%; + left: 4.5%; + width: 60%; + padding: 9px 10px; + } + + .speech-bubble p { + font-size: 10px; + } + + .nameplate-copy p { + max-width: 105px; + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; + } + + .quest-head .eyebrow { + letter-spacing: .08em; + } + + .refresh-note { + font-size: 8px; + } + + .quest-title { + font-size: 10px; + } +} + +@media (prefers-reduced-motion: reduce) { + *, + *::before, + *::after { + scroll-behavior: auto !important; + animation-duration: .01ms !important; + animation-iteration-count: 1 !important; + transition-duration: .01ms !important; + } + + .pet-svg, + .sun, + .stars i, + .fireflies i, + .motes i, + .hero-star, + .pet-button .eye { + animation: none !important; + } +} + +/* --------------------------------------------- + Keyframes + --------------------------------------------- */ + +@keyframes pet-float { + 0%, 100% { transform: translateY(0) rotate(-.25deg); } + 50% { transform: translateY(-7px) rotate(.3deg); } +} + +@keyframes eye-blink { + 0%, 91%, 97%, 100% { transform: scaleY(1); } + 93%, 95% { transform: scaleY(.08); } +} + +@keyframes tail-sway { + 0%, 100% { transform: rotate(-1.8deg); } + 50% { transform: rotate(2.8deg); } +} + +@keyframes ears-listen { + 0%, 88%, 96%, 100% { transform: rotate(0); } + 91% { transform: rotate(-2deg); } + 93% { transform: rotate(1.2deg); } +} + +@keyframes sprout-sway { + 0%, 100% { transform: rotate(-1.2deg); } + 50% { transform: rotate(2deg); } +} + +@keyframes munch { + 0%, 100% { transform: translateY(0) scale(1, 1); } + 35% { transform: translateY(3px) scale(1.015, .97); } + 65% { transform: translateY(-2px) scale(.99, 1.012); } +} + +@keyframes clean-wiggle { + 0%, 100% { transform: rotate(0); } + 30% { transform: rotate(-1.5deg); } + 70% { transform: rotate(1.5deg); } +} + +@keyframes pet-squish { + 0% { transform: translateY(0) scale(1); } + 35% { transform: translateY(5px) scale(1.025, .965); } + 65% { transform: translateY(-3px) scale(.99, 1.015); } + 100% { transform: translateY(0) scale(1); } +} + +@keyframes zzz-rise { + 0% { opacity: 0; transform: translate(-6px, 10px) rotate(-10deg) scale(.7); } + 22% { opacity: .9; } + 70% { opacity: .5; } + 100% { opacity: 0; transform: translate(13px, -28px) rotate(12deg) scale(1.08); } +} + +@keyframes sun-breathe { + 0%, 100% { transform: scale(1); opacity: .94; } + 50% { transform: scale(1.025); opacity: 1; } +} + +@keyframes cloud-drift { + from { transform: translateX(-8px); } + to { transform: translateX(16px); } +} + +@keyframes star-twinkle { + 0%, 100% { opacity: .3; transform: scale(.85); } + 45% { opacity: 1; transform: scale(1.15); } +} + +@keyframes mote-drift { + from { transform: translate(-5px, 4px); opacity: .25; } + to { transform: translate(10px, -13px); opacity: .75; } +} + +@keyframes firefly { + 0%, 100% { transform: translate(0, 0); opacity: .15; } + 35% { opacity: 1; } + 60% { transform: translate(8px, -12px); opacity: .35; } + 80% { transform: translate(-4px, -5px); opacity: .9; } +} + +@keyframes plant-sway { + 0%, 100% { transform: rotate(-.7deg); } + 50% { transform: rotate(1.2deg); } +} + +@keyframes speech-pop { + 0% { transform: translateY(4px) scale(.975); opacity: .7; } + 100% { transform: translateY(0) scale(1); opacity: 1; } +} + +@keyframes fx-rise { + 0% { + opacity: 0; + transform: translate(-50%, -50%) scale(.4) rotate(0deg); + } + 14% { opacity: 1; } + 100% { + opacity: 0; + transform: translate(calc(-50% + var(--dx, 10px)), calc(-50% - 86px)) scale(var(--scale, 1)) rotate(var(--rot, 8deg)); + } +} + +@keyframes bubble-rise { + 0% { + opacity: 0; + transform: translate(-50%, 0) scale(.5); + } + 18% { opacity: .85; } + 100% { + opacity: 0; + transform: translate(calc(-50% + var(--drift, 14px)), -125px) scale(1.08); + } +} + +@keyframes live-pulse { + 0%, 100% { box-shadow: 0 0 0 3px rgba(120, 149, 107, .1); } + 50% { box-shadow: 0 0 0 5px rgba(120, 149, 107, .03); } +} + +@keyframes low-pulse { + 0%, 100% { opacity: .62; } + 50% { opacity: 1; } +} + +@keyframes dialog-in { + from { opacity: 0; transform: translateY(14px) scale(.975); } + to { opacity: 1; transform: translateY(0) scale(1); } +} + +@keyframes backdrop-in { + from { opacity: 0; } + to { opacity: 1; } +} + +@keyframes hero-float { + 0%, 100% { transform: translateY(0) rotate(-8deg); } + 50% { transform: translateY(-8px) rotate(-3deg); } +} + +@keyframes game-star-in { + 0% { opacity: 0; transform: translate(-50%, -50%) scale(.25) rotate(-25deg); } + 70% { opacity: 1; transform: translate(-50%, -50%) scale(1.1) rotate(5deg); } + 100% { opacity: 1; transform: translate(-50%, -50%) scale(1) rotate(0); } +} + +@keyframes game-star-pulse { + 0% { transform: scale(.75); opacity: .5; } + 80%, 100% { transform: scale(1.45); opacity: 0; } +} + +@keyframes game-star-caught { + 0% { opacity: 1; transform: translate(-50%, -50%) scale(1); } + 50% { opacity: 1; transform: translate(-50%, -65%) scale(1.35); } + 100% { opacity: 0; transform: translate(-50%, -85%) scale(.3); } +} + +@keyframes game-star-gone { + 0% { opacity: 1; transform: translate(-50%, -50%) scale(1); } + 100% { opacity: 0; transform: translate(-50%, -60%) scale(.65); } +} + +@keyframes result-star-pop { + 0% { opacity: 0; transform: translateY(9px) scale(.4) rotate(-10deg); } + 100% { opacity: 1; transform: translateY(var(--offset, 0)) scale(1) rotate(var(--rotate, 0deg)); } +} + +@keyframes toast-in { + from { opacity: 0; transform: translateY(10px) scale(.97); } + to { opacity: 1; transform: translateY(0) scale(1); } +} + +@keyframes toast-out { + to { opacity: 0; transform: translateY(6px) scale(.98); } +} + +@keyframes item-shake { + 0%, 100% { transform: translateX(0); } + 25% { transform: translateX(-3px); } + 55% { transform: translateX(3px); } + 78% { transform: translateX(-2px); } +} + +@keyframes confetti-fall { + 0% { + opacity: 0; + transform: translate3d(0, -20px, 0) rotate(0); + } + 8% { opacity: 1; } + 100% { + opacity: 0; + transform: translate3d(var(--drift, 30px), 105vh, 0) rotate(var(--spin, 480deg)); + } +} \ No newline at end of file