diff --git a/css/game.css b/css/game.css index 2febed7..0814732 100644 --- a/css/game.css +++ b/css/game.css @@ -20,6 +20,23 @@ --warning: #f39c12; } +/* Touch Optimization */ +* { + -webkit-tap-highlight-color: transparent; + -webkit-touch-callout: none; + user-select: none; +} + +input, textarea { + user-select: text; + -webkit-user-select: text; +} + +body { + touch-action: manipulation; + -webkit-tap-highlight-color: transparent; +} + * { margin: 0; padding: 0; @@ -95,6 +112,7 @@ h2, h3 { display: flex; justify-content: center; margin: 20px 0; + position: relative; } #harvest-btn { @@ -110,6 +128,8 @@ h2, h3 { align-items: center; justify-content: center; box-shadow: 0 4px 20px rgba(155, 89, 182, 0.4); + position: relative; + overflow: hidden; } #harvest-btn:hover { @@ -121,6 +141,67 @@ h2, h3 { transform: scale(0.95); } +/* Pulse Animation */ +#harvest-btn::before { + content: ''; + position: absolute; + top: 50%; + left: 50%; + width: 100%; + height: 100%; + border-radius: 50%; + background: rgba(155, 89, 182, 0.3); + transform: translate(-50%, -50%); + animation: pulse 2s ease-in-out infinite; +} + +@keyframes pulse { + 0%, 100% { + width: 100%; + height: 100%; + opacity: 0.3; + } + 50% { + width: 120%; + height: 120%; + opacity: 0; + } +} + +/* Ripple Effect */ +.ripple { + position: absolute; + border-radius: 50%; + background: rgba(255, 255, 255, 0.6); + transform: scale(0); + animation: rippleEffect 0.6s linear; + pointer-events: none; +} + +@keyframes rippleEffect { + to { + transform: scale(4); + opacity: 0; + } +} + +/* Click button glow on active */ +#harvest-btn.clicked { + animation: clickGlow 0.2s ease; +} + +@keyframes clickGlow { + 0% { + box-shadow: 0 4px 20px rgba(155, 89, 182, 0.4); + } + 50% { + box-shadow: 0 0 40px rgba(255, 215, 0, 0.8), 0 4px 20px rgba(155, 89, 182, 0.6); + } + 100% { + box-shadow: 0 4px 20px rgba(155, 89, 182, 0.4); + } +} + .btn-icon { font-size: 3rem; } @@ -220,6 +301,85 @@ h2, h3 { opacity: 0.5; } +.buy-max-btn { + display: block; + width: 100%; + margin-top: 6px; + background: var(--accent-purple); + color: var(--text-primary); + border: none; + border-radius: 6px; + padding: 5px 12px; + cursor: pointer; + font-weight: bold; + font-size: 0.8rem; + transition: all 0.2s ease; +} + +.buy-max-btn:hover:not(:disabled) { + background: #9b59b6; + transform: scale(1.03); +} + +.buy-max-btn:disabled { + background: var(--text-muted); + cursor: not-allowed; + opacity: 0.4; +} + +/* Upgrades header + Buy All button */ +.upgrades-header { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 10px; +} + +.upgrades-header h3 { + margin: 0; +} + +.buy-all-btn { + background: var(--accent-gold); + color: var(--bg-primary, #1a1035); + border: none; + border-radius: 6px; + padding: 6px 14px; + cursor: pointer; + font-weight: bold; + font-size: 0.8rem; + transition: all 0.2s ease; +} + +.buy-all-btn:hover:not(:disabled) { + background: #f1c40f; + transform: scale(1.03); +} + +.buy-all-btn:disabled { + background: var(--text-muted); + color: var(--bg-primary, #1a1035); + cursor: not-allowed; + opacity: 0.4; +} + +/* Prestige Upgrades header + Buy Max button */ +.prestige-upgrades-header { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 10px; +} + +.prestige-upgrades-header h3 { + margin: 0; +} + +.prestige-buy-all { + background: var(--accent-gold); + color: var(--bg-primary, #1a1035); +} + /* Side Panel Tabs */ .tabs { display: flex; @@ -408,6 +568,123 @@ h2, h3 { border-color: var(--danger); } +/* Save Slot System */ +.footer-section { + display: flex; + align-items: center; + gap: 6px; + padding-right: 10px; + border-right: 1px solid var(--border-color); +} + +.footer-section:last-of-type { + border-right: none; + margin-left: auto; +} + +.footer-label { + color: var(--text-secondary); + font-size: 0.8rem; + font-weight: bold; +} + +.slot-btn { + background: var(--bg-card); + color: var(--text-secondary); + border: 1px solid var(--border-color); + border-radius: 6px; + padding: 6px 12px; + cursor: pointer; + font-weight: bold; + transition: all 0.2s ease; + min-width: 34px; +} + +.slot-btn:hover:not(:disabled) { + background: var(--bg-hover); + border-color: var(--accent-purple); + color: var(--text-primary); +} + +.slot-btn.active { + background: var(--accent-purple); + color: var(--text-primary); + border-color: var(--accent-gold); + box-shadow: 0 0 12px rgba(155, 89, 182, 0.6); +} + +.slot-btn:disabled { + cursor: default; + opacity: 0.5; +} + +/* Live one-line preview of the active/hovered save slot in the footer */ +.slot-preview { + color: var(--text-secondary); + font-size: 0.78rem; + font-family: monospace; + white-space: nowrap; + max-width: 320px; + overflow: hidden; + text-overflow: ellipsis; + opacity: 0.85; + transition: opacity 0.15s ease, color 0.15s ease; +} + +.slot-preview.empty { + color: var(--text-muted); + font-style: italic; + opacity: 0.6; +} + +#game-footer:hover .slot-preview, +.slot-preview:not(.empty):hover { + opacity: 1; + color: var(--accent-purple); +} + +/* Autosave toggle button */ +.toggle-btn { + background: var(--bg-card); + color: var(--text-secondary); + border: 1px solid var(--border-color); + border-radius: 6px; + padding: 8px 14px; + cursor: pointer; + font-weight: bold; + transition: all 0.2s ease; +} + +.toggle-btn.on { + background: var(--accent-green); + color: #0a0a1a; + border-color: var(--accent-green); +} + +.toggle-btn:not(.on) { + background: var(--bg-card); + color: var(--text-muted); +} + +/* Import modal slot selector */ +.import-slot-selector { + display: flex; + align-items: center; + gap: 6px; + margin: 12px 0; +} + +.import-slot-label { + color: var(--text-secondary); + font-size: 0.85rem; +} + +.modal-hint { + color: var(--text-muted); + font-size: 0.8rem; + margin-bottom: 10px; +} + #last-saved { margin-left: auto; color: var(--text-muted); @@ -605,7 +882,7 @@ h2, h3 { #main-layout { grid-template-columns: 1fr 1fr; } - + #side-panel { grid-column: span 2; } @@ -615,14 +892,305 @@ h2, h3 { #main-layout { grid-template-columns: 1fr; } - + #side-panel { grid-column: span 1; } - + #game-header { flex-direction: column; gap: 10px; text-align: center; } + + /* Touch-friendly button sizes */ + .buy-btn, .prestige-btn { + min-height: 44px; + padding: 12px 20px; + } + + .tab-btn { + min-height: 44px; + padding: 12px; + } + + #harvest-btn { + width: 180px; + height: 180px; + } + + /* Prevent double-tap zoom */ + button, a, input, select { + touch-action: manipulation; + } +} + +/* Mobile Touch Optimizations */ +@media (max-width: 480px) { + body { + overflow-x: hidden; + } + + #main-layout { + padding: 10px; + gap: 10px; + } + + section, aside { + padding: 15px; + } + + #game-header { + padding: 15px 20px; + } + + #game-footer { + flex-wrap: wrap; + justify-content: center; + padding: 10px 15px; + } + + #game-footer button { + flex: 1; + min-width: 80px; + } + + /* Larger touch targets */ + .building-card, .upgrade-card { + padding: 15px; + } + + .achievement { + padding: 12px; + } + + .achievement-icon { + font-size: 1.8rem; + } + + /* Prevent pull-to-refresh on scroll areas */ + #buildings-list, #upgrades-list, #prestige-upgrades-list { + overscroll-behavior: contain; + } +} + +/* ============================================ + PRESTIGE SYSTEM STYLES + ============================================ */ + +.prestige-btn { + background: linear-gradient(135deg, #9b59b6 0%, #8e44ad 100%); + color: var(--text-primary); + border: none; + border-radius: 6px; + padding: 8px 16px; + cursor: pointer; + font-weight: bold; + transition: all 0.2s ease; + box-shadow: 0 2px 10px rgba(155, 89, 182, 0.3); +} + +.prestige-btn:hover:not(:disabled) { + background: linear-gradient(135deg, #a569bd 0%, #9b59b6 100%); + transform: scale(1.05); + box-shadow: 0 4px 15px rgba(155, 89, 182, 0.5); +} + +.prestige-btn:disabled { + background: var(--text-muted); + cursor: not-allowed; + opacity: 0.5; + box-shadow: none; +} + +.prestige-btn.can-prestige { + animation: prestigeGlow 2s ease-in-out infinite; +} + +@keyframes prestigeGlow { + 0%, 100% { + box-shadow: 0 2px 10px rgba(155, 89, 182, 0.3); + } + 50% { + box-shadow: 0 2px 20px rgba(155, 89, 182, 0.6), 0 0 30px rgba(155, 89, 182, 0.4); + } +} + +/* Prestige Tab Styles */ +#prestige-info { + background: var(--bg-card); + border-radius: 8px; + padding: 15px; + border: 1px solid var(--border-color); + margin-bottom: 15px; +} + +.prestige-essence { + display: flex; + align-items: center; + gap: 8px; + font-size: 1.3rem; + font-weight: bold; + color: var(--accent-gold); + margin-bottom: 10px; +} + +.essence-icon { + font-size: 1.5rem; + color: #9b59b6; +} + +.prestige-multiplier { + color: var(--text-secondary); + margin-bottom: 8px; +} + +.prestige-multiplier span { + color: var(--accent-green); + font-weight: bold; +} + +.prestige-next { + color: var(--text-secondary); + font-size: 0.9rem; +} + +.prestige-next span { + color: var(--accent-purple); + font-weight: bold; +} + +/* Prestige Upgrade Cards */ +#prestige-upgrades-list { + display: flex; + flex-direction: column; + gap: 10px; + max-height: 300px; + overflow-y: auto; +} + +.prestige-upgrade-card { + background: var(--bg-card); + border-radius: 8px; + padding: 12px; + border: 1px solid var(--border-color); + cursor: pointer; + transition: all 0.2s ease; +} + +.prestige-upgrade-card:hover:not(.purchased):not(.locked) { + background: var(--bg-hover); + border-color: var(--accent-purple); +} + +.prestige-upgrade-card.purchased { + opacity: 0.6; + border-color: var(--accent-green); +} + +.prestige-upgrade-card.locked { + opacity: 0.4; + pointer-events: none; +} + +.prestige-upgrade-name { + font-weight: bold; + color: var(--text-primary); +} + +.prestige-upgrade-desc { + font-size: 0.85rem; + color: var(--text-secondary); + margin-top: 3px; +} + +.prestige-upgrade-cost { + color: #9b59b6; + font-size: 0.9rem; + margin-top: 5px; + font-weight: bold; +} + +/* Prestige Particle Effects */ +.particle.prestige-particle { + background: radial-gradient(circle, #ffd700 0%, #9b59b6 50%, transparent 100%); + box-shadow: 0 0 10px #ffd700, 0 0 20px #9b59b6; +} + +/* Stats Panel Prestige Info */ +.stat-item.prestige-stat { + border-color: var(--accent-purple); + background: linear-gradient(135deg, var(--bg-card) 0%, #2a1a3e 100%); +} + +/* ============================================ + Golden Starbloom Random Event + ============================================ */ + +/* Countdown badge in the header */ +.golden-bloom-badge { + font-size: 0.95rem; + font-weight: bold; + color: var(--text-secondary); + padding: 4px 12px; + border-radius: 20px; + border: 1px solid transparent; + white-space: nowrap; + opacity: 0; + transform: translateY(-6px); + transition: opacity 0.3s ease, transform 0.3s ease; +} + +.golden-bloom-badge.active { + opacity: 1; + transform: translateY(0); + color: var(--accent-gold); + background: rgba(255, 215, 0, 0.12); + border-color: var(--accent-gold); + box-shadow: 0 0 12px rgba(255, 215, 0, 0.35); +} + +/* The floating bloom itself */ +#golden-bloom-container { + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + pointer-events: none; +} + +.golden-bloom { + position: absolute; + font-size: 3rem; + cursor: pointer; + line-height: 1; + filter: drop-shadow(0 0 14px rgba(255, 215, 0, 0.9)); + animation: goldenBloomFloat 2s ease-in-out infinite, goldenBloomPulse 1.4s ease-in-out infinite; + transition: transform 0.1s ease; + pointer-events: auto; +} + +.golden-bloom:hover { + transform: scale(1.15); + filter: drop-shadow(0 0 22px rgba(255, 215, 0, 1)); +} + +@keyframes goldenBloomFloat { + 0%, 100% { translateY(0); } + 50% { translateY(-8px); } +} + +@keyframes goldenBloomPulse { + 0%, 100% { opacity: 0.85; } + 50% { opacity: 1; } +} + +/* Golden particle effect styles */ +.particle.golden-bloom-particle { + background: radial-gradient(circle, #fff2a8 0%, #ffd700 60%, transparent 100%); +} + +.particle.golden-sparkle { + background: radial-gradient(circle, #ffffff 0%, #ffe469 100%); } diff --git a/dev-journal.md b/dev-journal.md index e9f52c7..092c6e3 100644 --- a/dev-journal.md +++ b/dev-journal.md @@ -12,7 +12,7 @@ - Theme: "Cosmic Garden" - a space gardening idle game - Built core game engine with click harvesting, buildings, upgrades, achievements - Designed 12 buildings across 4 tiers (Star Sprout → Void Fern) -- Added 15+ upgrades and 20 achievements +- Added 15+ upgrades and 30 achievements - Implemented auto-save, offline progress, export/import - Built responsive dark-themed UI @@ -98,4 +98,347 @@ --- +--- + +### Session 12 - 2026-08-23 (Morning) +**Status:** ✅ Save Slot System Added + +#### What was done: +- Implemented a multi-slot save system with 3 independent, persistent save slots +- Each slot stores its own data under a dedicated localStorage key (`cosmicGardenSave-`), so switching slots never overwrites another slot's progress +- Added slot selection buttons in the footer (labeled 1/2/3) that switch between saves with a confirmation prompt and auto-save the current slot first +- Added an autosave toggle button (ON/OFF); when off, the periodic autosave loop is skipped, and the preference is persisted separately across page loads +- Enhanced the import modal with a per-slot selector so players can paste save data into a specific slot; importing into the active slot reloads it automatically +- Added `exportSlotToBase64()` / `importSlotFromData()` for backup/transfer of individual slots, plus `loadSlot()` helper +- Switching to an empty slot starts a fresh game while preserving Stellar Essence and prestige counters (so prestige currency is never lost when testing new runs) +- Added Save Slot + Autosave indicators to the Statistics panel, updated live each tick +- Fixed a latent bug: `reset()` did not include the `goldenBlooms` event field, which crashed `Stats.update()` on any reset. Also added the same missing field to the empty-slot branch of `switchSlot()`. Both were caught by an end-to-end test harness simulating localStorage/DOM/clipboard. +- Verified all 18 slot operations (save/load per slot, switch round-trips, empty-slot handling, export/import round-trip, persistence across reloads, reset preservation) with a Node.js DOM-simulation harness — 18/18 pass + +#### Files modified: +- `js/game.js` - Core save-slot logic: state fields (activeSlot, autoSave), SLOTS config, getSaveKey/loadSlot/switchSlot/exportSlotToBase64/importSlotFromData methods; wired slot + autosave + import-slot event listeners; updated save()/loadState()/reset()/startAutoSave() +- `js/ui.js` - Added updateSlotUI() to reflect active slot highlight and autosave toggle state +- `js/stats.js` - Added Active Save Slot and Autosave indicators, refreshed each tick +- `index.html` - Footer with slot buttons + autosave toggle; import modal slot selector; achievement count label corrected to 31/31 +- `css/game.css` - Styling for footer sections, slot buttons (active/disabled states), autosave toggle, and import-modal slot selector + +#### Next goals: +- Add a save-slot preview (e.g. show stardust total / buildings count / last-saved time) on hover or in the stats panel to help players tell slots apart +- Consider adding more than 3 slots via a config option or unlockable count +- Balance tweak: review overall game pacing now that prestige and random events are in place + *Journal auto-updated by Hermes Agent cron job (every 8 hours)* + +--- + +### Session 5 - 2026-08-20 (Afternoon) +**Status:** ✅ Prestige System Added + +#### What was done: +- Created complete prestige/reset system with Stellar Essence currency +- Formula: floor(sqrt(totalStardust / 1,000,000)) essence per prestige +- Each essence provides +10% production bonus (stacks multiplicatively) +- Added 6 prestige upgrades: Cosmic Fingers, Stellar Touch, Essence Amplifier, Quantum Garden, Accelerated Growth, Temporal Manipulation +- Created PrestigeUI module for rendering and managing prestige tab +- Added prestige particle effects (cosmic explosion on reset) +- Added prestige sound effect (ethereal whoosh via Web Audio API) +- Integrated prestige multiplier into click power, production, building costs, and offline progress +- Added prestige button to footer with glow animation when eligible +- Updated game state to persist prestige data across resets + +#### Files modified/created: +- `js/prestige.js` - New file with Prestige system (essence calculation, upgrades, multipliers) +- `js/prestigeUI.js` - New file with PrestigeUI module (tab rendering, event handling) +- `js/game.js` - Added prestige fields to state, integrated multipliers into calculations +- `js/particles.js` - Added createPrestigeEffect() method +- `js/audio.js` - Added playPrestige() sound effect +- `css/game.css` - Added prestige button styles, tab styles, upgrade card styles +- `index.html` - Added prestige tab and script tags +- `dev-journal.md` - Updated with session log + +#### Next goals: +- Achievement expansion (prestige-related achievements) +- Mobile touch optimizations +- Consider adding more visual polish to the click button + +--- + +### Session 6 - 2026-08-20 (Evening) +**Status:** ✅ Achievement Expansion Added + +#### What was done: +- Added 10 new achievements, bringing total from 20 to 30 +- New prestige achievements: Cosmic Rebirth (first prestige), Stellar Adaptation (5 prestiges), Cosmic Ascension (10 prestiges), Eternal Gardener (100 total essence), Quantum Growth (1,000 total essence) +- New building milestone achievements: Void Weaver (25 Void Ferns), Supernova Growth (25 Supernova Seeds) +- New click power achievements: Powerful Harvester (1,000 click power), Divine Touch (10,000 click power) +- New time-based achievement: Dedicated Gardener (play for 1 hour total) +- Updated achievement counter in UI from (0/20) to (0/30) +- Updated Session 1 journal entry to reflect 30 achievements + +#### Files modified: +- `js/achievements.js` - Added 10 new achievement definitions +- `index.html` - Updated achievement count display +- `dev-journal.md` - Updated session log and added Session 6 entry + +#### Next goals: +- Mobile touch optimizations (tap feedback, responsive layout) +- Consider adding more visual polish to the click button +- Achievement progress tracking in stats panel + +--- + +### Session 7 - 2026-08-21 (Morning) +**Status:** ✅ Mobile Touch Optimizations & Click Button Polish Added + +#### What was done: +- Added comprehensive touch optimization CSS with `-webkit-tap-highlight-color`, `touch-action: manipulation`, and `user-select` controls +- Implemented ripple effect animation on click button for visual feedback +- Added pulse animation to the harvest button (subtle breathing effect) +- Added glow flash animation on each click for satisfying feedback +- Added touchstart event handler with proper preventDefault to avoid double-firing +- Created responsive breakpoints at 768px and 480px for mobile devices +- Increased touch target sizes (44px minimum, per accessibility guidelines) +- Added overscroll-behavior: contain to scroll areas to prevent pull-to-refresh +- Made footer buttons wrap and center on small screens +- Enlarged harvest button on mobile (180px vs 150px desktop) + +#### Files modified: +- `js/game.js` - Added touch event handler, ripple effect creation, click animation methods +- `css/game.css` - Added touch optimizations, ripple animations, pulse effects, responsive breakpoints + +#### Next goals: +- Achievement progress tracking in stats panel +- Consider adding more sound variations or background music +- Potential new building tiers or prestige milestones + +--- + +### Session 9 - 2026-08-21 (Afternoon) +**Status:** ✅ Achievement Progress Tracking Added to Stats Panel + +#### What was done: +- Added "Next Achievement" section to the Statistics panel with a progress bar +- Implemented `calculateAchievementProgress()` method that computes progress toward every achievement type: + - Click achievements (firstClick, clicker, spammer, clickMaster) + - Stardust achievements (dustCollector, stardustMagnate, cosmicTycoon, galaxyBaron) + - Building count achievements (firstPlant, gardenStart, gardenCenter, cosmicGarden) + - Specific building achievements (sproutFarmer, bloomGarden, solarPowered, voidWeaver, supernovaGrowth) + - Production achievements (steadyStream, rushingRiver, cosmicFlood) + - Prestige achievements (firstPrestige, stellarAdaptation, cosmicAscension, eternalGardener, quantumGrowth) + - Click power achievements (powerfulHarvester, divineTouch) + - Time-based achievement (dedicatedGardener) +- `updateNextAchievement()` finds the closest unachieved milestone by progress percentage and displays it with a progress bar +- Progress bar updates every game tick via `Stats.update()` +- Shows "All achievements unlocked! 🎉" when all 30 achievements are complete + +#### Files modified: +- `js/stats.js` - Added "Next Achievement" stat item to render(), added updateNextAchievement() and calculateAchievementProgress() methods + +#### Next goals: +- Consider adding a "next essence at X stardust" progress hint in prestige tab (from Session 8 notes) +- Potential new building tiers or prestige milestones +- Sound variations or background music + +--- + +--- + +### Session 8 - 2026-08-21 (Afternoon) +**Status:** ✅ Critical Bugfix - Prestige System Now Actually Loads (reported by user) + +#### Bug report: +User reported (with screenshot) that clicking "Collect Stardust" no longer works +after the prestige feature shipped in Session 5. Console showed: +`Uncaught ReferenceError: PRESTIGE is not defined` (game.js line 159, in getClickPower) + +#### Root cause: +Session 5 created `js/prestige.js` + `js/prestigeUI.js` and rewired `game.js` to +call `PRESTIGE`/`PRESTIGE_UPGRADES` in click power, production, and offline +earnings — but **never added the two script tags to `index.html`**. So: +1. `PRESTIGE` was undefined → `getClickPower()` threw on every tick (250ms) and + every click → clicking did nothing, console flooded with errors. +2. `Game.init()` calls `PrestigeUI.init()` → crashed the whole init sequence + after `UI.init()`, so even the harvest click listener was never attached. +3. A second latent bug in `js/particles.js`: missing comma between + `createAmbientStars()` and `createPrestigeEffect()` (added in Session 5) + made the whole file fail to parse → `Particles` undefined → would have + broken `harvest()` even after fix #1. +4. The prestige tab UI (essence display, prestige button, upgrade list) was + also never added to `index.html`. + +#### Fixes applied: +- `index.html` - added ` + + diff --git a/js/achievements.js b/js/achievements.js index d88279b..552b09a 100644 --- a/js/achievements.js +++ b/js/achievements.js @@ -135,7 +135,7 @@ const ACHIEVEMENTS = { icon: '💧', condition: (state) => Game.getProductionPerSecond() >= 100 }, - + rushingRiver: { id: 'rushingRiver', name: 'Rushing River', @@ -143,13 +143,122 @@ const ACHIEVEMENTS = { icon: '🌊', condition: (state) => Game.getProductionPerSecond() >= 1000 }, - + cosmicFlood: { id: 'cosmicFlood', name: 'Cosmic Flood', description: 'Reach 100,000 stardust/sec', icon: '🌊', condition: (state) => Game.getProductionPerSecond() >= 100000 + }, + + // Prestige achievements + firstPrestige: { + id: 'firstPrestige', + name: 'Cosmic Rebirth', + description: 'Prestige for the first time', + icon: '🌀', + condition: (state) => state.prestigeCount >= 1 + }, + + stellarAdaptation: { + id: 'stellarAdaptation', + name: 'Stellar Adaptation', + description: 'Prestige 5 times', + icon: '⭐', + condition: (state) => state.prestigeCount >= 5 + }, + + cosmicAscension: { + id: 'cosmicAscension', + name: 'Cosmic Ascension', + description: 'Prestige 10 times', + icon: '🌟', + condition: (state) => state.prestigeCount >= 10 + }, + + eternalGardener: { + id: 'eternalGardener', + name: 'Eternal Gardener', + description: 'Earn 100 total Stellar Essence', + icon: '💎', + condition: (state) => state.totalPrestigeEssence >= 100 + }, + + quantumGrowth: { + id: 'quantumGrowth', + name: 'Quantum Growth', + description: 'Earn 1,000 total Stellar Essence', + icon: '🔮', + condition: (state) => state.totalPrestigeEssence >= 1000 + }, + + // Building milestone achievements + voidWeaver: { + id: 'voidWeaver', + name: 'Void Weaver', + description: 'Own 25 Void Ferns', + icon: '🌿', + condition: (state) => (state.buildings.voidFern || 0) >= 25 + }, + + supernovaGrowth: { + id: 'supernovaGrowth', + name: 'Supernova Growth', + description: 'Own 25 Supernova Seeds', + icon: '💥', + condition: (state) => (state.buildings.supernovaSeed || 0) >= 25 + }, + + // Click power achievements + powerfulHarvester: { + id: 'powerfulHarvester', + name: 'Powerful Harvester', + description: 'Reach 1,000 click power', + icon: '✋', + condition: (state) => Game.getClickPower() >= 1000 + }, + + divineTouch: { + id: 'divineTouch', + name: 'Divine Touch', + description: 'Reach 10,000 click power', + icon: '🤚', + condition: (state) => Game.getClickPower() >= 10000 + }, + + // Time-based achievement + dedicatedGardener: { + id: 'dedicatedGardener', + name: 'Dedicated Gardener', + description: 'Play for 1 hour total', + icon: '⏰', + condition: (state) => (Date.now() - state.startTime) >= 3600000 + }, + + // Golden Starbloom achievements + firstGoldenBloom: { + id: 'firstGoldenBloom', + name: 'Lucky Bloom', + description: 'Claim your first Golden Starbloom', + icon: '⭐', + condition: (state) => (state.goldenBlooms.totalClaimed || 0) >= 1 + }, + + goldenCollector: { + id: 'goldenCollector', + name: 'Golden Collector', + description: 'Claim 25 Golden Blooms', + icon: '🌟', + condition: (state) => (state.goldenBlooms.totalClaimed || 0) >= 25 + }, + + goldenEnthusiast: { + id: 'goldenEnthusiast', + name: 'Golden Enthusiast', + description: 'Claim 100 Golden Blooms', + icon: '💛', + condition: (state) => (state.goldenBlooms.totalClaimed || 0) >= 100 } }; diff --git a/js/audio.js b/js/audio.js index cfaeaf4..a58c4b0 100644 --- a/js/audio.js +++ b/js/audio.js @@ -1,119 +1,207 @@ /** * Cosmic Garden - Audio System - * Web Audio API-based sound generation (no external assets needed) + * Sound effects using Web Audio API (no external assets needed) */ const Audio = { ctx: null, - enabled: true, - + /** - * Initialize audio context (must be called after user interaction) + * Initialize audio context */ init() { try { - window.AudioContext = window.AudioContext || window.webkitAudioContext; - this.ctx = new AudioContext(); - console.log('🔊 Audio system initialized'); + this.ctx = new (window.AudioContext || window.webkitAudioContext)(); } catch (e) { - console.warn('Web Audio API not supported'); - this.enabled = false; + console.log('Web Audio API not supported'); } }, - + /** - * Resume audio context (for browsers that suspend it) + * Resume audio context (needed for some browsers) */ resume() { if (this.ctx && this.ctx.state === 'suspended') { this.ctx.resume(); } }, - + /** - * Create a simple tone + * Play click sound - rising C-major arpeggio */ - playTone(frequency, duration, type = 'sine', volume = 0.1) { - if (!this.enabled || !this.ctx) return; + playClick() { + if (!this.ctx) return; + this.resume(); + + const now = this.ctx.currentTime; + const notes = [523.25, 659.25, 783.99]; // C5, E5, G5 + notes.forEach((freq, i) => { + const osc = this.ctx.createOscillator(); + const gain = this.ctx.createGain(); + + osc.connect(gain); + gain.connect(this.ctx.destination); + + osc.frequency.value = freq; + osc.type = 'sine'; + + gain.gain.setValueAtTime(0.1, now + i * 0.05); + gain.gain.exponentialRampToValueAtTime(0.01, now + i * 0.05 + 0.15); + + osc.start(now + i * 0.05); + osc.stop(now + i * 0.05 + 0.15); + }); + }, + + /** + * Play purchase sound - descending chime + */ + playPurchase() { + if (!this.ctx) return; + this.resume(); + + const now = this.ctx.currentTime; + const notes = [783.99, 659.25, 523.25]; // G5, E5, C5 + + notes.forEach((freq, i) => { + const osc = this.ctx.createOscillator(); + const gain = this.ctx.createGain(); + + osc.connect(gain); + gain.connect(this.ctx.destination); + + osc.frequency.value = freq; + osc.type = 'triangle'; + + gain.gain.setValueAtTime(0.15, now + i * 0.08); + gain.gain.exponentialRampToValueAtTime(0.01, now + i * 0.08 + 0.2); + + osc.start(now + i * 0.08); + osc.stop(now + i * 0.08 + 0.2); + }); + }, + + /** + * Play achievement sound - ascending chord fanfare + */ + playAchievement() { + if (!this.ctx) return; + this.resume(); + + const now = this.ctx.currentTime; + const chords = [ + [523.25, 659.25, 783.99], // C major + [659.25, 783.99, 987.77], // E major + [783.99, 987.77, 1174.66] // G major + ]; + + chords.forEach((chord, chordIndex) => { + chord.forEach(freq => { + const osc = this.ctx.createOscillator(); + const gain = this.ctx.createGain(); + + osc.connect(gain); + gain.connect(this.ctx.destination); + + osc.frequency.value = freq; + osc.type = 'sine'; + + const startTime = now + chordIndex * 0.15; + gain.gain.setValueAtTime(0.12, startTime); + gain.gain.exponentialRampToValueAtTime(0.01, startTime + 0.3); + + osc.start(startTime); + osc.stop(startTime + 0.3); + }); + }); + }, + + /** + * Play prestige sound - ethereal whoosh + */ + playPrestige() { + if (!this.ctx) return; + this.resume(); + + const now = this.ctx.currentTime; + + // Rising sweep const osc = this.ctx.createOscillator(); const gain = this.ctx.createGain(); - osc.type = type; - osc.frequency.setValueAtTime(frequency, this.ctx.currentTime); - - gain.gain.setValueAtTime(volume, this.ctx.currentTime); - gain.gain.exponentialRampToValueAtTime(0.01, this.ctx.currentTime + duration); - osc.connect(gain); gain.connect(this.ctx.destination); - osc.start(this.ctx.currentTime); - osc.stop(this.ctx.currentTime + duration); - }, - - /** - * Play a rising arpeggio (for clicks) - */ - playClick() { - if (!this.enabled) return; - this.resume(); + osc.type = 'sine'; + osc.frequency.setValueAtTime(200, now); + osc.frequency.exponentialRampToValueAtTime(800, now + 0.5); + gain.gain.setValueAtTime(0.2, now); + gain.gain.exponentialRampToValueAtTime(0.01, now + 0.8); + + osc.start(now); + osc.stop(now + 0.8); + + // Sparkle overlay + for (let i = 0; i < 5; i++) { + const sparkle = this.ctx.createOscillator(); + const sparkleGain = this.ctx.createGain(); + + sparkle.connect(sparkleGain); + sparkleGain.connect(this.ctx.destination); + + sparkle.frequency.value = 1200 + Math.random() * 800; + sparkle.type = 'sine'; + + const startTime = now + 0.1 + i * 0.1; + sparkleGain.gain.setValueAtTime(0.08, startTime); + sparkleGain.gain.exponentialRampToValueAtTime(0.01, startTime + 0.2); + + sparkle.start(startTime); + sparkle.stop(startTime + 0.2); + } + }, + + /** + * Play golden starbloom sound - an ethereal shimmering arpeggio with a + * rising sweep, to distinguish it from regular purchase sounds. + */ + playGoldenBloom() { + if (!this.ctx) return; + this.resume(); + const now = this.ctx.currentTime; - const notes = [523.25, 659.25, 783.99]; // C major chord - - notes.forEach((freq, i) => { - setTimeout(() => { - this.playTone(freq, 0.1, 'sine', 0.08); - }, i * 30); + + // Rising magical sweep (like the prestige sound but brighter). + const sweep = this.ctx.createOscillator(); + const sweepGain = this.ctx.createGain(); + sweep.connect(sweepGain); + sweepGain.connect(this.ctx.destination); + sweep.type = 'sine'; + sweep.frequency.setValueAtTime(300, now); + sweep.frequency.exponentialRampToValueAtTime(1200, now + 0.6); + sweepGain.gain.setValueAtTime(0.18, now); + sweepGain.gain.exponentialRampToValueAtTime(0.01, now + 0.7); + sweep.start(now); + sweep.stop(now + 0.7); + + // A bright, ascending shimmer chord played quickly. + const shimmerNotes = [1046.5, 1318.5, 1568.0, 2093.0]; // C5, E5, G5, C6 + shimmerNotes.forEach((freq, i) => { + const osc = this.ctx.createOscillator(); + const gain = this.ctx.createGain(); + osc.connect(gain); + gain.connect(this.ctx.destination); + osc.frequency.value = freq; + osc.type = 'triangle'; + const startTime = now + 0.05 + i * 0.06; + gain.gain.setValueAtTime(0.1, startTime); + gain.gain.exponentialRampToValueAtTime(0.01, startTime + 0.25); + osc.start(startTime); + osc.stop(startTime + 0.25); }); - }, - - /** - * Play a purchase sound (descending chime) - */ - playPurchase() { - if (!this.enabled) return; - this.resume(); - - const notes = [783.99, 659.25, 523.25]; // G - E - C - - notes.forEach((freq, i) => { - setTimeout(() => { - this.playTone(freq, 0.15, 'triangle', 0.1); - }, i * 50); - }); - }, - - /** - * Play an achievement unlock fanfare - */ - playAchievement() { - if (!this.enabled) return; - this.resume(); - - const notes = [523.25, 659.25, 783.99, 1046.50]; // C - E - G - C (high) - - notes.forEach((freq, i) => { - setTimeout(() => { - this.playTone(freq, 0.2, 'square', 0.06); - }, i * 80); - }); - }, - - /** - * Play a notification sound - */ - playNotification() { - if (!this.enabled) return; - this.resume(); - - this.playTone(880, 0.1, 'sine', 0.08); - setTimeout(() => { - this.playTone(1100, 0.15, 'sine', 0.08); - }, 100); } }; -// Make globally accessible window.Audio = Audio; diff --git a/js/game.js b/js/game.js index e16b8f0..755d0e8 100644 --- a/js/game.js +++ b/js/game.js @@ -14,14 +14,38 @@ const Game = { lastSaveTime: Date.now(), buildings: {}, upgrades: [], - achievements: [] + achievements: [], + stellarEssence: 0, + prestigeCount: 0, + totalPrestigeEssence: 0, + prestigeUpgrades: [], + + // Save Slot System + activeSlot: 0, // index of the currently selected save slot (0..SLOTS-1) + autoSave: true, // whether the periodic autosave loop is running + + // Golden Starbloom random event state + goldenBlooms: { + active: false, // is a bloom currently visible on screen? + timer: 0, // seconds until the bloom vanishes + lastSpawn: Date.now(), // ms when we last (re)spawned one + totalClaimed: 0 // lifetime count of blooms the player has clicked + } }, // Configuration config: { tickRate: 100, // ms per tick (10 ticks/sec) autoSaveInterval: 30000, // 30 seconds - costScaling: 1.15 // Cost multiplier per building level + costScaling: 1.15, // Cost multiplier per building level + + // Save Slot System - number of independent save slots available + SLOTS: 3, + + goldenBloomMinMs: 45000, // min ms between bloom spawns (45s) + goldenBloomMaxMs: 90000, // max ms between bloom spawns (90s) + goldenBloomDuration: 12, // seconds a visible bloom stays before vanishing + goldenBloomBonusMult: 15 // production-per-second worth of stardust awarded }, /** @@ -35,6 +59,7 @@ const Game = { Audio.init(); UI.init(); Stats.init(); + PrestigeUI.init(); console.log('🌌 Cosmic Garden initialized!'); }, @@ -42,11 +67,33 @@ const Game = { * Setup event listeners */ setupEventListeners() { - // Harvest button - document.getElementById('harvest-btn').addEventListener('click', (e) => { + // Track which slot the user has selected for importing. + this.importSlot = 0; + + // Harvest button - support both click and touch + const harvestBtn = document.getElementById('harvest-btn'); + + harvestBtn.addEventListener('click', (e) => { this.harvest(e); + this.createRipple(harvestBtn, e); + this.animateClickButton(harvestBtn); }); + // Touch support for better mobile experience + harvestBtn.addEventListener('touchstart', (e) => { + e.preventDefault(); // Prevent double-fire with click + const touch = e.touches[0]; + const clickEvent = new MouseEvent('click', { + clientX: touch.clientX, + clientY: touch.clientY, + bubbles: true + }); + + // Create ripple effect for touch + this.createRipple(harvestBtn, { clientX: touch.clientX, clientY: touch.clientY }); + this.animateClickButton(harvestBtn); + }, { passive: false }); + // Save buttons document.getElementById('save-btn').addEventListener('click', () => { this.save(); @@ -64,9 +111,12 @@ const Game = { document.getElementById('confirm-import').addEventListener('click', () => { const data = document.getElementById('import-data').value; if (data) { - this.loadFromData(data); + const ok = this.importSlotFromData(data, this.importSlot); UI.hideImportModal(); - UI.showNotification('Save imported!', 'success'); + if (ok) { + // If the import landed in the active slot, switchSlot already re-rendered. + UI.update(); + } } }); @@ -74,12 +124,51 @@ const Game = { UI.hideImportModal(); }); + // Buy All upgrades button. + const buyAllBtn = document.getElementById('buy-all-upgrades'); + if (buyAllBtn) { + buyAllBtn.addEventListener('click', () => { + Game.buyAllUpgrades(); + }); + } + document.getElementById('reset-btn').addEventListener('click', () => { if (confirm('Are you sure you want to reset all progress? This cannot be undone!')) { this.reset(); } }); + // Save Slot System: slot selection buttons. + document.querySelectorAll('.slot-btn').forEach(btn => { + btn.addEventListener('click', () => { + if (this.state.activeSlot === parseInt(btn.dataset.slot, 10)) { + UI.showNotification(`Already on Slot ${parseInt(btn.dataset.slot, 10) + 1}`, 'info'); + return; + } + if (confirm(`Switch to Slot ${parseInt(btn.dataset.slot, 10) + 1}?\nUnsaved changes will be saved first.`)) { + this.save(); // Persist current slot before switching. + this.switchSlot(parseInt(btn.dataset.slot, 10)); + } + }); + }); + + // Autosave toggle button. + document.getElementById('autosave-btn').addEventListener('click', () => { + this.state.autoSave = !this.state.autoSave; + const btn = document.getElementById('autosave-btn'); + if (this.state.autoSave) { + btn.classList.add('on'); + btn.textContent = 'ON'; + UI.showNotification('Autosave enabled', 'success'); + } else { + btn.classList.remove('on'); + btn.textContent = 'OFF'; + UI.showNotification('Autosave disabled', 'info'); + } + // Persist the toggle preference immediately. + localStorage.setItem('cosmicGardenAutoSave', String(this.state.autoSave)); + }); + // Tab switching document.querySelectorAll('.tab-btn').forEach(btn => { btn.addEventListener('click', (e) => { @@ -90,6 +179,14 @@ const Game = { document.getElementById(`${tab}-tab`).classList.add('active'); }); }); + + // Import slot selector buttons. + document.querySelectorAll('[data-import-slot]').forEach(btn => { + btn.addEventListener('click', () => { + this.importSlot = parseInt(btn.dataset.importSlot, 10); + UI.showNotification(`Importing into Slot ${this.importSlot + 1}`, 'info'); + }); + }); }, /** @@ -131,6 +228,18 @@ const Game = { } } + // Apply prestige multipliers + const prestigeMultiplier = PRESTIGE.getMultiplier(); + power *= prestigeMultiplier; + + // Apply prestige upgrades + for (const upgradeId of (this.state.prestigeUpgrades || [])) { + const upgrade = PRESTIGE_UPGRADES[upgradeId]; + if (upgrade && upgrade.effect === 'clickMultiplier') { + power *= upgrade.value; + } + } + return Math.floor(power); }, @@ -148,6 +257,22 @@ const Game = { let production = building.baseProduction * count; + // Apply prestige multiplier + const prestigeMultiplier = PRESTIGE.getMultiplier(); + production *= prestigeMultiplier; + + // Apply Golden Starbloom temporary production boost (+50%) + if (this.state.productionBoostUntil && this.state.productionBoostUntil > Date.now()) { + production *= 1.5; + } + // Apply prestige upgrades + for (const upgradeId of (this.state.prestigeUpgrades || [])) { + const upgrade = PRESTIGE_UPGRADES[upgradeId]; + if (upgrade && upgrade.effect === 'productionMultiplier') { + production *= upgrade.value; + } + } + // Apply upgrade bonuses for (const upgradeId of this.state.upgrades) { const upgrade = UPGRADES[upgradeId]; @@ -171,11 +296,21 @@ const Game = { getBuildingCost(buildingId) { const building = BUILDINGS[buildingId]; const count = this.state.buildings[buildingId] || 0; - return Math.floor(building.baseCost * Math.pow(this.config.costScaling, count)); + let cost = Math.floor(building.baseCost * Math.pow(this.config.costScaling, count)); + + // Apply prestige cost reduction + for (const upgradeId of (this.state.prestigeUpgrades || [])) { + const upgrade = PRESTIGE_UPGRADES[upgradeId]; + if (upgrade && upgrade.effect === 'costReduction') { + cost *= upgrade.value; + } + } + + return Math.floor(cost); }, /** - * Buy a building + * Buy a single building */ buyBuilding(buildingId) { const cost = this.getBuildingCost(buildingId); @@ -203,6 +338,63 @@ const Game = { return false; }, + /** + * Calculate how many of a building the player can currently afford. + * Does not modify game state. + */ + getMaxAffordable(buildingId) { + const building = BUILDINGS[buildingId]; + if (!building) return 0; + + let stardust = this.state.stardust; + let count = 0; + let owned = this.state.buildings[buildingId] || 0; + + while (true) { + const cost = Math.floor(building.baseCost * Math.pow(this.config.costScaling, owned + count)); + if (stardust < cost) break; + stardust -= cost; + count++; + } + + return count; + }, + + /** + * Buy as many of a building as the player can afford at once. + * Returns the number purchased. + */ + buyMaxBuilding(buildingId) { + const building = BUILDINGS[buildingId]; + if (!building) return 0; + + let count = 0; + while (this.state.stardust >= this.getBuildingCost(buildingId)) { + const cost = this.getBuildingCost(buildingId); + this.state.stardust -= cost; + this.state.buildings[buildingId] = (this.state.buildings[buildingId] || 0) + 1; + count++; + } + + if (count > 0) { + UI.showNotification(`Bought ${count}x ${building.name}!`, 'success'); + + // Audio feedback + Audio.playPurchase(); + + // Particle effect on building purchase + const card = document.getElementById(`building-${buildingId}`); + if (card) { + const rect = card.getBoundingClientRect(); + Particles.createBuildingEffect(rect.left + rect.width / 2, rect.top + rect.height / 2); + } + + UI.update(); + } + + return count; + }, + /** * Buy an upgrade */ @@ -235,6 +427,179 @@ const Game = { return false; }, + /** + * Buy every upgrade the player can currently afford at once. + * Returns an object describing what happened: { bought: count, ids: [...] }. + * Upgrades are one-time purchases, so we loop until nothing affordable remains. + */ + buyAllUpgrades() { + const bought = []; + // Loop until a pass buys nothing (each purchase may unlock new affordability). + let progress = true; + while (progress) { + progress = false; + for (const [id, upgrade] of Object.entries(UPGRADES)) { + if (this.state.upgrades.includes(id)) continue; + // Respect requirements before attempting purchase. + if (upgrade.requirements) { + let met = true; + for (const [reqId, reqCount] of Object.entries(upgrade.requirements)) { + if ((this.state.buildings[reqId] || 0) < reqCount) { + met = false; + break; + } + } + if (!met) continue; + } + if (this.state.stardust >= upgrade.cost) { + this.state.stardust -= upgrade.cost; + this.state.upgrades.push(id); + bought.push(id); + progress = true; // another purchase may have unlocked more affordability + } + } + } + + if (bought.length > 0) { + UI.showNotification(`Bought ${bought.length} upgrade(s)!`, 'success'); + Audio.playPurchase(); + UI.update(); + } + + return { bought: bought.length, ids: bought }; + }, + + /** + * Buy every prestige upgrade the player can currently afford at once, + * paid from Stellar Essence. Returns an object describing what happened: + * { bought: count, ids: [...] }. Prestige upgrades are one-time purchases, + * so we loop until a full pass buys nothing (purchasing one never unlocks + * another, but the loop is kept for symmetry with buyAllUpgrades and to + * stay safe should future prestige upgrades gain requirement gates). + */ + buyMaxPrestigeUpgrades() { + const bought = []; + let progress = true; + while (progress) { + progress = false; + for (const [id, upgrade] of Object.entries(PRESTIGE_UPGRADES)) { + if ((this.state.prestigeUpgrades || []).includes(id)) continue; + // Respect requirements before attempting purchase, mirroring + // PRESTIGE.buyUpgrade semantics. + if (upgrade.requirements) { + let met = true; + for (const [reqId, reqCount] of Object.entries(upgrade.requirements)) { + if ((this.state.buildings[reqId] || 0) < reqCount) { + met = false; + break; + } + } + if (!met) continue; + } + if ((this.state.stellarEssence || 0) >= upgrade.cost) { + this.state.prestigeUpgrades = this.state.prestigeUpgrades || []; + this.state.prestigeUpgrades.push(id); + // Spend Stellar Essence, mirroring PRESTIGE.buyUpgrade. + this.state.stellarEssence -= upgrade.cost; + bought.push(id); + progress = true; + } + } + } + + if (bought.length > 0) { + UI.showNotification(`Bought ${bought.length} prestige upgrade(s)!`, 'achievement'); + Audio.playPurchase(); + UI.update(); + } + + return { bought: bought.length, ids: bought }; + }, + + /** + * Golden Starbloom - random event system. + * A rare, time-limited bloom occasionally appears on screen. Clicking it + * awards a stardust bonus and grants a temporary production boost. + */ + + /** + * Seconds remaining until the current bloom vanishes (0 if none active). + */ + getGoldenBloomRemaining() { + return this.state.goldenBlooms.active ? Math.ceil(this.state.goldenBlooms.timer) : 0; + }, + + /** + * Award a Golden Starbloom bonus and apply the production multiplier. + * Called when the player clicks a visible bloom. Returns the amount earned. + */ + claimGoldenBloom(x, y) { + const state = this.state.goldenBlooms; + + // No active bloom - nothing to claim. + if (!state.active) return 0; + + state.active = false; + state.timer = 0; + state.totalClaimed++; + + // Bonus: goldenBloomBonusMult x current production per second (min 1). + const bonus = Math.max(1, Math.floor(this.getProductionPerSecond() * this.config.goldenBloomBonusMult)); + this.addStardust(bonus); + + // Temporary production boost: +50% for 30 seconds. + this.state.productionBoostUntil = Date.now() + 30000; + + UI.showNotification('⭐ Golden Starbloom! +' + this.formatNumber(bonus) + ' ✨', 'achievement'); + Audio.playGoldenBloom(); + Particles.createGoldenEffect(x, y); + UI.update(); + return bonus; + }, + + /** + * Update golden bloom scheduling on each tick. + * Spawns a new bloom if none is active and enough time has elapsed; + * decrements the countdown of an active bloom until it vanishes. + */ + updateGoldenBloom(dtSeconds) { + const state = this.state.goldenBlooms; + + // If a bloom is still on screen, count down its lifetime. + if (state.active) { + state.timer -= dtSeconds; + if (state.timer <= 0) { + state.active = false; + state.timer = 0; + } + return; + } + + // Otherwise, check whether it is time to spawn the next bloom. + const elapsed = Date.now() - state.lastSpawn; + const minMs = this.config.goldenBloomMinMs; + const maxMs = this.config.goldenBloomMaxMs; + if (elapsed >= minMs) { + // Wait until somewhere in [min, max] before spawning. + if (elapsed >= maxMs || Math.random() < 0.05) { + state.active = true; + state.timer = this.config.goldenBloomDuration; + state.lastSpawn = Date.now(); + UI.spawnGoldenBloom(); + } + } + }, + + /** + * Whether the temporary production boost is currently active, and how + * many seconds remain. + */ + getProductionBoostInfo() { + const until = this.state.productionBoostUntil || 0; + if (until <= Date.now()) return { active: false, remaining: 0 }; + return { active: true, remaining: Math.ceil((until - Date.now()) / 1000) }; + }, + /** * Game tick - called every config.tickRate ms */ @@ -246,6 +611,9 @@ const Game = { this.addStardust(production * tickSeconds); } + // Advance the Golden Starbloom random event clock. + this.updateGoldenBloom(tickSeconds); + UI.update(); Achievements.check(); }, @@ -262,6 +630,8 @@ const Game = { */ startAutoSave() { setInterval(() => { + // Only autosave when the player has enabled it (default on). + if (!this.state.autoSave) return; this.save(); document.getElementById('last-saved').textContent = `Last saved: ${new Date().toLocaleTimeString()}`; @@ -269,28 +639,59 @@ const Game = { }, /** - * Save game state + * localStorage key for a given slot's save data. + */ + getSaveKey(slot) { + return `cosmicGardenSave-${slot}`; + }, + + /** + * The key holding the selected slot index across page loads. + */ + getActiveSlotKey() { + return 'cosmicGardenActiveSlot'; + }, + + /** + * Save game state to the currently active slot. */ save() { this.state.lastSaveTime = Date.now(); - localStorage.setItem('cosmicGardenSave', JSON.stringify(this.state)); + localStorage.setItem(this.getSaveKey(this.state.activeSlot), JSON.stringify(this.state)); }, /** * Load game state */ loadState() { - const saved = localStorage.getItem('cosmicGardenSave'); + // Restore the persisted selected-slot index before reading its data. + const persistedSlot = localStorage.getItem(this.getActiveSlotKey()); + if (persistedSlot !== null) { + this.state.activeSlot = parseInt(persistedSlot, 10) || 0; + } + + const saved = localStorage.getItem(this.getSaveKey(this.state.activeSlot)); if (saved) { try { const parsed = JSON.parse(saved); this.state = { ...this.state, ...parsed }; + // The explicitly-persisted slot selection always wins. + if (persistedSlot !== null) { + this.state.activeSlot = parseInt(persistedSlot, 10) || 0; + } // Calculate offline progress const offlineTime = (Date.now() - this.state.lastSaveTime) / 1000; if (offlineTime > 60) { const production = this.getProductionPerSecond(); - const offlineEarnings = production * offlineTime * 0.5; // 50% offline efficiency + let offlineRate = 0.5; // 50% offline efficiency + for (const upgradeId of (this.state.prestigeUpgrades || [])) { + const upgrade = PRESTIGE_UPGRADES[upgradeId]; + if (upgrade && upgrade.effect === 'offlineBonus') { + offlineRate = upgrade.value; + } + } + const offlineEarnings = production * offlineTime * offlineRate; if (offlineEarnings > 0) { this.addStardust(offlineEarnings); UI.showNotification( @@ -318,6 +719,196 @@ const Game = { } }, + /** + * Load the data stored in a specific slot index (without changing it). + * Returns the parsed state, or null if that slot is empty. + */ + loadSlot(slot) { + const raw = localStorage.getItem(this.getSaveKey(slot)); + if (!raw) return null; + try { + return JSON.parse(raw); + } catch (e) { + console.error(`Failed to read slot ${slot}:`, e); + return null; + } + }, + + /** + * Build a lightweight preview of a given save slot WITHOUT mutating the + * live game state. Returns an object describing what that slot holds: + * - present: whether the slot has any saved data + * - stardust: current (live) stardust production/sec for the slot + * - totalStardust: lifetime stardust earned in the slot + * - buildings: total number of buildings owned in the slot + * - achievements: count/total of achievements unlocked in the slot + * - prestigeCount: how many times the slot has been prestiged + * - lastSaved: human-readable "time ago" string for the slot's save time + * An empty/unreadable slot returns present:false with zeroed fields. + */ + getSlotPreview(slot) { + const data = this.loadSlot(slot); + if (!data) { + return { + present: false, + stardust: 0, + totalStardust: 0, + buildings: 0, + achievements: [0, 33], + prestigeCount: 0, + lastSaved: 'never' + }; + } + + const buildingCount = Object.values(data.buildings || {}) + .reduce((sum, c) => sum + (Number(c) || 0), 0); + + let timeAgo = 'never'; + if (typeof data.lastSaveTime === 'number') { + const secs = Math.max(0, Math.floor((Date.now() - data.lastSaveTime) / 1000)); + timeAgo = this.formatTimeAgo(secs); + } + + return { + present: true, + stardust: Number(data.stardust) || 0, + totalStardust: Number(data.totalStardust) || 0, + buildings: buildingCount, + achievements: [ + Array.isArray(data.achievements) ? data.achievements.length : 0, + 33 + ], + prestigeCount: Number(data.prestigeCount) || 0, + lastSaved: timeAgo + }; + }, + + /** + * Format a number of seconds as a compact "time ago" string + * (e.g. "just now", "1m ago", "3h ago", "2d ago"). Used by slot previews. + */ + formatTimeAgo(secs) { + if (secs < 5) return 'just now'; + + // Largest units first; each entry is [thresholdSeconds, label]. + const buckets = [ + [365 * 86400, 'y'], + [30 * 86400, 'mo'], + [86400, 'd'], + [3600, 'h'], + [60, 'm'] + ]; + + for (const [threshold, label] of buckets) { + if (secs >= threshold) { + const value = Math.floor(secs / threshold); + return `${value}${label} ago`; + } + } + + // Below one minute. + return `${Math.max(1, secs)}s ago`; + }, + + /** + * Switch the active save slot. Persists the selection and reloads that + * slot's data from localStorage into the live game state, preserving + * prestige-related counters that should carry across slots only when + * present in the target slot. + */ + switchSlot(slot) { + slot = ((parseInt(slot, 10) || 0) + this.config.SLOTS) % this.config.SLOTS; + + // Persist the selection first so loadState() reads it back. + localStorage.setItem(this.getActiveSlotKey(), String(slot)); + this.state.activeSlot = slot; + + const data = this.loadSlot(slot); + if (data) { + try { + const parsed = JSON.parse(JSON.stringify(data)); + this.state = { ...this.state, ...parsed }; + // Ensure the slot index stays consistent. + this.state.activeSlot = slot; + + UI.showNotification( + `Saved to Slot ${slot + 1}!`, 'success' + ); + } catch (e) { + console.error('Failed to load selected slot:', e); + UI.showNotification('Could not load that slot!', 'warning'); + } + } else { + // Target slot is empty: reset to a fresh start but keep essence. + this.state = { + stardust: 0, + totalStardust: 0, + totalClicks: 0, + clickPower: 1, + startTime: Date.now(), + lastSaveTime: Date.now(), + buildings: {}, + upgrades: [], + achievements: [], + stellarEssence: this.state.stellarEssence || 0, + prestigeCount: this.state.prestigeCount || 0, + totalPrestigeEssence: this.state.totalPrestigeEssence || 0, + prestigeUpgrades: this.state.prestigeUpgrades || [], + goldenBlooms: { active: false, timer: 0, lastSpawn: Date.now(), totalClaimed: 0 }, + // Reset the temporary golden-bloom production boost so a boost + // from a previous slot cannot leak into a fresh slot. + productionBoostUntil: 0, + activeSlot: slot, + autoSave: this.state.autoSave + }; + UI.showNotification( + `Empty Slot ${slot + 1} - new game started!`, 'success' + ); + } + + // Re-render dynamic UI and refresh the golden-bloom badge. + UI.init(); + UI.update(); + }, + + /** + * Export a single slot's save as base64 (for backup or transfer). + */ + exportSlotToBase64(slot) { + const data = this.loadSlot(slot); + if (!data) { + UI.showNotification(`Slot ${slot + 1} is empty!`, 'warning'); + return null; + } + const encoded = btoa(JSON.stringify(data)); + navigator.clipboard.writeText(encoded).then(() => { + UI.showNotification(`Slot ${slot + 1} copied to clipboard!`, 'success'); + }); + return encoded; + }, + + /** + * Import base64 save data into a chosen slot. + */ + importSlotFromData(data, slot) { + try { + const parsed = JSON.parse(atob(data)); + localStorage.setItem(this.getSaveKey(slot), JSON.stringify(parsed)); + UI.showNotification(`Imported into Slot ${slot + 1}!`, 'success'); + + // If we imported into the active slot, reload it. + if (parseInt(slot, 10) === this.state.activeSlot) { + this.switchSlot(slot); + } else { + UI.update(); + } + return true; + } catch (e) { + UI.showNotification('Invalid save data!', 'warning'); + return false; + } + }, + /** * Export save as base64 */ @@ -343,7 +934,14 @@ const Game = { lastSaveTime: Date.now(), buildings: {}, upgrades: [], - achievements: [] + achievements: [], + stellarEssence: 0, + prestigeCount: 0, + totalPrestigeEssence: 0, + prestigeUpgrades: [], + goldenBlooms: { active: false, timer: 0, lastSpawn: Date.now(), totalClaimed: 0 }, + activeSlot: this.state.activeSlot, + autoSave: this.state.autoSave }; UI.update(); UI.showNotification('Game reset!', 'warning'); @@ -358,6 +956,34 @@ const Game = { if (num >= 1e6) return (num / 1e6).toFixed(2) + 'M'; if (num >= 1e3) return (num / 1e3).toFixed(2) + 'K'; return Math.floor(num).toLocaleString(); + }, + + /** + * Create ripple effect on button click + */ + createRipple(button, event) { + const ripple = document.createElement('span'); + ripple.className = 'ripple'; + + const rect = button.getBoundingClientRect(); + const size = Math.max(rect.width, rect.height); + const x = event.clientX - rect.left - size / 2; + const y = event.clientY - rect.top - size / 2; + + ripple.style.width = ripple.style.height = `${size}px`; + ripple.style.left = `${x}px`; + ripple.style.top = `${y}px`; + + button.appendChild(ripple); + setTimeout(() => ripple.remove(), 600); + }, + + /** + * Animate click button with glow effect + */ + animateClickButton(button) { + button.classList.add('clicked'); + setTimeout(() => button.classList.remove('clicked'), 200); } }; diff --git a/js/particles.js b/js/particles.js index a46a68a..602285d 100644 --- a/js/particles.js +++ b/js/particles.js @@ -177,7 +177,179 @@ const Particles = { document.getElementById('game-container').appendChild(star); } - } + }, + /** + * Create prestige reset effect - cosmic explosion + */ + createPrestigeEffect(x, y) { + const colors = ['#ffd700', '#9b59b6', '#3498db', '#ffffff', '#e74c3c']; + + // Outer ring burst + for (let i = 0; i < 30; i++) { + const particle = document.createElement('div'); + particle.className = 'particle prestige-particle'; + + const angle = (Math.PI * 2 * i) / 30; + const velocity = 100 + Math.random() * 80; + const size = 10 + Math.random() * 15; + const color = colors[Math.floor(Math.random() * colors.length)]; + + particle.style.cssText = ` + left: ${x}px; + top: ${y}px; + width: ${size}px; + height: ${size}px; + background: ${color}; + border-radius: 50%; + pointer-events: none; + position: fixed; + z-index: 998; + box-shadow: 0 0 15px ${color}, 0 0 30px ${color}; + `; + + document.body.appendChild(particle); + + const animation = particle.animate([ + { + transform: 'translate(-50%, -50%) scale(1)', + opacity: 1 + }, + { + transform: `translate(calc(-50% + ${Math.cos(angle) * velocity}px), calc(-50% + ${Math.sin(angle) * velocity}px)) scale(1.5)`, + opacity: 0.8 + }, + { + transform: `translate(calc(-50% + ${Math.cos(angle) * velocity * 2}px), calc(-50% + ${Math.sin(angle) * velocity * 2}px)) scale(0)`, + opacity: 0 + } + ], { + duration: 1500, + easing: 'cubic-bezier(0.25, 0.46, 0.45, 0.94)' + }); + + animation.onfinish = () => particle.remove(); + } + + // Inner sparkle burst + for (let i = 0; i < 15; i++) { + const sparkle = document.createElement('div'); + sparkle.className = 'particle'; + + const angle = Math.random() * Math.PI * 2; + const velocity = 50 + Math.random() * 50; + const size = 4 + Math.random() * 6; + + sparkle.style.cssText = ` + left: ${x}px; + top: ${y}px; + width: ${size}px; + height: ${size}px; + background: #ffd700; + border-radius: 50%; + pointer-events: none; + position: fixed; + z-index: 998; + box-shadow: 0 0 10px #ffd700; + `; + + document.body.appendChild(sparkle); + + const animation = sparkle.animate([ + { + transform: 'translate(-50%, -50%) scale(1)', + opacity: 1 + }, + { + transform: `translate(calc(-50% + ${Math.cos(angle) * velocity}px), calc(-50% + ${Math.sin(angle) * velocity}px)) scale(0)`, + opacity: 0 + } + ], { + duration: 800, + easing: 'ease-out' + }); + + animation.onfinish = () => sparkle.remove(); + } + }, + + /** + * Create a Golden Starbloom burst - a shimmering golden explosion. + */ + createGoldenEffect(x, y) { + const colors = ['#ffd700', '#ffe469', '#fff2a8', '#ffcf3f', '#ffffff']; + + // Outer ring of glowing golden particles + for (let i = 0; i < 24; i++) { + const particle = document.createElement('div'); + particle.className = 'particle golden-bloom-particle'; + + const angle = (Math.PI * 2 * i) / 24; + const velocity = 90 + Math.random() * 70; + const size = 8 + Math.random() * 10; + const color = colors[Math.floor(Math.random() * colors.length)]; + + particle.style.cssText = ` + left: ${x}px; + top: ${y}px; + width: ${size}px; + height: ${size}px; + background: ${color}; + border-radius: 50%; + pointer-events: none; + position: fixed; + z-index: 998; + box-shadow: 0 0 12px ${color}, 0 0 24px #ffd700; + `; + + document.body.appendChild(particle); + + const animation = particle.animate([ + { transform: 'translate(-50%, -50%) scale(1)', opacity: 1 }, + { transform: `translate(calc(-50% + ${Math.cos(angle) * velocity}px), calc(-50% + ${Math.sin(angle) * velocity}px)) scale(1.3)`, opacity: 0.85 }, + { transform: `translate(calc(-50% + ${Math.cos(angle) * velocity * 2}px), calc(-50% + ${Math.sin(angle) * velocity * 2}px)) scale(0)`, opacity: 0 } + ], { + duration: 1200, + easing: 'cubic-bezier(0.25, 0.46, 0.45, 0.94)' + }); + + animation.onfinish = () => particle.remove(); + } + + // Golden sparkle burst + for (let i = 0; i < 12; i++) { + const sparkle = document.createElement('div'); + sparkle.className = 'particle golden-sparkle'; + + const angle = Math.random() * Math.PI * 2; + const velocity = 40 + Math.random() * 50; + const size = 3 + Math.random() * 4; + + sparkle.style.cssText = ` + left: ${x}px; + top: ${y}px; + width: ${size}px; + height: ${size}px; + background: #fff2a8; + border-radius: 50%; + pointer-events: none; + position: fixed; + z-index: 999; + box-shadow: 0 0 10px #ffd700; + `; + + document.body.appendChild(sparkle); + + const animation = sparkle.animate([ + { transform: 'translate(-50%, -50%) scale(1)', opacity: 1 }, + { transform: `translate(calc(-50% + ${Math.cos(angle) * velocity}px), calc(-50% + ${Math.sin(angle) * velocity}px)) scale(0)`, opacity: 0 } + ], { + duration: 700, + easing: 'ease-out' + }); + + animation.onfinish = () => sparkle.remove(); + } + }, }; // Make globally accessible diff --git a/js/prestige.js b/js/prestige.js new file mode 100644 index 0000000..9890b61 --- /dev/null +++ b/js/prestige.js @@ -0,0 +1,184 @@ +/** + * Cosmic Garden - Prestige System + * Allows resetting progress for permanent Stellar Essence bonuses + */ + +const PRESTIGE = { + /** + * Calculate Stellar Essence earned from prestiging + * Formula: floor(sqrt(totalStardust / 1,000,000)) + */ + calculateEssence() { + const totalStardust = Game.state.totalStardust || 0; + return Math.floor(Math.sqrt(totalStardust / 1e6)); + }, + + /** + * Get the current prestige multiplier from essence + * Each essence provides +10% production bonus + */ + getMultiplier() { + const essence = Game.state.stellarEssence || 0; + return 1 + (essence * 0.1); + }, + + /** + * Perform the prestige reset + */ + prestige() { + const essenceGain = this.calculateEssence(); + + if (essenceGain <= 0) { + UI.showNotification('Need more stardust to prestige!', 'warning'); + return false; + } + + // Reset game state but keep essence and prestige count + const previousPrestigeCount = Game.state.prestigeCount || 0; + const previousEssence = Game.state.stellarEssence || 0; + const newEssence = previousEssence + essenceGain; + + Game.state = { + stardust: 0, + totalStardust: 0, + totalClicks: 0, + clickPower: 1, + startTime: Date.now(), + lastSaveTime: Date.now(), + buildings: {}, + upgrades: [], + achievements: [], + stellarEssence: newEssence, + prestigeCount: previousPrestigeCount + 1, + totalPrestigeEssence: (Game.state.totalPrestigeEssence || 0) + essenceGain, + prestigeUpgrades: Game.state.prestigeUpgrades || [] + }; + + // Audio feedback + Audio.playPrestige(); + + // Particle effect for prestige + const btn = document.getElementById('prestige-btn'); + if (btn) { + const rect = btn.getBoundingClientRect(); + Particles.createPrestigeEffect(rect.left + rect.width / 2, rect.top + rect.height / 2); + } + + UI.showNotification( + `✦ Prestige! Earned ${essenceGain} Stellar Essence! (${newEssence} total)`, + 'achievement' + ); + + UI.update(); + return true; + }, + + /** + * Buy a prestige upgrade + */ + buyUpgrade(upgradeId) { + const upgrade = PRESTIGE_UPGRADES[upgradeId]; + if (!upgrade || Game.state.prestigeUpgrades?.includes(upgradeId)) { + return false; + } + + if (Game.state.stellarEssence >= upgrade.cost) { + Game.state.stellarEssence -= upgrade.cost; + Game.state.prestigeUpgrades = Game.state.prestigeUpgrades || []; + Game.state.prestigeUpgrades.push(upgradeId); + + UI.showNotification(`Unlocked: ${upgrade.name}!`, 'achievement'); + Audio.playPurchase(); + UI.update(); + return true; + } + + return false; + }, + + /** + * Get the effective click power with prestige bonus + */ + getEffectiveClickPower() { + const basePower = Game.getClickPower(); + const multiplier = this.getMultiplier(); + return Math.floor(basePower * multiplier); + }, + + /** + * Get the effective production per second with prestige bonus + */ + getEffectiveProductionPerSecond() { + const baseProduction = Game.getProductionPerSecond(); + const multiplier = this.getMultiplier(); + return baseProduction * multiplier; + } +}; + +/** + * Prestige Upgrades - Permanent bonuses purchased with Stellar Essence + */ +const PRESTIGE_UPGRADES = { + cosmicFingers: { + id: 'cosmicFingers', + name: 'Cosmic Fingers', + description: 'Click power x2 (permanent)', + icon: '👆', + cost: 10, + effect: 'clickMultiplier', + value: 2 + }, + + stellarTouch: { + id: 'stellarTouch', + name: 'Stellar Touch', + description: 'Click power x3 (permanent)', + icon: '✨', + cost: 50, + effect: 'clickMultiplier', + value: 3 + }, + + essenceAmplifier: { + id: 'essenceAmplifier', + name: 'Essence Amplifier', + description: 'All production x1.5 (permanent)', + icon: '💎', + cost: 25, + effect: 'productionMultiplier', + value: 1.5 + }, + + quantumGarden: { + id: 'quantumGarden', + name: 'Quantum Garden', + description: 'All production x2 (permanent)', + icon: '🌌', + cost: 100, + effect: 'productionMultiplier', + value: 2 + }, + + acceleratedGrowth: { + id: 'acceleratedGrowth', + name: 'Accelerated Growth', + description: 'Buildings cost 10% less (permanent)', + icon: '📉', + cost: 75, + effect: 'costReduction', + value: 0.9 + }, + + temporalManipulation: { + id: 'temporalManipulation', + name: 'Temporal Manipulation', + description: 'Offline earnings 75% (was 50%)', + icon: '⏰', + cost: 200, + effect: 'offlineBonus', + value: 0.75 + } +}; + +window.PRESTIGE = PRESTIGE; +window.PRESTIGE_UPGRADES = PRESTIGE_UPGRADES; diff --git a/js/prestigeUI.js b/js/prestigeUI.js new file mode 100644 index 0000000..e1bd7d9 --- /dev/null +++ b/js/prestigeUI.js @@ -0,0 +1,171 @@ +/** + * Cosmic Garden - Prestige UI Manager + * Handles all prestige-related UI rendering and interactions + */ + +const PrestigeUI = { + /** + * Initialize the prestige UI + */ + init() { + this.renderPrestigeUpgrades(); + this.setupEventListeners(); + this.update(); + }, + + /** + * Setup event listeners + */ + setupEventListeners() { + // Prestige button + document.getElementById('prestige-btn')?.addEventListener('click', () => { + const essenceGain = PRESTIGE.calculateEssence(); + + if (essenceGain > 0) { + if (confirm( + `Prestige now to earn ${essenceGain} Stellar Essence?\n\n` + + `You will keep your prestige upgrades and essence.\n` + + `All buildings, upgrades, and stardust will be reset.` + )) { + PRESTIGE.prestige(); + } + } else { + UI.showNotification('Need more stardust to prestige!', 'warning'); + } + }); + + // Tab switching for prestige tab + document.querySelectorAll('.tab-btn').forEach(btn => { + btn.addEventListener('click', (e) => { + const tab = e.target.dataset.tab; + if (tab === 'prestige') { + this.renderPrestigeUpgrades(); + } + }); + }); + + // Buy Max prestige upgrades button - buy every affordable prestige + // upgrade at once, paid from Stellar Essence. + const buyMaxBtn = document.getElementById('buy-all-prestige'); + if (buyMaxBtn) { + buyMaxBtn.addEventListener('click', () => { + Game.buyMaxPrestigeUpgrades(); + }); + } + }, + + /** + * Render prestige upgrade cards + */ + renderPrestigeUpgrades() { + const container = document.getElementById('prestige-upgrades-list'); + if (!container) return; + + container.innerHTML = ''; + + for (const [id, upgrade] of Object.entries(PRESTIGE_UPGRADES)) { + const card = document.createElement('div'); + card.className = 'prestige-upgrade-card'; + card.id = `prestige-upgrade-${id}`; + + const purchased = Game.state.prestigeUpgrades?.includes(id); + const canAfford = (Game.state.stellarEssence || 0) >= upgrade.cost; + + card.classList.toggle('purchased', purchased); + card.classList.toggle('locked', !purchased && !canAfford); + + card.innerHTML = ` +
${upgrade.icon} ${upgrade.name}
+
${upgrade.description}
+
${purchased ? '✓ Owned' : `${upgrade.cost} ✦`}
+ `; + + if (!purchased && canAfford) { + card.addEventListener('click', () => { + PRESTIGE.buyUpgrade(id); + }); + } + + container.appendChild(card); + } + }, + + /** + * Update prestige display + */ + update() { + // Essence count + const essenceEl = document.getElementById('prestige-essence-count'); + if (essenceEl) { + essenceEl.textContent = Game.state.stellarEssence || 0; + } + + // Multiplier display + const multiplierEl = document.getElementById('prestige-multiplier'); + if (multiplierEl) { + const multiplier = PRESTIGE.getMultiplier(); + const percent = Math.round((multiplier - 1) * 100); + multiplierEl.textContent = `+${percent}%`; + } + + // Next prestige essence + const nextEl = document.getElementById('prestige-next-essence'); + if (nextEl) { + const nextEssence = PRESTIGE.calculateEssence(); + nextEl.textContent = nextEssence; + } + + // Update prestige button state + const prestigeBtn = document.getElementById('prestige-btn'); + if (prestigeBtn) { + const canPrestige = PRESTIGE.calculateEssence() > 0; + prestigeBtn.disabled = !canPrestige; + prestigeBtn.classList.toggle('can-prestige', canPrestige); + } + + // Update prestige upgrades list if visible + const prestigeTab = document.getElementById('prestige-tab'); + if (prestigeTab?.classList.contains('active')) { + this.renderPrestigeUpgrades(); + } + + // Show/hide the "Buy Max" button based on whether any unowned prestige + // upgrade is currently affordable with Stellar Essence. Read-only. + this.updateBuyMaxPrestigeButton(); + }, + + /** + * Show/hide and enable/disable the "Buy Max" prestige-upgrades button + * depending on whether at least one unowned, requirement-met prestige + * upgrade is currently affordable with Stellar Essence. Read of game state + * only; never mutates it. Mirrors Game.buyMaxPrestigeUpgrades / + * PRESTIGE.buyUpgrade semantics. + */ + updateBuyMaxPrestigeButton() { + const btn = document.getElementById('buy-all-prestige'); + if (!btn) return; + + let affordableAny = false; + for (const [id, upgrade] of Object.entries(PRESTIGE_UPGRADES)) { + if ((Game.state.prestigeUpgrades || []).includes(id)) continue; + if ((Game.state.stellarEssence || 0) < upgrade.cost) continue; + if (upgrade.requirements) { + let met = true; + for (const [reqId, reqCount] of Object.entries(upgrade.requirements)) { + if ((Game.state.buildings[reqId] || 0) < reqCount) { + met = false; + break; + } + } + if (!met) continue; + } + affordableAny = true; + break; + } + + btn.style.display = affordableAny ? '' : 'none'; + btn.disabled = !affordableAny; + }, +}; + +window.PrestigeUI = PrestigeUI; diff --git a/js/stats.js b/js/stats.js index 8bcb340..2c29b89 100644 --- a/js/stats.js +++ b/js/stats.js @@ -17,7 +17,7 @@ const Stats = { render() { const container = document.getElementById('stats-content'); if (!container) return; - + container.innerHTML = `
Total Stardust Earned
@@ -51,12 +51,40 @@ const Stats = {
Achievements Unlocked
0/${Object.keys(ACHIEVEMENTS).length}
+
+
Total Golden Blooms Claimed
+
0
+
+
+
Production Boost
+
Inactive
+
+
+
+
+
+
Next Achievement
+
Loading...
+
+
+
+
Game Progress
+ + +
+
Active Save Slot
+
Slot 1 / ${Game.config.SLOTS}
+
+
+
Autosave
+
ON
+
`; }, @@ -114,12 +142,171 @@ const Stats = { achievementsEl.textContent = `${Achievements.getCount()}/${Achievements.getTotal()}`; } + // Active save slot display + const activeSlotEl = document.getElementById('stat-active-slot'); + if (activeSlotEl) { + const idx = Game.state.activeSlot || 0; + activeSlotEl.textContent = `Slot ${idx + 1} / ${Game.config.SLOTS}`; + } + + // Autosave status display + const autosaveEl = document.getElementById('stat-autosave'); + if (autosaveEl) { + autosaveEl.textContent = Game.state.autoSave ? 'ON' : 'OFF'; + autosaveEl.style.color = Game.state.autoSave ? 'var(--accent-green)' : 'var(--text-muted)'; + } + + // Total Golden Blooms claimed + const goldenBloomsEl = document.getElementById('stat-golden-blooms'); + if (goldenBloomsEl) { + goldenBloomsEl.textContent = Game.state.goldenBlooms.totalClaimed || 0; + } + + // Production boost status + progress bar + const boostInfo = Game.getProductionBoostInfo(); + const boostEl = document.getElementById('stat-production-boost'); + const boostProgressEl = document.getElementById('stat-boost-progress'); + if (boostEl) { + if (boostInfo.active) { + boostEl.textContent = `Active (${boostInfo.remaining}s)`; + if (boostProgressEl) { + boostProgressEl.style.width = `${Math.min(100, (boostInfo.remaining / 30) * 100)}%`; + } + } else { + boostEl.textContent = 'Inactive'; + if (boostProgressEl) { + boostProgressEl.style.width = '0%'; + } + } + } + // Game progress (based on total stardust milestones) const progressBarEl = document.getElementById('stat-progress-bar'); if (progressBarEl) { const progress = this.calculateProgress(); progressBarEl.style.width = `${progress}%`; } + + // Next achievement tracking + this.updateNextAchievement(); + }, + + /** + * Update the next achievement display with progress bar + */ + updateNextAchievement() { + const nextEl = document.getElementById('stat-next-achievement'); + const progressEl = document.getElementById('stat-achievement-progress'); + if (!nextEl) return; + + // Find the closest unachieved milestone + let closest = null; + let closestProgress = -1; + + for (const [id, achievement] of Object.entries(ACHIEVEMENTS)) { + if (Game.state.achievements.includes(id)) continue; + + const result = this.calculateAchievementProgress(achievement); + if (result && result.progress > closestProgress) { + closestProgress = result.progress; + closest = { ...result, name: achievement.name, icon: achievement.icon }; + } + } + + if (closest) { + nextEl.textContent = `${closest.icon} ${closest.name}`; + if (progressEl) { + progressEl.style.width = `${Math.min(100, Math.max(0, closest.progress))}%`; + } + } else { + nextEl.textContent = 'All achievements unlocked! 🎉'; + if (progressEl) { + progressEl.style.width = '100%'; + } + } + }, + + /** + * Calculate progress toward a specific achievement + */ + calculateAchievementProgress(achievement) { + const state = Game.state; + + // Click achievements + if (achievement.id === 'firstClick') return { current: state.totalClicks, target: 1, progress: Math.min(100, (state.totalClicks / 1) * 100) }; + if (achievement.id === 'clicker') return { current: state.totalClicks, target: 100, progress: Math.min(100, (state.totalClicks / 100) * 100) }; + if (achievement.id === 'spammer') return { current: state.totalClicks, target: 1000, progress: Math.min(100, (state.totalClicks / 1000) * 100) }; + if (achievement.id === 'clickMaster') return { current: state.totalClicks, target: 10000, progress: Math.min(100, (state.totalClicks / 10000) * 100) }; + + // Stardust achievements + if (achievement.id === 'dustCollector') return { current: state.totalStardust, target: 1000, progress: Math.min(100, (state.totalStardust / 1000) * 100) }; + if (achievement.id === 'stardustMagnate') return { current: state.totalStardust, target: 100000, progress: Math.min(100, (state.totalStardust / 100000) * 100) }; + if (achievement.id === 'cosmicTycoon') return { current: state.totalStardust, target: 10000000, progress: Math.min(100, (state.totalStardust / 10000000) * 100) }; + if (achievement.id === 'galaxyBaron') return { current: state.totalStardust, target: 1000000000, progress: Math.min(100, (state.totalStardust / 1000000000) * 100) }; + + // Building achievements + if (achievement.id === 'firstPlant') { + const totalBuildings = Object.values(state.buildings).reduce((a, b) => a + b, 0); + return { current: totalBuildings, target: 1, progress: Math.min(100, (totalBuildings / 1) * 100) }; + } + if (achievement.id === 'gardenStart') { + const totalBuildings = Object.values(state.buildings).reduce((a, b) => a + b, 0); + return { current: totalBuildings, target: 10, progress: Math.min(100, (totalBuildings / 10) * 100) }; + } + if (achievement.id === 'gardenCenter') { + const totalBuildings = Object.values(state.buildings).reduce((a, b) => a + b, 0); + return { current: totalBuildings, target: 50, progress: Math.min(100, (totalBuildings / 50) * 100) }; + } + if (achievement.id === 'cosmicGarden') { + const totalBuildings = Object.values(state.buildings).reduce((a, b) => a + b, 0); + return { current: totalBuildings, target: 100, progress: Math.min(100, (totalBuildings / 100) * 100) }; + } + + // Specific building achievements + if (achievement.id === 'sproutFarmer') return { current: state.buildings.starSprout || 0, target: 25, progress: Math.min(100, ((state.buildings.starSprout || 0) / 25) * 100) }; + if (achievement.id === 'bloomGarden') return { current: state.buildings.moonBloom || 0, target: 25, progress: Math.min(100, ((state.buildings.moonBloom || 0) / 25) * 100) }; + if (achievement.id === 'solarPowered') return { current: state.buildings.solarLeaf || 0, target: 25, progress: Math.min(100, ((state.buildings.solarLeaf || 0) / 25) * 100) }; + if (achievement.id === 'voidWeaver') return { current: state.buildings.voidFern || 0, target: 25, progress: Math.min(100, ((state.buildings.voidFern || 0) / 25) * 100) }; + if (achievement.id === 'supernovaGrowth') return { current: state.buildings.supernovaSeed || 0, target: 25, progress: Math.min(100, ((state.buildings.supernovaSeed || 0) / 25) * 100) }; + + // Production achievements + if (achievement.id === 'steadyStream') { + const prod = Game.getProductionPerSecond(); + return { current: prod, target: 100, progress: Math.min(100, (prod / 100) * 100) }; + } + if (achievement.id === 'rushingRiver') { + const prod = Game.getProductionPerSecond(); + return { current: prod, target: 1000, progress: Math.min(100, (prod / 1000) * 100) }; + } + if (achievement.id === 'cosmicFlood') { + const prod = Game.getProductionPerSecond(); + return { current: prod, target: 100000, progress: Math.min(100, (prod / 100000) * 100) }; + } + + // Prestige achievements + if (achievement.id === 'firstPrestige') return { current: state.prestigeCount || 0, target: 1, progress: Math.min(100, ((state.prestigeCount || 0) / 1) * 100) }; + if (achievement.id === 'stellarAdaptation') return { current: state.prestigeCount || 0, target: 5, progress: Math.min(100, ((state.prestigeCount || 0) / 5) * 100) }; + if (achievement.id === 'cosmicAscension') return { current: state.prestigeCount || 0, target: 10, progress: Math.min(100, ((state.prestigeCount || 0) / 10) * 100) }; + if (achievement.id === 'eternalGardener') return { current: state.totalPrestigeEssence || 0, target: 100, progress: Math.min(100, ((state.totalPrestigeEssence || 0) / 100) * 100) }; + if (achievement.id === 'quantumGrowth') return { current: state.totalPrestigeEssence || 0, target: 1000, progress: Math.min(100, ((state.totalPrestigeEssence || 0) / 1000) * 100) }; + + // Click power achievements + if (achievement.id === 'powerfulHarvester') { + const cp = Game.getClickPower(); + return { current: cp, target: 1000, progress: Math.min(100, (cp / 1000) * 100) }; + } + if (achievement.id === 'divineTouch') { + const cp = Game.getClickPower(); + return { current: cp, target: 10000, progress: Math.min(100, (cp / 10000) * 100) }; + } + + // Time-based achievement + if (achievement.id === 'dedicatedGardener') { + const elapsed = Date.now() - state.startTime; + return { current: elapsed, target: 3600000, progress: Math.min(100, (elapsed / 3600000) * 100) }; + } + + return null; }, /** diff --git a/js/ui.js b/js/ui.js index a2dcf81..52bc7dc 100644 --- a/js/ui.js +++ b/js/ui.js @@ -11,6 +11,7 @@ const UI = { this.renderBuildings(); this.renderUpgrades(); this.renderAchievements(); + this.updateSlotUI(); this.update(); }, @@ -35,6 +36,18 @@ const UI = { // Stats Stats.update(); + + // Prestige + PrestigeUI.update(); + + // Golden Starbloom countdown badge + this.updateGoldenBloomBadge(); + + // Save Slot System highlight / toggle state + this.updateSlotUI(); + + // Save Slot System live preview in the footer + this.updateSlotPreview(); }, /** @@ -66,6 +79,7 @@ const UI = {
${Game.formatNumber(cost)} ✨
${count}
+ `; @@ -73,6 +87,12 @@ const UI = { card.querySelector('.buy-btn').addEventListener('click', () => { Game.buyBuilding(id); }); + const maxBtn = card.querySelector('.buy-max-btn'); + if (maxBtn) { + maxBtn.addEventListener('click', () => { + Game.buyMaxBuilding(id); + }); + } } container.appendChild(card); @@ -100,6 +120,15 @@ const UI = { btn.disabled = !canAfford || locked; } + // Buy Max button - reflect how many can be bought at current stardust + const maxBtn = card.querySelector('.buy-max-btn'); + if (maxBtn) { + const affordable = Game.getMaxAffordable(id); + maxBtn.textContent = `${Game.formatNumber(affordable)}x Max`; + maxBtn.disabled = locked || affordable === 0; + maxBtn.title = affordable > 0 ? `Buy up to ${affordable} ${building.name}(s)` : 'Not enough stardust'; + } + card.classList.toggle('locked', locked); } }, @@ -160,15 +189,52 @@ const UI = { for (const [id, upgrade] of Object.entries(UPGRADES)) { const card = document.getElementById(`upgrade-${id}`); if (!card) continue; - + const purchased = Game.state.upgrades.includes(id); const costEl = card.querySelector('.upgrade-cost'); - + if (costEl && !purchased) { const canAfford = Game.state.stardust >= upgrade.cost; costEl.textContent = canAfford ? `${Game.formatNumber(upgrade.cost)} ✨` : `❌ ${Game.formatNumber(upgrade.cost)} ✨`; } } + + // Toggle the "Buy All" button: show it once at least one unowned, + // requirement-met upgrade exists, and disable it when nothing is + // affordable. Its enabled state is refreshed live each tick. + this.updateBuyAllButton(); + }, + + /** + * Show/hide and enable/disable the "Buy All upgrades" button based on + * whether any unowned upgrade (whose requirements are met) is currently + * affordable. Read of game state only; never mutates it. + */ + updateBuyAllButton() { + const btn = document.getElementById('buy-all-upgrades'); + if (!btn) return; + + let affordableAny = false; + for (const [id, upgrade] of Object.entries(UPGRADES)) { + if (Game.state.upgrades.includes(id)) continue; + if (Game.state.stardust < upgrade.cost) continue; + // Requirement check mirrors Game.buyUpgrade / buyAllUpgrades. + if (upgrade.requirements) { + let met = true; + for (const [reqId, reqCount] of Object.entries(upgrade.requirements)) { + if ((Game.state.buildings[reqId] || 0) < reqCount) { + met = false; + break; + } + } + if (!met) continue; + } + affordableAny = true; + break; + } + + btn.style.display = affordableAny ? '' : 'none'; + btn.disabled = !affordableAny; }, /** @@ -210,6 +276,138 @@ const UI = { } }, + /** + * Spawn a Golden Starbloom in the click area. Called by Game when the + * random event becomes active. The bloom floats at a random position and + * is clickable to claim its bonus. + */ + spawnGoldenBloom() { + const container = document.getElementById('golden-bloom-container'); + if (!container) return; + + // Remove any existing bloom first (shouldn't be one, but stay safe). + this.removeGoldenBloom(); + + const bloom = document.createElement('div'); + bloom.className = 'golden-bloom'; + bloom.textContent = '⭐'; + bloom.title = 'Golden Starbloom! Click for a bonus!'; + + // Random position within the click area. + const rect = container.getBoundingClientRect(); + const x = 20 + Math.random() * (Math.max(1, rect.width - 80)); + const y = 20 + Math.random() * (Math.max(1, rect.height - 80)); + bloom.style.left = `${x}px`; + bloom.style.top = `${y}px`; + + // Click handler claims the bonus. + bloom.addEventListener('click', () => { + const earned = Game.claimGoldenBloom(rect.left + x, rect.top + y); + this.removeGoldenBloom(); + if (earned > 0) { + UI.showFloatingText( + rect.left + x, rect.top + y, + `+${Game.formatNumber(earned)} ✨` + ); + } + }); + + container.appendChild(bloom); + }, + + /** + * Remove any visible Golden Starbloom from the click area. + */ + removeGoldenBloom() { + const container = document.getElementById('golden-bloom-container'); + if (!container) return; + const existing = container.querySelector('.golden-bloom'); + if (existing) existing.remove(); + }, + + /** + * Update the Golden Starbloom countdown badge shown in the header. + */ + updateGoldenBloomBadge() { + const el = document.getElementById('golden-bloom-badge'); + const remaining = Game.getGoldenBloomRemaining(); + if (!el) return; + + if (remaining > 0) { + el.classList.add('active'); + el.textContent = `⭐ Golden Bloom: ${remaining}s`; + } else { + el.classList.remove('active'); + } + }, + + /** + * Reflect the active save slot highlight and autosave toggle state. + */ + updateSlotUI() { + // Highlight the selected slot button. + document.querySelectorAll('.slot-btn').forEach(btn => { + const idx = parseInt(btn.dataset.slot, 10); + btn.classList.toggle('active', idx === Game.state.activeSlot); + btn.disabled = idx === Game.state.activeSlot; + }); + + // Reflect the autosave toggle state. + const autosaveBtn = document.getElementById('autosave-btn'); + if (autosaveBtn) { + if (Game.state.autoSave) { + autosaveBtn.classList.add('on'); + autosaveBtn.textContent = 'ON'; + } else { + autosaveBtn.classList.remove('on'); + autosaveBtn.textContent = 'OFF'; + } + } + }, + + /** + * Update the one-line live preview of the active slot in the footer. + * Also wires hover handlers onto each slot button so hovering previews a + * different slot's contents (stardust, buildings, achievements, last saved). + * This is a read-only display helper and never mutates game state. + */ + updateSlotPreview() { + const el = document.getElementById('slot-preview'); + if (!el) return; + + // Hovering a slot button previews that slot's contents instead of the + // active one. Track which slot is currently being previewed on hover. + let hovering = null; + + const render = (slot) => { + const preview = Game.getSlotPreview(slot); + if (!preview.present) { + el.textContent = `Slot ${slot + 1}: empty`; + el.classList.add('empty'); + return; + } + el.classList.remove('empty'); + el.textContent = + `Slot ${slot + 1}: ${Game.formatNumber(preview.stardust)} ✨ · ` + + `${preview.buildings} plants · ${preview.achievements[0]}/${preview.achievements[1]} · ${preview.lastSaved}`; + }; + + // Default: preview the active slot live (updates every tick). + render(Game.state.activeSlot); + + // Remove any previously-attached listeners to avoid duplicates on re-render. + document.querySelectorAll('.slot-btn').forEach(btn => { + btn.replaceWith(btn.cloneNode(true)); + }); + + // On hover, preview the hovered slot; on leave, fall back to active. + document.querySelectorAll('.slot-btn').forEach(btn => { + const idx = parseInt(btn.dataset.slot, 10); + btn.addEventListener('mouseenter', () => { hovering = idx; render(idx); }); + btn.addEventListener('mouseleave', () => { hovering = null; render(Game.state.activeSlot); }); + }); + }, + /** * Show floating text animation */