Merge branch 'main' of git.obahan.xyz:lullu/idle-game

This commit is contained in:
2026-08-24 09:43:52 +00:00
11 changed files with 2820 additions and 112 deletions
+571 -3
View File
@@ -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%);
}
+344 -1
View File
@@ -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-<n>`), 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 `<script>` tags for `js/prestige.js` and `js/prestigeUI.js`
(before `main.js`), added the ✦ Prestige tab button + tab panel with
`prestige-btn`, `prestige-essence-count`, `prestige-multiplier`,
`prestige-next-essence`, `prestige-upgrades-list`
- `js/particles.js` - added missing comma after `createAmbientStars()`
- `js/ui.js` - `UI.update()` now also calls `PrestigeUI.update()` so the
prestige display refreshes every tick
#### Verification:
Built a headless DOM test harness (`headless-test/test_game.py`, lives OUTSIDE
the game repo) running all game scripts in a real JS engine (py_mini_racer /
JavaScriptCore) with DOM stubs. 33/33 checks pass: init, 5 harvest clicks →
5 stardust, 20 game ticks without errors, full prestige flow (2M stardust →
1 essence, reset, multiplier), prestige upgrade purchase math, UI rendering,
firstPrestige achievement, save/load round-trip preserving essence.
#### Files modified:
- `index.html` - prestige scripts + prestige tab UI
- `js/particles.js` - missing comma (parse error)
- `js/ui.js` - PrestigeUI.update() wired into UI.update()
#### Lessons / notes for future sessions:
- Always check `index.html` script tags when adding new JS files!
- A headless smoke test exists at /workspace/headless-test/test_game.py —
re-run it after any change to js/ to catch ReferenceErrors before pushing.
(Requires `pip install py_mini_racer`.)
#### Next goals:
- Consider adding a "next essence at X stardust" progress hint
---
### Session 10 - 2026-08-22 (Morning)
**Status:** ✅ Buy Max (Bulk-Buy) Feature Added
#### What was done:
- Added a "Buy Max" bulk-buy mechanic — a staple of idle/clicker games that was previously missing (you could only buy one building at a time, forcing many tedious clicks).
- Added `Game.getMaxAffordable(buildingId)` which computes how many of a given building the player can afford right now using an iterative cost-scaling loop. It is read-only and does not mutate state.
- Added `Game.buyMaxBuilding(buildingId)` which loops buying one at a time until unaffordable, deducting the correct total stardust and incrementing counts properly (respects prestige costReduction upgrades since it reuses `getBuildingCost`).
- Wired a "Buy Max" button onto every building card in `UI.renderBuildings()` with an affordable-count badge (e.g. "5x Max").
- Updated `UI.updateBuildings()` so the Buy Max badge refreshes its count and enabled/disabled state every game tick, keeping it accurate as stardust accrues.
- Added CSS styling for `.buy-max-btn` (purple theme, hover scale, disabled state) to match existing building-card layout.
#### Files modified:
- `js/game.js` - added getMaxAffordable() and buyMaxBuilding() methods
- `js/ui.js` - rendered Buy Max button per building card + tick refresh in updateBuildings()
- `css/game.css` - .buy-max-btn styles
- `dev-journal.md` - appended Session 10 entry
#### Verification:
Wrote a headless Node.js test (`/opt/data/idle-game-test/buy-max-test.mjs`, lives OUTSIDE the repo). Evaluated the real game.js in-process with DOM/UI stubs. 6/6 checks pass: getMaxAffordable matches manual cost-scaling calc, buyMax deducts correct stardust and increments counts correctly, unaffordable purchase returns 0, getMaxAffordable is idempotent, and notifications fire on purchase.
#### Next goals:
- Consider adding "Buy Max" to upgrades too (upgrades are one-time so less critical, but a bulk "buy all affordable" could be nice).
- Potential new building tiers or prestige milestones.
- Sound variations or background music.
---
### Session 11 - 2026-08-23 (Morning)
**Status:** ✅ Golden Starbloom Random Event Added
#### What was done:
- Added a classic idle-game random event: the **Golden Starbloom**, a rare, time-limited bloom that occasionally appears in the click area. Clicking it grants a stardust bonus and a temporary production boost.
- Game state: added `state.goldenBlooms` (active flag, timer, lastSpawn timestamp, lifetime totalClaimed) plus `config.goldenBloom*` tuning values (min/max spawn interval 45-90s, 12s visible duration, bonus multiplier).
- Added a temporary **+50% production boost** (30 seconds) applied inside `getProductionPerSecond()`, alongside the existing prestige multiplier. Boost is expired-checked each tick and reflected in stats.
- Core logic in game.js: `claimGoldenBloom(x,y)` (awards `floor(production * mult)` stardust, min 1), `updateGoldenBloom(dtSeconds)` (schedules spawns on the tick clock; spawns once elapsed passes maxMs, with a small per-tick probability after minMs to avoid burst-spawning), `getProductionBoostInfo()`, and `getGoldenBloomRemaining()`.
- Wired `updateGoldenBloom()` into the main game tick so scheduling advances every 100ms.
- UI: added `UI.spawnGoldenBloom()` (floats a golden ⭐ at a random position in the click area, clickable to claim) and `UI.removeGoldenBloom()`, plus a header countdown badge (`golden-bloom-badge`) that glows while a bloom is live.
- Stats panel: added "Total Golden Blooms Claimed" stat and a "Production Boost" stat with a live progress bar (0-30s).
- Achievements: added 3 new achievements bringing the total from 30 to **33** — Lucky Bloom (first claim), Golden Collector (25 claims), Golden Enthusiast (100 claims). Updated the achievements-tab count in index.html.
- Visual + audio polish: `Particles.createGoldenEffect()` (golden ring burst + sparkle) and `Audio.playGoldenBloom()` (rising sweep + ascending shimmer chord). Added CSS for the bloom, its hover/floating/pulse animations, the badge, and golden particle styles.
- Wrote a headless Node.js test (`golden-bloom-test.mjs`) evaluating the real game.js in-process with DOM/UI/audio/particle stubs. **22/22 checks pass**, covering: no-op claim when inactive, bonus math, boost multiplier + expiry, countdown decrement + vanishing, forced spawn at maxMs, and no premature spawn before minMs. The existing buy-max test still passes (no regression).
#### Files modified:
- `js/game.js` - golden bloom state/config, claim/update/bloom/boost methods, tick wiring, production boost multiplier
- `js/ui.js` - spawnGoldenBloom(), removeGoldenBloom(), updateGoldenBloomBadge(), wired into update()
- `js/stats.js` - two new stat items + update logic (golden count, boost progress)
- `js/particles.js` - createGoldenEffect()
- `js/audio.js` - playGoldenBloom()
- `js/achievements.js` - 3 new golden achievements (Lucky Bloom, Golden Collector, Golden Enthusiast)
- `css/game.css` - bloom/badge/particle styling + keyframes
- `index.html` - golden-bloom-container in click area, golden-bloom-badge in header, achievement count 30->33
- `dev-journal.md` - appended Session 11 entry
#### Verification:
Headless test at /opt/data/idle-game-test/golden-bloom-test.mjs — **22/22 pass**. Existing buy-max test re-ran clean (6/6, no regression). All modified JS files pass `node --check`.
#### Next goals:
- Consider adding "Buy Max" to upgrades too.
- Potential new building tiers or prestige milestones.
- Sound variations or background music.
---
### Session 13 - 2026-08-23 (Afternoon)
**Status:** ✅ Save Slot Preview Added
#### What was done:
- Followed up on Session 12's stated next goal by adding a **save-slot preview** so players can tell their save slots apart at a glance.
- Added `Game.getSlotPreview(slot)` — a read-only helper that reads a given slot's data from localStorage WITHOUT mutating the live game state. It returns: `present` (has any saved data), `stardust`, `totalStardust`, `buildings` (sum of all building counts), `achievements` [count/total], `prestigeCount`, and a human-readable `lastSaved` "time ago" string.
- Added `Game.formatTimeAgo(secs)` — formats a second-count into compact relative labels ("just now", "1m ago", "3h ago", "2d ago", "7mo ago", etc.).
- Wired the preview into the footer: a monospace `.slot-preview` span now shows the active slot's live summary every tick (`Slot N: 1.2K ✨ · 8 plants · 2/33 · just now`). Hovering any slot button swaps the preview to that slot's contents; leaving falls back to the active slot.
- Added CSS for `.slot-preview` (monospace, ellipsis-truncated, dims until hover) and a `.empty` style for unreadable slots ("Slot N: empty").
- All display logic is read-only — `getSlotPreview()` calls the existing `loadSlot()` and never touches `Game.state`, verified by a dedicated test.
#### Files modified:
- `js/game.js` - added `getSlotPreview(slot)` + `formatTimeAgo(secs)` methods (inserted before `switchSlot()`)
- `index.html` - added `<span id="slot-preview">` inside the footer Slot section
- `js/ui.js` - added `updateSlotPreview()`; called it from `UI.update()` each tick and wired per-slot hover/leave listeners (re-cloning slot buttons each render to avoid duplicate listeners)
- `css/game.css` - `.slot-preview`, `.slot-preview.empty`, and hover-brightening rules
#### Verification:
Wrote a headless Node.js test (`/opt/data/idle-game-test/slot-preview-test.mjs`) evaluating the real game.js in-process with localStorage + UI stubs. **22/22 checks pass**, covering: empty-slot handling (present:false, zeroed), populated-slot field reads (stardust, totalStardust, summed buildings, achievement count/total, prestigeCount), formatTimeAgo buckets (just now / m / h / d), relative lastSaved strings, read-only guarantee (live state unchanged after previews), and graceful handling of malformed/missing fields (minimal object → present:true zeroed; non-numeric building count coerced to 0 with no crash). Existing golden-bloom test re-ran clean (22/22, no regression). All modified JS files pass `node --check`.
#### Next goals:
- Consider adding a "Buy Max" button to upgrades too (one-time purchases — less critical).
- Potential new building tiers or prestige milestones.
- Sound variations or background music.
---
### Session 14 - 2026-08-24 (Morning)
**Status:** ✅ Buy All Upgrades (Bulk-Buy for Upgrades) Added
#### What was done:
- Followed up on Sessions 10, 11, and 13's recurring "next goal" by adding a **Buy All** button to the Upgrades tab. Previously one-time upgrades could only be purchased individually (one click each), which was tedious once dozens were available — buildings already had a Buy Max bulk-buy from Session 10, but upgrades did not.
- Added `Game.buyAllUpgrades()` which loops through every upgrade in `UPGRADES`, skips owned ones, respects each upgrade's `requirements` (building-count gates), and buys everything affordable at once. It runs until a full pass purchases nothing (so buying one upgrade can unlock affordability for another via the stardust re-check), then returns `{ bought: count, ids: [...] }`.
- Wired a **Buy All** button into the Upgrades tab header in `index.html` (`#buy-all-upgrades`).
- Added `UI.updateBuyAllButton()` (called from `UI.updateUpgrades()` each tick) which shows/hides the button based on whether any unowned, requirement-met upgrade is currently affordable, and toggles its disabled state live.
- Added CSS for `.upgrades-header` (flex layout) and `.buy-all-btn` (gold accent theme matching existing buttons, hover scale, disabled state).
- Fixed a latent bug found during testing: the empty-slot branch of `Game.switchSlot()` did not reset `state.productionBoostUntil`, which could leave a stale golden-bloom boost active after switching to a fresh slot. Added `productionBoostUntil: 0` to that branch for symmetry with `reset()`.
#### Files modified:
- `js/game.js` - added `buyAllUpgrades()` method; wired Buy All button click listener in setupEventListeners(); fixed empty-slot productionBoostUntil reset in switchSlot()
- `js/ui.js` - added `updateBuyAllButton()`; called it from `updateUpgrades()` each tick
- `index.html` - added Buy All button to Upgrades tab header
- `css/game.css` - `.upgrades-header`, `.buy-all-btn` styles
- `dev-journal.md` - appended Session 14 entry
#### Verification:
Wrote a headless Node.js test (`/opt/data/idle-game-test/buy-all-upgrades-test.mjs`) evaluating the real game.js and ui.js in-process with DOM/audio/particle stubs. **24/24 checks pass**, covering: no-op when nothing affordable, bulk purchase with requirement gating (blocks unmet requirements), chained affordability across loop passes, idempotent second call, already-owned upgrade skipping, notification-on-purchase vs silence on no-op, and the real `UI.updateBuyAllButton()` show/hide/enabled state against actual BUILDINGS/UPGRADES data. Existing buy-max (6/6) and golden-bloom (22/22) tests re-ran clean — no regressions. All modified JS files pass `node --check`.
#### Next goals:
- Consider adding a "Buy Max" button to the prestige upgrades list as well.
- Potential new building tiers or prestige milestones.
- Sound variations or background music.
---
### Session 15 - 2026-08-24 (Morning)
**Status:** ✅ Buy Max Prestige Upgrades (bulk-buy for prestige upgrades) Added
#### What was done:
- Followed up on Session 14's own "next goal" by adding a **Buy Max** button to the **Prestige Upgrades** list. Previously one-time prestige upgrades could only be purchased individually (one click each), which was tedious once several were available — buildings had Buy Max (Session 10) and regular upgrades had Buy All (Session 14), but prestige upgrades did not. This brings prestige upgrades in line with that established bulk-buy convention.
- Added `Game.buyMaxPrestigeUpgrades()` to `js/game.js`. It loops through every entry in `PRESTIGE_UPGRADES`, skips owned ones, respects each upgrade's (optional) `requirements` building-count gate, and buys everything affordable at once from **Stellar Essence**. It runs until a full pass purchases nothing (loop kept for symmetry with `buyAllUpgrades` and to stay safe if future prestige upgrades gain requirement gates), then decrements `stellarEssence` per purchase (mirroring `PRESTIGE.buyUpgrade`) and returns `{ bought: count, ids: [...] }`.
- Wired the button into the Prestige tab in `index.html` (`#buy-all-prestige`, placed alongside a left-aligned header row mirroring the Upgrades tab's Buy All layout). Added a click listener in `PrestigeUI.setupEventListeners()`.
- Added `PrestigeUI.updateBuyMaxPrestigeButton()` (called from `PrestigeUI.update()` each tick) which shows/hides and enables/disables the button based on whether any unowned, requirement-met prestige upgrade is currently affordable with Stellar Essence — a read-only state check, exactly mirroring `UI.updateBuyAllButton()`.
- Added CSS for `.prestige-upgrades-header` (flex layout) and `.prestige-buy-all` (gold accent theme reusing the existing `.buy-all-btn` base, matching the Upgrades tab's Buy All button).
- **Bug found & fixed during testing:** the first implementation of `buyMaxPrestigeUpgrades()` added upgrades to the list but never decremented `stellarEssence`, which would have let players prestige-buy unlimited upgrades for free. The headless test asserted essence depletion and caught it; added the missing `this.state.stellarEssence -= upgrade.cost` line so spend mirrors `PRESTIGE.buyUpgrade`.
#### Files modified:
- `js/game.js` - added `buyMaxPrestigeUpgrades()` method (with correct Stellar Essence spend)
- `js/prestigeUI.js` - added click listener for the Buy Max button + `updateBuyMaxPrestigeButton()`; called from `update()` each tick
- `index.html` - added Buy Max button to the Prestige Upgrades header
- `css/game.css` - `.prestige-upgrades-header`, `.prestige-buy-all` styles
- `dev-journal.md` - appended Session 15 entry
#### Verification:
Wrote a headless Node.js test (`/opt/data/idle-game-test/prestige-buy-max-test.mjs`) that loads the real `game.js`, `prestige.js`, and `prestigeUI.js` in-process with DOM/audio/particle stubs. **22/22 checks pass**, covering: no-op when nothing affordable, bulk purchase deducting Stellar Essence correctly, ungated-upgrade behavior (real prestige upgrades have no requirement gates), idempotent second call, already-owned upgrade skipping, notification-on-purchase vs silence on no-op, the missing-essence-spend regression (now fixed), and the real `PrestigeUI.updateBuyMaxPrestigeButton()` show/hide/enabled state against actual `PRESTIGE_UPGRADES` data. All existing suites re-ran clean with no regressions: buy-all-upgrades 24/24, buy-max 6/6, golden-bloom 22/22, slot-preview 22/22. All modified JS files pass `node --check`.
#### Next goals:
- Potential new building tiers or prestige milestones / new prestige upgrades.
- Sound variations or background music.
- Consider a global "Buy Max" for buildings across all types at once (currently per-building).
+68 -6
View File
@@ -17,6 +17,8 @@
<div id="per-second">
<span id="stardust-ps">0</span> ✨/sec
</div>
<!-- Golden Starbloom countdown badge -->
<div id="golden-bloom-badge" class="golden-bloom-badge"></div>
</header>
<!-- Main Layout -->
@@ -29,6 +31,8 @@
<span class="btn-icon">✨</span>
<span class="btn-text">Collect Stardust</span>
</button>
<!-- Golden Starbloom random event appears here -->
<div id="golden-bloom-container"></div>
</div>
<div id="click-info">
<p>Click power: <span id="click-power">1</span> ✨</p>
@@ -50,15 +54,19 @@
<button class="tab-btn active" data-tab="upgrades">Upgrades</button>
<button class="tab-btn" data-tab="achievements">Achievements</button>
<button class="tab-btn" data-tab="stats">Stats</button>
<button class="tab-btn" data-tab="prestige">✦ Prestige</button>
</div>
<div id="upgrades-tab" class="tab-content active">
<h3>Upgrades</h3>
<div class="upgrades-header">
<h3>Upgrades</h3>
<button id="buy-all-upgrades" class="buy-all-btn" style="display:none" title="Buy every upgrade you can currently afford">Buy All</button>
</div>
<div id="upgrades-list"></div>
</div>
<div id="achievements-tab" class="tab-content">
<h3>Achievements (0/20)</h3>
<h3>Achievements (0/31)</h3>
<div id="achievements-list"></div>
</div>
@@ -66,15 +74,60 @@
<h3>Statistics</h3>
<div id="stats-content"></div>
</div>
<div id="prestige-tab" class="tab-content">
<h3>✦ Prestige</h3>
<div id="prestige-info">
<div class="prestige-essence">
<span class="essence-icon">✦</span>
<span id="prestige-essence-count">0</span>
<span style="font-size:0.85rem; color:var(--text-secondary)">Stellar Essence</span>
</div>
<div class="prestige-multiplier">
Production bonus: <span id="prestige-multiplier">+0%</span>
</div>
<div class="prestige-next">
Next prestige: <span id="prestige-next-essence">0</span> ✦
</div>
<p style="font-size:0.8rem; color:var(--text-muted); margin:8px 0">
Requires 1M total stardust for the first ✦. Prestige resets
stardust, buildings and upgrades — but keeps essence and
prestige upgrades.
</p>
<button id="prestige-btn" class="prestige-btn" disabled>
✦ Prestige
</button>
</div>
<h3 style="margin-top:15px">Prestige Upgrades</h3>
<div class="prestige-upgrades-header">
<span></span>
<button id="buy-all-prestige" class="buy-all-btn prestige-buy-all" style="display:none" title="Buy every prestige upgrade you can currently afford with Stellar Essence">Buy Max</button>
</div>
<div id="prestige-upgrades-list"></div>
</div>
</aside>
</div>
<!-- Footer: Controls -->
<footer id="game-footer">
<button id="save-btn">💾 Save</button>
<button id="export-btn">📤 Export Save</button>
<button id="import-btn">📥 Import Save</button>
<button id="reset-btn">🗑️ Reset</button>
<div class="footer-section">
<span class="footer-label">Slot:</span>
<button class="slot-btn" data-slot="0">1</button>
<button class="slot-btn" data-slot="1">2</button>
<button class="slot-btn" data-slot="2">3</button>
<!-- Live one-line preview of the active slot; hover a slot button to preview it -->
<span id="slot-preview" class="slot-preview" title="Hover a slot button to preview its contents"></span>
</div>
<div class="footer-section">
<span class="footer-label">Auto:</span>
<button id="autosave-btn" class="toggle-btn on">ON</button>
</div>
<div class="footer-section">
<button id="save-btn">💾 Save</button>
<button id="export-btn">📤 Export</button>
<button id="import-btn">📥 Import</button>
<button id="reset-btn">🗑️ Reset</button>
</div>
<span id="last-saved"></span>
</footer>
</div>
@@ -86,7 +139,14 @@
<div id="import-modal" class="modal hidden">
<div class="modal-content">
<h3>Import Save</h3>
<p class="modal-hint">Paste your save data, then choose which slot to import into.</p>
<textarea id="import-data" placeholder="Paste your save data here..."></textarea>
<div class="import-slot-selector">
<span class="import-slot-label">Import into slot:</span>
<button class="slot-btn" data-import-slot="0">1</button>
<button class="slot-btn" data-import-slot="1">2</button>
<button class="slot-btn" data-import-slot="2">3</button>
</div>
<div class="modal-buttons">
<button id="confirm-import">Import</button>
<button id="cancel-import">Cancel</button>
@@ -104,6 +164,8 @@
<script src="js/stats.js"></script>
<script src="js/particles.js"></script>
<script src="js/audio.js"></script>
<script src="js/prestige.js"></script>
<script src="js/prestigeUI.js"></script>
<script src="js/main.js"></script>
</body>
</html>
+111 -2
View File
@@ -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
}
};
+171 -83
View File
@@ -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;
+639 -13
View File
@@ -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);
}
};
+173 -1
View File
@@ -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
+184
View File
@@ -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;
+171
View File
@@ -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 = `
<div class=\"prestige-upgrade-name\">${upgrade.icon} ${upgrade.name}</div>
<div class=\"prestige-upgrade-desc\">${upgrade.description}</div>
<div class=\"prestige-upgrade-cost\">${purchased ? '✓ Owned' : `${upgrade.cost} ✦`}</div>
`;
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;
+188 -1
View File
@@ -17,7 +17,7 @@ const Stats = {
render() {
const container = document.getElementById('stats-content');
if (!container) return;
container.innerHTML = `
<div class="stat-item">
<div class="stat-label">Total Stardust Earned</div>
@@ -51,12 +51,40 @@ const Stats = {
<div class="stat-label">Achievements Unlocked</div>
<div class="stat-value" id="stat-achievements">0/${Object.keys(ACHIEVEMENTS).length}</div>
</div>
<div class="stat-item">
<div class="stat-label">Total Golden Blooms Claimed</div>
<div class="stat-value" id="stat-golden-blooms">0</div>
</div>
<div class="stat-item">
<div class="stat-label">Production Boost</div>
<div class="stat-value" id="stat-production-boost">Inactive</div>
<div class="stat-bar">
<div class="stat-bar-fill" id="stat-boost-progress" style="width: 0%"></div>
</div>
</div>
<div class="stat-item">
<div class="stat-label">Next Achievement</div>
<div id="stat-next-achievement" style="font-size:0.85rem; color:var(--text-secondary); margin-top:4px">Loading...</div>
<div class="stat-bar">
<div class="stat-bar-fill" id="stat-achievement-progress" style="width: 0%"></div>
</div>
</div>
<div class="stat-item">
<div class="stat-label">Game Progress</div>
<div class="stat-bar">
<div class="stat-bar-fill" id="stat-progress-bar" style="width: 0%"></div>
</div>
</div>
<!-- Save Slot System -->
<div class="stat-item">
<div class="stat-label">Active Save Slot</div>
<div class="stat-value" id="stat-active-slot">Slot 1 / ${Game.config.SLOTS}</div>
</div>
<div class="stat-item">
<div class="stat-label">Autosave</div>
<div class="stat-value" id="stat-autosave">ON</div>
</div>
`;
},
@@ -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;
},
/**
+200 -2
View File
@@ -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 = {
<div class="building-cost">${Game.formatNumber(cost)} ✨</div>
<div class="building-count">${count}</div>
<button class="buy-btn" ${!canAfford || locked ? 'disabled' : ''}>Buy</button>
<button class="buy-max-btn" data-building="${id}" ${locked || count === 0 ? 'title="Buy as many as you can afford"' : 'disabled'}>${Game.formatNumber(Game.getMaxAffordable(id))}x Max</button>
</div>
`;
@@ -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
*/