Merge branch 'main' of git.obahan.xyz:lullu/idle-game
This commit is contained in:
+568
@@ -20,6 +20,23 @@
|
|||||||
--warning: #f39c12;
|
--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;
|
margin: 0;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
@@ -95,6 +112,7 @@ h2, h3 {
|
|||||||
display: flex;
|
display: flex;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
margin: 20px 0;
|
margin: 20px 0;
|
||||||
|
position: relative;
|
||||||
}
|
}
|
||||||
|
|
||||||
#harvest-btn {
|
#harvest-btn {
|
||||||
@@ -110,6 +128,8 @@ h2, h3 {
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
box-shadow: 0 4px 20px rgba(155, 89, 182, 0.4);
|
box-shadow: 0 4px 20px rgba(155, 89, 182, 0.4);
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
#harvest-btn:hover {
|
#harvest-btn:hover {
|
||||||
@@ -121,6 +141,67 @@ h2, h3 {
|
|||||||
transform: scale(0.95);
|
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 {
|
.btn-icon {
|
||||||
font-size: 3rem;
|
font-size: 3rem;
|
||||||
}
|
}
|
||||||
@@ -220,6 +301,85 @@ h2, h3 {
|
|||||||
opacity: 0.5;
|
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 */
|
/* Side Panel Tabs */
|
||||||
.tabs {
|
.tabs {
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -408,6 +568,123 @@ h2, h3 {
|
|||||||
border-color: var(--danger);
|
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 {
|
#last-saved {
|
||||||
margin-left: auto;
|
margin-left: auto;
|
||||||
color: var(--text-muted);
|
color: var(--text-muted);
|
||||||
@@ -625,4 +902,295 @@ h2, h3 {
|
|||||||
gap: 10px;
|
gap: 10px;
|
||||||
text-align: center;
|
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
@@ -12,7 +12,7 @@
|
|||||||
- Theme: "Cosmic Garden" - a space gardening idle game
|
- Theme: "Cosmic Garden" - a space gardening idle game
|
||||||
- Built core game engine with click harvesting, buildings, upgrades, achievements
|
- Built core game engine with click harvesting, buildings, upgrades, achievements
|
||||||
- Designed 12 buildings across 4 tiers (Star Sprout → Void Fern)
|
- 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
|
- Implemented auto-save, offline progress, export/import
|
||||||
- Built responsive dark-themed UI
|
- 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)*
|
*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
@@ -17,6 +17,8 @@
|
|||||||
<div id="per-second">
|
<div id="per-second">
|
||||||
<span id="stardust-ps">0</span> ✨/sec
|
<span id="stardust-ps">0</span> ✨/sec
|
||||||
</div>
|
</div>
|
||||||
|
<!-- Golden Starbloom countdown badge -->
|
||||||
|
<div id="golden-bloom-badge" class="golden-bloom-badge"></div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<!-- Main Layout -->
|
<!-- Main Layout -->
|
||||||
@@ -29,6 +31,8 @@
|
|||||||
<span class="btn-icon">✨</span>
|
<span class="btn-icon">✨</span>
|
||||||
<span class="btn-text">Collect Stardust</span>
|
<span class="btn-text">Collect Stardust</span>
|
||||||
</button>
|
</button>
|
||||||
|
<!-- Golden Starbloom random event appears here -->
|
||||||
|
<div id="golden-bloom-container"></div>
|
||||||
</div>
|
</div>
|
||||||
<div id="click-info">
|
<div id="click-info">
|
||||||
<p>Click power: <span id="click-power">1</span> ✨</p>
|
<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 active" data-tab="upgrades">Upgrades</button>
|
||||||
<button class="tab-btn" data-tab="achievements">Achievements</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="stats">Stats</button>
|
||||||
|
<button class="tab-btn" data-tab="prestige">✦ Prestige</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div id="upgrades-tab" class="tab-content active">
|
<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 id="upgrades-list"></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div id="achievements-tab" class="tab-content">
|
<div id="achievements-tab" class="tab-content">
|
||||||
<h3>Achievements (0/20)</h3>
|
<h3>Achievements (0/31)</h3>
|
||||||
<div id="achievements-list"></div>
|
<div id="achievements-list"></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -66,15 +74,60 @@
|
|||||||
<h3>Statistics</h3>
|
<h3>Statistics</h3>
|
||||||
<div id="stats-content"></div>
|
<div id="stats-content"></div>
|
||||||
</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>
|
</aside>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Footer: Controls -->
|
<!-- Footer: Controls -->
|
||||||
<footer id="game-footer">
|
<footer id="game-footer">
|
||||||
<button id="save-btn">💾 Save</button>
|
<div class="footer-section">
|
||||||
<button id="export-btn">📤 Export Save</button>
|
<span class="footer-label">Slot:</span>
|
||||||
<button id="import-btn">📥 Import Save</button>
|
<button class="slot-btn" data-slot="0">1</button>
|
||||||
<button id="reset-btn">🗑️ Reset</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>
|
<span id="last-saved"></span>
|
||||||
</footer>
|
</footer>
|
||||||
</div>
|
</div>
|
||||||
@@ -86,7 +139,14 @@
|
|||||||
<div id="import-modal" class="modal hidden">
|
<div id="import-modal" class="modal hidden">
|
||||||
<div class="modal-content">
|
<div class="modal-content">
|
||||||
<h3>Import Save</h3>
|
<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>
|
<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">
|
<div class="modal-buttons">
|
||||||
<button id="confirm-import">Import</button>
|
<button id="confirm-import">Import</button>
|
||||||
<button id="cancel-import">Cancel</button>
|
<button id="cancel-import">Cancel</button>
|
||||||
@@ -104,6 +164,8 @@
|
|||||||
<script src="js/stats.js"></script>
|
<script src="js/stats.js"></script>
|
||||||
<script src="js/particles.js"></script>
|
<script src="js/particles.js"></script>
|
||||||
<script src="js/audio.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>
|
<script src="js/main.js"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -150,6 +150,115 @@ const ACHIEVEMENTS = {
|
|||||||
description: 'Reach 100,000 stardust/sec',
|
description: 'Reach 100,000 stardust/sec',
|
||||||
icon: '🌊',
|
icon: '🌊',
|
||||||
condition: (state) => Game.getProductionPerSecond() >= 100000
|
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
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
+162
-74
@@ -1,28 +1,24 @@
|
|||||||
/**
|
/**
|
||||||
* Cosmic Garden - Audio System
|
* 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 = {
|
const Audio = {
|
||||||
ctx: null,
|
ctx: null,
|
||||||
enabled: true,
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Initialize audio context (must be called after user interaction)
|
* Initialize audio context
|
||||||
*/
|
*/
|
||||||
init() {
|
init() {
|
||||||
try {
|
try {
|
||||||
window.AudioContext = window.AudioContext || window.webkitAudioContext;
|
this.ctx = new (window.AudioContext || window.webkitAudioContext)();
|
||||||
this.ctx = new AudioContext();
|
|
||||||
console.log('🔊 Audio system initialized');
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.warn('Web Audio API not supported');
|
console.log('Web Audio API not supported');
|
||||||
this.enabled = false;
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Resume audio context (for browsers that suspend it)
|
* Resume audio context (needed for some browsers)
|
||||||
*/
|
*/
|
||||||
resume() {
|
resume() {
|
||||||
if (this.ctx && this.ctx.state === 'suspended') {
|
if (this.ctx && this.ctx.state === 'suspended') {
|
||||||
@@ -31,89 +27,181 @@ const Audio = {
|
|||||||
},
|
},
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Create a simple tone
|
* Play click sound - rising C-major arpeggio
|
||||||
*/
|
*/
|
||||||
playTone(frequency, duration, type = 'sine', volume = 0.1) {
|
playClick() {
|
||||||
if (!this.enabled || !this.ctx) return;
|
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 osc = this.ctx.createOscillator();
|
||||||
const gain = this.ctx.createGain();
|
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);
|
osc.connect(gain);
|
||||||
gain.connect(this.ctx.destination);
|
gain.connect(this.ctx.destination);
|
||||||
|
|
||||||
osc.start(this.ctx.currentTime);
|
osc.type = 'sine';
|
||||||
osc.stop(this.ctx.currentTime + duration);
|
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 a rising arpeggio (for clicks)
|
* Play golden starbloom sound - an ethereal shimmering arpeggio with a
|
||||||
|
* rising sweep, to distinguish it from regular purchase sounds.
|
||||||
*/
|
*/
|
||||||
playClick() {
|
playGoldenBloom() {
|
||||||
if (!this.enabled) return;
|
if (!this.ctx) return;
|
||||||
this.resume();
|
this.resume();
|
||||||
|
|
||||||
const now = this.ctx.currentTime;
|
const now = this.ctx.currentTime;
|
||||||
const notes = [523.25, 659.25, 783.99]; // C major chord
|
|
||||||
|
|
||||||
notes.forEach((freq, i) => {
|
// Rising magical sweep (like the prestige sound but brighter).
|
||||||
setTimeout(() => {
|
const sweep = this.ctx.createOscillator();
|
||||||
this.playTone(freq, 0.1, 'sine', 0.08);
|
const sweepGain = this.ctx.createGain();
|
||||||
}, i * 30);
|
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;
|
window.Audio = Audio;
|
||||||
|
|||||||
+639
-13
@@ -14,14 +14,38 @@ const Game = {
|
|||||||
lastSaveTime: Date.now(),
|
lastSaveTime: Date.now(),
|
||||||
buildings: {},
|
buildings: {},
|
||||||
upgrades: [],
|
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
|
// Configuration
|
||||||
config: {
|
config: {
|
||||||
tickRate: 100, // ms per tick (10 ticks/sec)
|
tickRate: 100, // ms per tick (10 ticks/sec)
|
||||||
autoSaveInterval: 30000, // 30 seconds
|
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();
|
Audio.init();
|
||||||
UI.init();
|
UI.init();
|
||||||
Stats.init();
|
Stats.init();
|
||||||
|
PrestigeUI.init();
|
||||||
console.log('🌌 Cosmic Garden initialized!');
|
console.log('🌌 Cosmic Garden initialized!');
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -42,11 +67,33 @@ const Game = {
|
|||||||
* Setup event listeners
|
* Setup event listeners
|
||||||
*/
|
*/
|
||||||
setupEventListeners() {
|
setupEventListeners() {
|
||||||
// Harvest button
|
// Track which slot the user has selected for importing.
|
||||||
document.getElementById('harvest-btn').addEventListener('click', (e) => {
|
this.importSlot = 0;
|
||||||
|
|
||||||
|
// Harvest button - support both click and touch
|
||||||
|
const harvestBtn = document.getElementById('harvest-btn');
|
||||||
|
|
||||||
|
harvestBtn.addEventListener('click', (e) => {
|
||||||
this.harvest(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
|
// Save buttons
|
||||||
document.getElementById('save-btn').addEventListener('click', () => {
|
document.getElementById('save-btn').addEventListener('click', () => {
|
||||||
this.save();
|
this.save();
|
||||||
@@ -64,9 +111,12 @@ const Game = {
|
|||||||
document.getElementById('confirm-import').addEventListener('click', () => {
|
document.getElementById('confirm-import').addEventListener('click', () => {
|
||||||
const data = document.getElementById('import-data').value;
|
const data = document.getElementById('import-data').value;
|
||||||
if (data) {
|
if (data) {
|
||||||
this.loadFromData(data);
|
const ok = this.importSlotFromData(data, this.importSlot);
|
||||||
UI.hideImportModal();
|
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();
|
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', () => {
|
document.getElementById('reset-btn').addEventListener('click', () => {
|
||||||
if (confirm('Are you sure you want to reset all progress? This cannot be undone!')) {
|
if (confirm('Are you sure you want to reset all progress? This cannot be undone!')) {
|
||||||
this.reset();
|
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
|
// Tab switching
|
||||||
document.querySelectorAll('.tab-btn').forEach(btn => {
|
document.querySelectorAll('.tab-btn').forEach(btn => {
|
||||||
btn.addEventListener('click', (e) => {
|
btn.addEventListener('click', (e) => {
|
||||||
@@ -90,6 +179,14 @@ const Game = {
|
|||||||
document.getElementById(`${tab}-tab`).classList.add('active');
|
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);
|
return Math.floor(power);
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -148,6 +257,22 @@ const Game = {
|
|||||||
|
|
||||||
let production = building.baseProduction * count;
|
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
|
// Apply upgrade bonuses
|
||||||
for (const upgradeId of this.state.upgrades) {
|
for (const upgradeId of this.state.upgrades) {
|
||||||
const upgrade = UPGRADES[upgradeId];
|
const upgrade = UPGRADES[upgradeId];
|
||||||
@@ -171,11 +296,21 @@ const Game = {
|
|||||||
getBuildingCost(buildingId) {
|
getBuildingCost(buildingId) {
|
||||||
const building = BUILDINGS[buildingId];
|
const building = BUILDINGS[buildingId];
|
||||||
const count = this.state.buildings[buildingId] || 0;
|
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) {
|
buyBuilding(buildingId) {
|
||||||
const cost = this.getBuildingCost(buildingId);
|
const cost = this.getBuildingCost(buildingId);
|
||||||
@@ -203,6 +338,63 @@ const Game = {
|
|||||||
return false;
|
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
|
* Buy an upgrade
|
||||||
*/
|
*/
|
||||||
@@ -235,6 +427,179 @@ const Game = {
|
|||||||
return false;
|
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
|
* Game tick - called every config.tickRate ms
|
||||||
*/
|
*/
|
||||||
@@ -246,6 +611,9 @@ const Game = {
|
|||||||
this.addStardust(production * tickSeconds);
|
this.addStardust(production * tickSeconds);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Advance the Golden Starbloom random event clock.
|
||||||
|
this.updateGoldenBloom(tickSeconds);
|
||||||
|
|
||||||
UI.update();
|
UI.update();
|
||||||
Achievements.check();
|
Achievements.check();
|
||||||
},
|
},
|
||||||
@@ -262,6 +630,8 @@ const Game = {
|
|||||||
*/
|
*/
|
||||||
startAutoSave() {
|
startAutoSave() {
|
||||||
setInterval(() => {
|
setInterval(() => {
|
||||||
|
// Only autosave when the player has enabled it (default on).
|
||||||
|
if (!this.state.autoSave) return;
|
||||||
this.save();
|
this.save();
|
||||||
document.getElementById('last-saved').textContent =
|
document.getElementById('last-saved').textContent =
|
||||||
`Last saved: ${new Date().toLocaleTimeString()}`;
|
`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() {
|
save() {
|
||||||
this.state.lastSaveTime = Date.now();
|
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
|
* Load game state
|
||||||
*/
|
*/
|
||||||
loadState() {
|
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) {
|
if (saved) {
|
||||||
try {
|
try {
|
||||||
const parsed = JSON.parse(saved);
|
const parsed = JSON.parse(saved);
|
||||||
this.state = { ...this.state, ...parsed };
|
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
|
// Calculate offline progress
|
||||||
const offlineTime = (Date.now() - this.state.lastSaveTime) / 1000;
|
const offlineTime = (Date.now() - this.state.lastSaveTime) / 1000;
|
||||||
if (offlineTime > 60) {
|
if (offlineTime > 60) {
|
||||||
const production = this.getProductionPerSecond();
|
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) {
|
if (offlineEarnings > 0) {
|
||||||
this.addStardust(offlineEarnings);
|
this.addStardust(offlineEarnings);
|
||||||
UI.showNotification(
|
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
|
* Export save as base64
|
||||||
*/
|
*/
|
||||||
@@ -343,7 +934,14 @@ const Game = {
|
|||||||
lastSaveTime: Date.now(),
|
lastSaveTime: Date.now(),
|
||||||
buildings: {},
|
buildings: {},
|
||||||
upgrades: [],
|
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.update();
|
||||||
UI.showNotification('Game reset!', 'warning');
|
UI.showNotification('Game reset!', 'warning');
|
||||||
@@ -358,6 +956,34 @@ const Game = {
|
|||||||
if (num >= 1e6) return (num / 1e6).toFixed(2) + 'M';
|
if (num >= 1e6) return (num / 1e6).toFixed(2) + 'M';
|
||||||
if (num >= 1e3) return (num / 1e3).toFixed(2) + 'K';
|
if (num >= 1e3) return (num / 1e3).toFixed(2) + 'K';
|
||||||
return Math.floor(num).toLocaleString();
|
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
@@ -177,7 +177,179 @@ const Particles = {
|
|||||||
|
|
||||||
document.getElementById('game-container').appendChild(star);
|
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
|
// Make globally accessible
|
||||||
|
|||||||
+184
@@ -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;
|
||||||
@@ -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;
|
||||||
+187
@@ -51,12 +51,40 @@ const Stats = {
|
|||||||
<div class="stat-label">Achievements Unlocked</div>
|
<div class="stat-label">Achievements Unlocked</div>
|
||||||
<div class="stat-value" id="stat-achievements">0/${Object.keys(ACHIEVEMENTS).length}</div>
|
<div class="stat-value" id="stat-achievements">0/${Object.keys(ACHIEVEMENTS).length}</div>
|
||||||
</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-item">
|
||||||
<div class="stat-label">Game Progress</div>
|
<div class="stat-label">Game Progress</div>
|
||||||
<div class="stat-bar">
|
<div class="stat-bar">
|
||||||
<div class="stat-bar-fill" id="stat-progress-bar" style="width: 0%"></div>
|
<div class="stat-bar-fill" id="stat-progress-bar" style="width: 0%"></div>
|
||||||
</div>
|
</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()}`;
|
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)
|
// Game progress (based on total stardust milestones)
|
||||||
const progressBarEl = document.getElementById('stat-progress-bar');
|
const progressBarEl = document.getElementById('stat-progress-bar');
|
||||||
if (progressBarEl) {
|
if (progressBarEl) {
|
||||||
const progress = this.calculateProgress();
|
const progress = this.calculateProgress();
|
||||||
progressBarEl.style.width = `${progress}%`;
|
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;
|
||||||
},
|
},
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ const UI = {
|
|||||||
this.renderBuildings();
|
this.renderBuildings();
|
||||||
this.renderUpgrades();
|
this.renderUpgrades();
|
||||||
this.renderAchievements();
|
this.renderAchievements();
|
||||||
|
this.updateSlotUI();
|
||||||
this.update();
|
this.update();
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -35,6 +36,18 @@ const UI = {
|
|||||||
|
|
||||||
// Stats
|
// Stats
|
||||||
Stats.update();
|
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-cost">${Game.formatNumber(cost)} ✨</div>
|
||||||
<div class="building-count">${count}</div>
|
<div class="building-count">${count}</div>
|
||||||
<button class="buy-btn" ${!canAfford || locked ? 'disabled' : ''}>Buy</button>
|
<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>
|
</div>
|
||||||
`;
|
`;
|
||||||
|
|
||||||
@@ -73,6 +87,12 @@ const UI = {
|
|||||||
card.querySelector('.buy-btn').addEventListener('click', () => {
|
card.querySelector('.buy-btn').addEventListener('click', () => {
|
||||||
Game.buyBuilding(id);
|
Game.buyBuilding(id);
|
||||||
});
|
});
|
||||||
|
const maxBtn = card.querySelector('.buy-max-btn');
|
||||||
|
if (maxBtn) {
|
||||||
|
maxBtn.addEventListener('click', () => {
|
||||||
|
Game.buyMaxBuilding(id);
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
container.appendChild(card);
|
container.appendChild(card);
|
||||||
@@ -100,6 +120,15 @@ const UI = {
|
|||||||
btn.disabled = !canAfford || locked;
|
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);
|
card.classList.toggle('locked', locked);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -169,6 +198,43 @@ const UI = {
|
|||||||
costEl.textContent = canAfford ? `${Game.formatNumber(upgrade.cost)} ✨` : `❌ ${Game.formatNumber(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
|
* Show floating text animation
|
||||||
*/
|
*/
|
||||||
|
|||||||
Reference in New Issue
Block a user