Kimi K3
MoonshotJuly 2026
What it wrote
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>STARFORGE — Cosmic Incremental</title>
<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='.9em' font-size='90'>⭐</text></svg>" />
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=Orbitron:wght@500;700;900&family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet" />
<link rel="stylesheet" href="style.css" />
</head>
<body>
<canvas id="bg"></canvas>
<div id="app">
<header id="topbar">
<div class="brand">✦ STAR<span>FORGE</span></div>
<div class="top-stats">
<div class="chip chip-dust" title="Stardust">
<span class="chip-icon">✨</span>
<span class="chip-num" id="dust-count">0</span>
</div>
<div class="chip" title="Stardust per second">
<span class="chip-num" id="pps-count">0</span><span class="chip-unit">/s</span>
</div>
<div class="chip chip-cores hidden" id="chip-cores" title="Stellar Cores — permanent production bonus">
<span class="chip-icon">🌟</span>
<span class="chip-num" id="core-count">0</span>
</div>
</div>
<div class="top-actions">
<button id="btn-sound" class="icon-btn" title="Toggle sound">🔊</button>
<button id="btn-particles" class="icon-btn" title="Toggle particles">✨</button>
<button id="btn-save" class="icon-btn" title="Save now">💾</button>
<button id="btn-wipe" class="icon-btn danger" title="Hard reset save">🗑️</button>
</div>
</header>
<main>
<!-- ══════════ LEFT: THE STAR ══════════ -->
<section id="stage">
<div id="combo-wrap" class="hidden">
<div id="combo-track"><div id="combo-bar"></div></div>
<div id="combo-label">COMBO ×1.0</div>
</div>
<div id="star-wrap">
<div id="star-aura"></div>
<div id="star" role="button" tabindex="0" aria-label="Forge stardust">
<div id="star-core"></div>
</div>
<div id="star-ring"></div>
<div id="star-ring2"></div>
</div>
<div id="click-power">+1 per click</div>
<div id="float-layer"></div>
<div id="comet" class="hidden" title="Catch the comet!">☄️</div>
</section>
<!-- ══════════ RIGHT: CONTROL PANEL ══════════ -->
<aside id="panel">
<nav id="tabs">
<button class="tab active" data-tab="generators">🏭<span>Generators</span></button>
<button class="tab" data-tab="upgrades">⚡<span>Upgrades</span><i id="upg-badge" class="badge hidden"></i></button>
<button class="tab" data-tab="prestige">💫<span>Supernova</span></button>
<button class="tab" data-tab="stats">📊<span>Stats</span></button>
</nav>
<div id="tab-generators" class="tab-page active">
<div class="qty-row">
<span class="qty-label">Buy</span>
<button class="qty active" data-qty="1">×1</button>
<button class="qty" data-qty="10">×10</button>
<button class="qty" data-qty="100">×100</button>
<button class="qty" data-qty="max">MAX</button>
</div>
<div id="generator-list"></div>
</div>
<div id="tab-upgrades" class="tab-page">
<div id="upgrade-list"></div>
</div>
<div id="tab-prestige" class="tab-page">
<div class="prestige-hero">💫</div>
<h2>GO SUPERNOVA</h2>
<p class="prestige-desc">
Collapse your star into a brilliant supernova. You lose all stardust,
generators and upgrades — but gain <b>Stellar Cores</b>. Each core
permanently boosts ALL production by <b>+5%</b>, forever.
</p>
<div class="prestige-stats">
<div class="pstat"><span>Current cores</span><b id="prestige-cores">0</b></div>
<div class="pstat"><span>Current bonus</span><b id="prestige-bonus">+0%</b></div>
<div class="pstat"><span>Cores on reset</span><b id="prestige-pending" class="gold">0</b></div>
</div>
<div id="prestige-progress-wrap">
<div id="prestige-progress"></div>
</div>
<div id="prestige-next">Next core at 1.00M stardust forged this run</div>
<button id="btn-prestige" class="big-btn" disabled>💫 GO SUPERNOVA</button>
<div id="prestige-history"></div>
</div>
<div id="tab-stats" class="tab-page">
<div id="stats-list"></div>
<h3 class="ach-title">🏆 Achievements <span id="ach-count"></span></h3>
<div id="ach-grid"></div>
</div>
</aside>
</main>
</div>
<div id="toast-stack"></div>
<div id="modal-root"></div>
<div id="flash"></div>
<script src="game.js"></script>
</body>
</html>/* ═══════════════════════ STARFORGE — style.css ═══════════════════════ */
:root {
--bg0: #05060f;
--bg1: #0b0e23;
--panel: rgba(17, 21, 48, 0.82);
--panel-border: rgba(122, 140, 255, 0.16);
--gold: #ffd76a;
--gold-deep: #ff9d3c;
--cyan: #6ae6ff;
--violet: #9a6aff;
--text: #e8ecff;
--text-dim: #8a93c4;
--good: #5effa6;
--bad: #ff5e7a;
--radius: 14px;
font-size: 16px;
}
* { margin: 0; padding: 0; box-sizing: border-box; }
html, body { height: 100%; }
body {
background: radial-gradient(1200px 800px at 20% 10%, #141a3f 0%, var(--bg1) 45%, var(--bg0) 100%);
color: var(--text);
font-family: "Inter", system-ui, sans-serif;
overflow: hidden;
user-select: none;
}
#bg {
position: fixed;
inset: 0;
z-index: 0;
}
#app {
position: relative;
z-index: 1;
height: 100vh;
display: flex;
flex-direction: column;
}
.hidden { display: none !important; }
/* ─────────────────────────── Top bar ─────────────────────────── */
#topbar {
display: flex;
align-items: center;
gap: 18px;
padding: 12px 20px;
background: rgba(8, 10, 26, 0.7);
border-bottom: 1px solid var(--panel-border);
backdrop-filter: blur(12px);
}
.brand {
font-family: "Orbitron", sans-serif;
font-weight: 900;
font-size: 1.25rem;
letter-spacing: 0.14em;
color: var(--gold);
text-shadow: 0 0 18px rgba(255, 215, 106, 0.55);
}
.brand span { color: var(--cyan); text-shadow: 0 0 18px rgba(106, 230, 255, 0.55); }
.top-stats {
display: flex;
gap: 10px;
flex: 1;
justify-content: center;
flex-wrap: wrap;
}
.chip {
display: flex;
align-items: baseline;
gap: 6px;
padding: 6px 16px;
border-radius: 999px;
background: rgba(122, 140, 255, 0.08);
border: 1px solid var(--panel-border);
font-family: "Orbitron", sans-serif;
font-size: 0.95rem;
font-weight: 700;
}
.chip-dust .chip-num { color: var(--gold); text-shadow: 0 0 12px rgba(255, 215, 106, 0.6); }
.chip-cores .chip-num { color: var(--violet); text-shadow: 0 0 12px rgba(154, 106, 255, 0.6); }
.chip-unit { color: var(--text-dim); font-size: 0.75rem; }
.top-actions { display: flex; gap: 8px; }
.icon-btn {
width: 38px;
height: 38px;
border-radius: 10px;
border: 1px solid var(--panel-border);
background: rgba(122, 140, 255, 0.08);
cursor: pointer;
font-size: 1rem;
transition: transform 0.12s, background 0.12s, box-shadow 0.12s;
}
.icon-btn:hover { background: rgba(122, 140, 255, 0.2); transform: translateY(-1px); box-shadow: 0 4px 14px rgba(0,0,0,0.4); }
.icon-btn.off { opacity: 0.35; filter: grayscale(0.8); }
.icon-btn.danger:hover { background: rgba(255, 94, 122, 0.2); }
/* ─────────────────────────── Layout ─────────────────────────── */
main {
flex: 1;
display: grid;
grid-template-columns: 1fr 420px;
min-height: 0;
}
/* ─────────────────────────── Stage (star) ─────────────────────────── */
#stage {
position: relative;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 26px;
overflow: hidden;
}
#star-wrap {
position: relative;
width: 320px;
height: 320px;
display: grid;
place-items: center;
}
#star-aura {
position: absolute;
inset: -70px;
border-radius: 50%;
background: radial-gradient(circle, rgba(255, 200, 80, 0.28) 0%, rgba(255, 140, 60, 0.1) 42%, transparent 70%);
animation: auraBreathe 4s ease-in-out infinite;
pointer-events: none;
}
@keyframes auraBreathe {
0%, 100% { transform: scale(1); opacity: 0.85; }
50% { transform: scale(1.12); opacity: 1; }
}
#star {
position: relative;
width: 220px;
height: 220px;
border-radius: 50%;
cursor: pointer;
z-index: 3;
transition: transform 0.08s ease-out;
animation: starFloat 6s ease-in-out infinite;
-webkit-tap-highlight-color: transparent;
outline: none;
}
@keyframes starFloat {
0%, 100% { translate: 0 0; }
50% { translate: 0 -12px; }
}
#star-core {
position: absolute;
inset: 0;
border-radius: 50%;
background:
radial-gradient(circle at 38% 34%, #fffbe8 0%, #ffe89a 18%, #ffbe4e 42%, #ff7a2f 68%, #c9401f 100%);
box-shadow:
0 0 40px rgba(255, 190, 78, 0.9),
0 0 110px rgba(255, 140, 50, 0.55),
0 0 200px rgba(255, 110, 40, 0.3),
inset -18px -22px 60px rgba(160, 30, 10, 0.55),
inset 10px 12px 40px rgba(255, 255, 230, 0.35);
animation: coreChurn 5s linear infinite;
}
@keyframes coreChurn {
0%, 100% { filter: brightness(1) saturate(1); }
50% { filter: brightness(1.14) saturate(1.15); }
}
#star.pulse { animation: starPulse 0.18s ease-out, starFloat 6s ease-in-out infinite; }
@keyframes starPulse {
0% { transform: scale(1); }
35% { transform: scale(0.92); }
100% { transform: scale(1); }
}
#star-ring, #star-ring2 {
position: absolute;
border-radius: 50%;
border: 1px dashed rgba(255, 215, 106, 0.28);
pointer-events: none;
}
#star-ring { width: 290px; height: 290px; animation: spin 26s linear infinite; }
#star-ring2 { width: 330px; height: 330px; border-color: rgba(106, 230, 255, 0.18); animation: spin 40s linear infinite reverse; }
#star-ring::before, #star-ring2::before {
content: "";
position: absolute;
top: -4px; left: 50%;
width: 8px; height: 8px;
border-radius: 50%;
background: var(--gold);
box-shadow: 0 0 10px var(--gold);
}
#star-ring2::before { background: var(--cyan); box-shadow: 0 0 10px var(--cyan); }
@keyframes spin { to { transform: rotate(360deg); } }
#click-power {
font-family: "Orbitron", sans-serif;
font-size: 1.05rem;
font-weight: 700;
color: var(--text-dim);
letter-spacing: 0.08em;
}
#click-power b { color: var(--gold); }
/* Combo */
#combo-wrap {
position: absolute;
top: 22px;
left: 50%;
transform: translateX(-50%);
width: 240px;
text-align: center;
z-index: 5;
}
#combo-track {
height: 8px;
border-radius: 999px;
background: rgba(122, 140, 255, 0.12);
border: 1px solid var(--panel-border);
overflow: hidden;
}
#combo-bar {
height: 100%;
width: 0%;
border-radius: 999px;
background: linear-gradient(90deg, var(--gold-deep), var(--gold), #fff3c4);
box-shadow: 0 0 12px rgba(255, 190, 78, 0.8);
transition: width 0.1s linear;
}
#combo-label {
margin-top: 6px;
font-family: "Orbitron", sans-serif;
font-size: 0.8rem;
font-weight: 700;
letter-spacing: 0.18em;
color: var(--gold);
text-shadow: 0 0 10px rgba(255, 215, 106, 0.7);
}
/* Floating click numbers */
#float-layer { position: absolute; inset: 0; pointer-events: none; z-index: 6; overflow: hidden; }
.float-num {
position: absolute;
font-family: "Orbitron", sans-serif;
font-weight: 900;
font-size: 1.15rem;
color: var(--gold);
text-shadow: 0 0 12px rgba(255, 190, 78, 0.9), 0 2px 6px rgba(0,0,0,0.6);
animation: floatUp 1.1s ease-out forwards;
will-change: transform, opacity;
}
.float-num.crit { color: #fff3c4; font-size: 1.45rem; }
@keyframes floatUp {
0% { opacity: 0; transform: translate(-50%, 0) scale(0.6); }
12% { opacity: 1; transform: translate(-50%, -14px) scale(1.05); }
100% { opacity: 0; transform: translate(-50%, -110px) scale(0.9); }
}
/* Comet */
#comet {
position: absolute;
z-index: 7;
font-size: 2.6rem;
cursor: pointer;
filter: drop-shadow(0 0 18px rgba(255, 230, 150, 0.95));
animation: cometWobble 0.7s ease-in-out infinite;
transition: transform 0.1s;
}
#comet:hover { transform: scale(1.2); }
@keyframes cometWobble {
0%, 100% { rotate: -8deg; }
50% { rotate: 8deg; }
}
/* ─────────────────────────── Panel ─────────────────────────── */
#panel {
display: flex;
flex-direction: column;
min-height: 0;
background: var(--panel);
border-left: 1px solid var(--panel-border);
backdrop-filter: blur(16px);
}
#tabs {
display: grid;
grid-template-columns: repeat(4, 1fr);
border-bottom: 1px solid var(--panel-border);
}
.tab {
position: relative;
padding: 13px 4px;
background: transparent;
border: none;
border-bottom: 2px solid transparent;
color: var(--text-dim);
font-family: "Inter", sans-serif;
font-size: 0.82rem;
font-weight: 600;
cursor: pointer;
display: flex;
flex-direction: column;
align-items: center;
gap: 3px;
transition: color 0.15s, background 0.15s;
}
.tab:hover { color: var(--text); background: rgba(122, 140, 255, 0.06); }
.tab.active { color: var(--gold); border-bottom-color: var(--gold); background: rgba(255, 215, 106, 0.05); }
.badge {
position: absolute;
top: 6px;
right: 14px;
min-width: 17px;
height: 17px;
padding: 0 4px;
border-radius: 999px;
background: var(--bad);
color: #fff;
font-size: 0.66rem;
font-weight: 700;
font-style: normal;
display: grid;
place-items: center;
box-shadow: 0 0 8px rgba(255, 94, 122, 0.8);
}
.tab-page {
display: none;
flex: 1;
min-height: 0;
overflow-y: auto;
padding: 14px;
scrollbar-width: thin;
scrollbar-color: rgba(122, 140, 255, 0.3) transparent;
}
.tab-page.active { display: block; }
/* Quantity selector */
.qty-row {
display: flex;
gap: 6px;
align-items: center;
margin-bottom: 12px;
position: sticky;
top: -14px;
padding: 10px 0;
background: var(--panel);
z-index: 2;
}
.qty-label { font-size: 0.8rem; color: var(--text-dim); font-weight: 600; margin-right: 2px; }
.qty {
padding: 5px 12px;
border-radius: 8px;
border: 1px solid var(--panel-border);
background: rgba(122, 140, 255, 0.07);
color: var(--text-dim);
font-size: 0.78rem;
font-weight: 700;
cursor: pointer;
transition: all 0.12s;
}
.qty:hover { color: var(--text); }
.qty.active { background: rgba(255, 215, 106, 0.15); border-color: rgba(255, 215, 106, 0.5); color: var(--gold); }
/* Generator & upgrade cards */
.card {
position: relative;
display: flex;
align-items: center;
gap: 12px;
padding: 12px 14px;
margin-bottom: 10px;
border-radius: var(--radius);
background: rgba(122, 140, 255, 0.06);
border: 1px solid var(--panel-border);
cursor: pointer;
transition: transform 0.12s, border-color 0.12s, background 0.12s, box-shadow 0.12s;
}
.card:hover { transform: translateY(-1px); background: rgba(122, 140, 255, 0.11); }
.card.affordable {
border-color: rgba(255, 215, 106, 0.45);
background: rgba(255, 215, 106, 0.06);
box-shadow: 0 0 18px rgba(255, 215, 106, 0.08), inset 0 0 24px rgba(255, 215, 106, 0.03);
}
.card.affordable:hover { box-shadow: 0 0 26px rgba(255, 215, 106, 0.18); }
.card.locked { cursor: not-allowed; opacity: 0.45; }
.card.locked:hover { transform: none; }
.card.mystery { filter: saturate(0.2); }
.card-icon {
font-size: 1.9rem;
width: 52px;
height: 52px;
flex-shrink: 0;
display: grid;
place-items: center;
border-radius: 12px;
background: rgba(5, 7, 20, 0.55);
border: 1px solid var(--panel-border);
}
.card.affordable .card-icon { animation: iconBob 1.6s ease-in-out infinite; }
@keyframes iconBob {
0%, 100% { transform: translateY(0); }
50% { transform: translateY(-3px); }
}
.card-body { flex: 1; min-width: 0; }
.card-name { font-weight: 700; font-size: 0.92rem; display: flex; justify-content: space-between; gap: 8px; }
.card-owned { font-family: "Orbitron", sans-serif; color: var(--cyan); font-size: 0.85rem; }
.card-desc { font-size: 0.74rem; color: var(--text-dim); margin-top: 2px; line-height: 1.35; }
.card-meta { display: flex; justify-content: space-between; align-items: baseline; margin-top: 6px; gap: 8px; }
.card-cost { font-family: "Orbitron", sans-serif; font-size: 0.82rem; font-weight: 700; color: var(--gold); }
.card-cost.cant { color: var(--bad); }
.card-rate { font-size: 0.72rem; color: var(--text-dim); white-space: nowrap; }
.card.owned-upgrade { opacity: 0.5; cursor: default; border-color: rgba(94, 255, 166, 0.3); }
.card.owned-upgrade:hover { transform: none; }
.card.owned-upgrade .card-cost { color: var(--good); }
/* ─────────────────────────── Prestige ─────────────────────────── */
#tab-prestige { text-align: center; padding: 26px 22px; }
.prestige-hero { font-size: 3.4rem; animation: iconBob 3s ease-in-out infinite; filter: drop-shadow(0 0 24px rgba(154, 106, 255, 0.8)); }
#tab-prestige h2 {
font-family: "Orbitron", sans-serif;
letter-spacing: 0.2em;
margin: 10px 0 14px;
background: linear-gradient(90deg, var(--violet), var(--cyan));
-webkit-background-clip: text;
background-clip: text;
color: transparent;
}
.prestige-desc { font-size: 0.85rem; color: var(--text-dim); line-height: 1.6; margin-bottom: 20px; }
.prestige-desc b { color: var(--violet); }
.prestige-stats { display: flex; flex-direction: column; gap: 8px; margin-bottom: 18px; }
.pstat {
display: flex;
justify-content: space-between;
padding: 10px 14px;
border-radius: 10px;
background: rgba(122, 140, 255, 0.07);
border: 1px solid var(--panel-border);
font-size: 0.85rem;
}
.pstat b { font-family: "Orbitron", sans-serif; color: var(--violet); }
.pstat b.gold { color: var(--gold); }
#prestige-progress-wrap {
height: 10px;
border-radius: 999px;
background: rgba(122, 140, 255, 0.12);
border: 1px solid var(--panel-border);
overflow: hidden;
margin-bottom: 8px;
}
#prestige-progress {
height: 100%;
width: 0%;
background: linear-gradient(90deg, var(--violet), var(--cyan));
box-shadow: 0 0 12px rgba(154, 106, 255, 0.8);
transition: width 0.3s;
}
#prestige-next { font-size: 0.75rem; color: var(--text-dim); margin-bottom: 20px; }
.big-btn {
width: 100%;
padding: 15px;
border-radius: 12px;
border: 1px solid rgba(154, 106, 255, 0.6);
background: linear-gradient(135deg, rgba(154, 106, 255, 0.25), rgba(106, 230, 255, 0.15));
color: var(--text);
font-family: "Orbitron", sans-serif;
font-size: 1rem;
font-weight: 900;
letter-spacing: 0.14em;
cursor: pointer;
transition: all 0.15s;
}
.big-btn:hover:not(:disabled) {
box-shadow: 0 0 30px rgba(154, 106, 255, 0.45);
transform: translateY(-2px);
}
.big-btn:disabled { opacity: 0.35; cursor: not-allowed; }
#prestige-history { margin-top: 18px; font-size: 0.78rem; color: var(--text-dim); line-height: 1.6; }
/* ─────────────────────────── Stats & achievements ─────────────────────────── */
.stat-row {
display: flex;
justify-content: space-between;
gap: 10px;
padding: 9px 4px;
border-bottom: 1px solid rgba(122, 140, 255, 0.09);
font-size: 0.85rem;
}
.stat-row span { color: var(--text-dim); }
.stat-row b { font-family: "Orbitron", sans-serif; font-weight: 700; text-align: right; }
.ach-title { margin: 22px 0 12px; font-size: 0.95rem; letter-spacing: 0.06em; }
.ach-title span { color: var(--text-dim); font-weight: 500; font-size: 0.8rem; }
#ach-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(64px, 1fr)); gap: 8px; }
.ach {
aspect-ratio: 1;
display: grid;
place-items: center;
border-radius: 12px;
background: rgba(122, 140, 255, 0.05);
border: 1px solid var(--panel-border);
font-size: 1.5rem;
filter: grayscale(1);
opacity: 0.35;
transition: all 0.2s;
cursor: help;
}
.ach.unlocked {
filter: none;
opacity: 1;
background: rgba(255, 215, 106, 0.1);
border-color: rgba(255, 215, 106, 0.45);
box-shadow: 0 0 14px rgba(255, 215, 106, 0.12);
}
/* ─────────────────────────── Toasts ─────────────────────────── */
#toast-stack {
position: fixed;
top: 70px;
right: 18px;
z-index: 100;
display: flex;
flex-direction: column;
gap: 10px;
pointer-events: none;
}
.toast {
min-width: 240px;
max-width: 320px;
padding: 12px 16px;
border-radius: 12px;
background: rgba(14, 18, 42, 0.95);
border: 1px solid rgba(255, 215, 106, 0.5);
box-shadow: 0 8px 30px rgba(0, 0, 0, 0.5), 0 0 20px rgba(255, 215, 106, 0.12);
display: flex;
gap: 12px;
align-items: center;
animation: toastIn 0.3s cubic-bezier(0.2, 1.4, 0.4, 1), toastOut 0.4s ease-in 4.2s forwards;
}
.toast-icon { font-size: 1.6rem; }
.toast-title { font-weight: 700; font-size: 0.85rem; color: var(--gold); }
.toast-msg { font-size: 0.75rem; color: var(--text-dim); margin-top: 2px; }
@keyframes toastIn {
from { opacity: 0; transform: translateX(60px); }
to { opacity: 1; transform: translateX(0); }
}
@keyframes toastOut {
to { opacity: 0; transform: translateX(60px); }
}
/* ─────────────────────────── Modal ─────────────────────────── */
#modal-root:empty { display: none; }
#modal-root {
position: fixed;
inset: 0;
z-index: 200;
display: grid;
place-items: center;
background: rgba(3, 4, 12, 0.75);
backdrop-filter: blur(6px);
}
.modal {
width: min(420px, 90vw);
padding: 28px;
border-radius: 18px;
background: linear-gradient(160deg, #161b3f, #0b0e23);
border: 1px solid rgba(122, 140, 255, 0.3);
box-shadow: 0 20px 80px rgba(0, 0, 0, 0.7);
text-align: center;
animation: modalIn 0.25s cubic-bezier(0.2, 1.4, 0.4, 1);
}
@keyframes modalIn {
from { opacity: 0; transform: scale(0.85); }
to { opacity: 1; transform: scale(1); }
}
.modal-icon { font-size: 3rem; margin-bottom: 10px; }
.modal h3 { font-family: "Orbitron", sans-serif; letter-spacing: 0.1em; margin-bottom: 10px; color: var(--gold); }
.modal p { font-size: 0.88rem; color: var(--text-dim); line-height: 1.6; margin-bottom: 20px; }
.modal p b { color: var(--gold); }
.modal-btns { display: flex; gap: 10px; }
.modal-btns button {
flex: 1;
padding: 12px;
border-radius: 10px;
border: 1px solid var(--panel-border);
background: rgba(122, 140, 255, 0.1);
color: var(--text);
font-weight: 700;
font-size: 0.88rem;
cursor: pointer;
transition: all 0.12s;
}
.modal-btns button:hover { background: rgba(122, 140, 255, 0.22); }
.modal-btns button.primary { background: rgba(255, 215, 106, 0.18); border-color: rgba(255, 215, 106, 0.55); color: var(--gold); }
.modal-btns button.danger { background: rgba(255, 94, 122, 0.15); border-color: rgba(255, 94, 122, 0.5); color: var(--bad); }
/* Screen flash (supernova / comet) */
#flash {
position: fixed;
inset: 0;
z-index: 150;
pointer-events: none;
background: radial-gradient(circle, rgba(255, 255, 240, 0.95) 0%, rgba(255, 200, 100, 0.5) 45%, transparent 75%);
opacity: 0;
}
#flash.go { animation: flashGo 0.9s ease-out; }
@keyframes flashGo {
0% { opacity: 0; }
15% { opacity: 1; }
100% { opacity: 0; }
}
/* Shake for big events */
body.shake #app { animation: shake 0.5s ease-in-out; }
@keyframes shake {
0%, 100% { transform: translate(0, 0); }
20% { transform: translate(-6px, 3px); }
40% { transform: translate(5px, -4px); }
60% { transform: translate(-4px, -2px); }
80% { transform: translate(3px, 2px); }
}
/* ─────────────────────────── Responsive ─────────────────────────── */
@media (max-width: 900px) {
body { overflow: auto; }
#app { height: auto; min-height: 100vh; }
main { grid-template-columns: 1fr; grid-template-rows: 480px auto; }
#panel { border-left: none; border-top: 1px solid var(--panel-border); min-height: 60vh; }
.tab-page { max-height: 60vh; }
#star-wrap { transform: scale(0.85); }
.top-stats { justify-content: flex-start; }
}/* ═══════════════════════ STARFORGE — game.js ═══════════════════════ */
'use strict';
/* ─────────────────────────── Helpers ─────────────────────────── */
const $ = (id) => document.getElementById(id);
const SUF = ['', 'K', 'M', 'B', 'T', 'Qa', 'Qi', 'Sx', 'Sp', 'Oc', 'No', 'Dc', 'UDc', 'DDc', 'TDc', 'QaDc'];
function fmt(n) {
if (!isFinite(n)) return '∞';
if (n < 0) return '-' + fmt(-n);
if (n < 1000) {
if (Number.isInteger(n)) return String(n);
return n < 10 ? n.toFixed(1) : String(Math.floor(n));
}
const tier = Math.floor(Math.log10(n) / 3);
if (tier >= SUF.length) return n.toExponential(2).replace('e+', 'e');
const s = n / Math.pow(10, tier * 3);
return (s >= 100 ? s.toFixed(0) : s >= 10 ? s.toFixed(1) : s.toFixed(2)) + SUF[tier];
}
function fmtTime(sec) {
sec = Math.floor(sec);
const h = Math.floor(sec / 3600), m = Math.floor((sec % 3600) / 60), s = sec % 60;
if (h > 0) return `${h}h ${m}m`;
if (m > 0) return `${m}m ${s}s`;
return `${s}s`;
}
const rand = (a, b) => a + Math.random() * (b - a);
const clamp = (v, a, b) => Math.max(a, Math.min(b, v));
/* ─────────────────────────── Game data ─────────────────────────── */
const COST_GROWTH = 1.15;
const BUILDINGS = [
{ id: 'miner', name: 'Asteroid Miner', icon: '⛏️', baseCost: 15, baseRate: 0.1, desc: 'Chips stardust off wandering asteroids.' },
{ id: 'solar', name: 'Solar Array', icon: '🛰️', baseCost: 100, baseRate: 1, desc: 'Basks in the glow and sips the photons.' },
{ id: 'lunar', name: 'Lunar Colony', icon: '🌙', baseCost: 1100, baseRate: 8, desc: 'Moon folk harvest dust around the clock.' },
{ id: 'nebula', name: 'Nebula Harvester', icon: '🌫️', baseCost: 12000, baseRate: 47, desc: 'Trawls neon clouds for stray sparkle.' },
{ id: 'shipyard', name: 'Orbital Shipyard', icon: '🚀', baseCost: 130000, baseRate: 260, desc: 'Mass-produces dust-catcher drones.' },
{ id: 'siphon', name: 'Black Hole Siphon', icon: '🕳️', baseCost: 1.4e6, baseRate: 1400, desc: 'Steals light back from the abyss.' },
{ id: 'dyson', name: 'Dyson Sphere', icon: '🌐', baseCost: 2.0e7, baseRate: 7800, desc: 'A whole star, politely bottled.' },
{ id: 'galaxy', name: 'Galaxy Engine', icon: '🌀', baseCost: 3.3e8, baseRate: 44000, desc: 'Spins entire galaxies for pocket dust.' },
];
// Upgrades: per-building tiers generated + hand-made click/global upgrades
const UPGRADES = [];
BUILDINGS.forEach((b) => {
[
{ need: 10, cm: 15, label: 'I' },
{ need: 25, cm: 200, label: 'II' },
{ need: 50, cm: 2500, label: 'III' },
].forEach((t, i) => {
UPGRADES.push({
id: `${b.id}-u${i + 1}`, icon: b.icon, name: `${b.name} ${t.label}`,
desc: `${b.name}s are twice as efficient.`,
cost: b.baseCost * t.cm, type: 'building', target: b.id,
visible: () => owned(b.id) >= t.need,
});
});
});
UPGRADES.push(
{ id: 'click1', icon: '👆', name: 'Reinforced Finger', desc: 'Clicking power ×2.', cost: 100, type: 'click', visible: () => state.clicks >= 15 },
{ id: 'click2', icon: '🖐️', name: 'Plasma Palm', desc: 'Clicking power ×2.', cost: 5000, type: 'click', visible: () => state.runDust >= 1500 },
{ id: 'click3', icon: '✊', name: 'Antimatter Fist', desc: 'Clicking power ×2.', cost: 250000, type: 'click', visible: () => state.runDust >= 60000 },
{ id: 'click4', icon: '🤜', name: 'Quantum Knuckles', desc: 'Clicking power ×2.', cost: 5e7, type: 'click', visible: () => state.runDust >= 1e7 },
{ id: 'click5', icon: '⚛️', name: 'Singularity Touch', desc: 'Clicking power ×2.', cost: 5e9, type: 'click', visible: () => state.runDust >= 1e9 },
{ id: 'magnet1', icon: '🧲', name: 'Stardust Magnetism', desc: 'ALL production +10%.', cost: 50000, type: 'global', mult: 0.10, visible: () => state.runDust >= 12000 },
{ id: 'magnet2', icon: '🌠', name: 'Cosmic Magnetism', desc: 'ALL production +10%.', cost: 5e6, type: 'global', mult: 0.10, visible: () => state.runDust >= 1e6 },
{ id: 'magnet3', icon: '🌌', name: 'Galactic Magnetism', desc: 'ALL production +25%.', cost: 5e8, type: 'global', mult: 0.25, visible: () => state.runDust >= 1e8 },
{ id: 'siphon-click', icon: '🔄', name: 'Kinetic Siphon', desc: 'Clicks also gain 2% of your per-second rate.', cost: 1e6, type: 'flag', visible: () => state.runDust >= 250000 },
{ id: 'comet-up', icon: '☄️', name: 'Comet Whisperer', desc: 'Comets appear 40% more often and grant +50% more.', cost: 2e5, type: 'flag', visible: () => state.comets >= 1 },
);
const ACHIEVEMENTS = [
{ id: 'click-1', icon: '👆', name: 'First Contact', desc: 'Click the star once.', check: () => state.clicks >= 1 },
{ id: 'click-100', icon: '🖐️', name: 'Warming Up', desc: 'Click 100 times.', check: () => state.clicks >= 100 },
{ id: 'click-1k', icon: '✊', name: 'Carpal Tunnel', desc: 'Click 1,000 times.', check: () => state.clicks >= 1000 },
{ id: 'click-10k', icon: '👊', name: 'Finger of God', desc: 'Click 10,000 times.', check: () => state.clicks >= 10000 },
{ id: 'dust-1k', icon: '✨', name: 'Spark', desc: 'Forge 1K stardust (all time).', check: () => state.allTime >= 1e3 },
{ id: 'dust-1m', icon: '🌟', name: 'Flare', desc: 'Forge 1M stardust (all time).', check: () => state.allTime >= 1e6 },
{ id: 'dust-1b', icon: '💫', name: 'Nova', desc: 'Forge 1B stardust (all time).', check: () => state.allTime >= 1e9 },
{ id: 'dust-1t', icon: '🌌', name: 'Quasar', desc: 'Forge 1T stardust (all time).', check: () => state.allTime >= 1e12 },
{ id: 'b-first', icon: '🔧', name: 'Automation Begins', desc: 'Own your first generator.', check: () => totalBuildings() >= 1 },
{ id: 'b-10', icon: '📦', name: 'Fleet of Ten', desc: 'Own 10 of any generator.', check: () => BUILDINGS.some((b) => owned(b.id) >= 10) },
{ id: 'b-50', icon: '🏗️', name: 'Industrialist', desc: 'Own 50 of any generator.', check: () => BUILDINGS.some((b) => owned(b.id) >= 50) },
{ id: 'b-100', icon: '🏙️', name: 'Megastructure', desc: 'Own 100 of any generator.', check: () => BUILDINGS.some((b) => owned(b.id) >= 100) },
{ id: 'b-all', icon: '🌍', name: 'Full Spectrum', desc: 'Own every type of generator.', check: () => BUILDINGS.every((b) => owned(b.id) >= 1) },
{ id: 'b-total100', icon: '🏭', name: 'Century Club', desc: 'Own 100 generators total.', check: () => totalBuildings() >= 100 },
{ id: 'pps-100', icon: '⚡', name: 'Humming Grid', desc: 'Reach 100 stardust/s.', check: () => pps() >= 100 },
{ id: 'pps-10k', icon: '🔥', name: 'Roaring Grid', desc: 'Reach 10K stardust/s.', check: () => pps() >= 1e4 },
{ id: 'pps-1m', icon: '☀️', name: 'Stellar Grid', desc: 'Reach 1M stardust/s.', check: () => pps() >= 1e6 },
{ id: 'prestige-1', icon: '💫', name: 'Beautiful Death', desc: 'Go supernova once.', check: () => state.prestiges >= 1 },
{ id: 'prestige-5', icon: '🌠', name: 'Cycle of Rebirth', desc: 'Go supernova 5 times.', check: () => state.prestiges >= 5 },
{ id: 'comet-1', icon: '☄️', name: 'Comet Catcher', desc: 'Catch a golden comet.', check: () => state.comets >= 1 },
{ id: 'comet-10', icon: '🎯', name: 'Comet Whisperer', desc: 'Catch 10 golden comets.', check: () => state.comets >= 10 },
{ id: 'combo-max', icon: '🥁', name: 'Maximum Rhythm', desc: 'Reach a ×5 combo.', check: () => state.bestCombo >= 50 },
{ id: 'upg-10', icon: '🧰', name: 'Tinkerer', desc: 'Own 10 upgrades.', check: () => Object.keys(state.upgrades).length >= 10 },
];
/* ─────────────────────────── State ─────────────────────────── */
const SAVE_KEY = 'starforge-save-v1';
let state = defaultState();
let qty = '1';
let lastTick = performance.now();
let lastClickTime = 0;
let combo = 0;
let saveTimer = null;
function defaultState() {
return {
dust: 0, runDust: 0, allTime: 0, clicks: 0,
buildings: {}, upgrades: {}, ach: {},
cores: 0, prestiges: 0, comets: 0, bestCombo: 0,
runStart: Date.now(), totalStart: Date.now(), lastSeen: Date.now(),
settings: { sound: true, particles: true },
};
}
function save() {
state.lastSeen = Date.now();
try { localStorage.setItem(SAVE_KEY, JSON.stringify(state)); } catch (e) { /* storage full/blocked */ }
}
function load() {
try {
const raw = localStorage.getItem(SAVE_KEY);
if (!raw) return false;
const data = JSON.parse(raw);
const fresh = defaultState();
Object.assign(fresh, data);
fresh.settings = Object.assign(defaultState().settings, data.settings || {});
state = fresh;
return true;
} catch (e) { return false; }
}
/* ─────────────────────────── Derived values ─────────────────────────── */
const owned = (id) => state.buildings[id] || 0;
const has = (id) => !!state.upgrades[id];
const totalBuildings = () => BUILDINGS.reduce((s, b) => s + owned(b.id), 0);
function buildingMult(id) {
let m = 1;
for (let i = 1; i <= 3; i++) if (has(`${id}-u${i}`)) m *= 2;
return m;
}
function globalMult() {
let m = 1;
if (has('magnet1')) m += 0.10;
if (has('magnet2')) m += 0.10;
if (has('magnet3')) m += 0.25;
return m * (1 + state.cores * 0.05);
}
function pps() {
const g = globalMult();
return BUILDINGS.reduce((s, b) => s + owned(b.id) * b.baseRate * buildingMult(b.id), 0) * g;
}
function clickPower() {
let base = 1;
for (let i = 1; i <= 5; i++) if (has(`click${i}`)) base *= 2;
let power = base * globalMult();
if (has('siphon-click')) power += pps() * 0.02;
return power;
}
const comboMult = () => 1 + Math.min(combo, 50) * 0.08;
function coresPending() {
return Math.floor(Math.sqrt(state.runDust / 1e6));
}
function nextCoreAt() {
return Math.pow(coresPending() + 1, 2) * 1e6;
}
/* ─────────────────────────── Audio (tiny synth) ─────────────────────────── */
let actx = null;
function beep(freq, dur = 0.08, type = 'square', vol = 0.1, slide = 0, delay = 0) {
if (!state.settings.sound) return;
try {
actx = actx || new (window.AudioContext || window.webkitAudioContext)();
if (actx.state === 'suspended') actx.resume();
const t = actx.currentTime + delay;
const o = actx.createOscillator();
const g = actx.createGain();
o.type = type;
o.frequency.setValueAtTime(freq, t);
if (slide) o.frequency.exponentialRampToValueAtTime(Math.max(20, freq + slide), t + dur);
g.gain.setValueAtTime(vol, t);
g.gain.exponentialRampToValueAtTime(0.001, t + dur);
o.connect(g).connect(actx.destination);
o.start(t);
o.stop(t + dur + 0.02);
} catch (e) { /* no audio */ }
}
const sfx = {
click: () => beep(240 + Math.min(combo, 50) * 8, 0.06, 'triangle', 0.09, 80),
crit: () => { beep(520, 0.09, 'triangle', 0.12, 300); beep(1040, 0.12, 'sine', 0.08, 400, 0.05); },
buy: () => { beep(520, 0.07, 'square', 0.07); beep(780, 0.09, 'square', 0.07, 0, 0.07); },
ach: () => [523, 659, 784, 1046].forEach((f, i) => beep(f, 0.14, 'sine', 0.1, 0, i * 0.09)),
comet: () => beep(880, 0.25, 'sine', 0.12, 900),
nova: () => { beep(70, 0.9, 'sawtooth', 0.22, -40); beep(900, 0.7, 'sine', 0.14, 900, 0.25); },
};
/* ─────────────────────────── Particles & starfield ─────────────────────────── */
const canvas = $('bg');
const ctx = canvas.getContext('2d');
let stars = [];
let parts = [];
function resizeCanvas() {
canvas.width = window.innerWidth * devicePixelRatio;
canvas.height = window.innerHeight * devicePixelRatio;
canvas.style.width = window.innerWidth + 'px';
canvas.style.height = window.innerHeight + 'px';
ctx.setTransform(devicePixelRatio, 0, 0, devicePixelRatio, 0, 0);
stars = Array.from({ length: Math.floor(window.innerWidth / 9) }, () => ({
x: Math.random() * window.innerWidth,
y: Math.random() * window.innerHeight,
r: rand(0.4, 1.6),
p: rand(0, Math.PI * 2),
sp: rand(0.3, 1.2),
drift: rand(0.02, 0.1),
}));
}
window.addEventListener('resize', resizeCanvas);
resizeCanvas();
function burst(x, y, n, colors, speed = 3.2) {
if (!state.settings.particles) return;
for (let i = 0; i < n; i++) {
const a = Math.random() * Math.PI * 2;
const v = rand(0.5, 1) * speed;
parts.push({
x, y,
vx: Math.cos(a) * v, vy: Math.sin(a) * v - 1,
life: rand(0.5, 1.1), age: 0,
r: rand(1.2, 3.2),
c: colors[Math.floor(Math.random() * colors.length)],
});
}
if (parts.length > 900) parts.splice(0, parts.length - 900);
}
function starCenter() {
const r = $('star').getBoundingClientRect();
return { x: r.left + r.width / 2, y: r.top + r.height / 2 };
}
let emberTimer = 0;
function drawFrame(dt) {
const W = window.innerWidth, H = window.innerHeight;
ctx.clearRect(0, 0, W, H);
// starfield
for (const s of stars) {
s.p += dt * s.sp;
s.x -= s.drift * dt * 10;
if (s.x < -4) s.x = W + 4;
const tw = 0.35 + 0.65 * (0.5 + 0.5 * Math.sin(s.p));
ctx.globalAlpha = tw * 0.85;
ctx.fillStyle = '#cdd8ff';
ctx.beginPath();
ctx.arc(s.x, s.y, s.r, 0, Math.PI * 2);
ctx.fill();
}
ctx.globalAlpha = 1;
if (state.settings.particles) {
// ambient embers rising from the star
emberTimer += dt;
if (emberTimer > 0.45) {
emberTimer = 0;
const c = starCenter();
parts.push({
x: c.x + rand(-70, 70), y: c.y + rand(-30, 60),
vx: rand(-0.15, 0.15), vy: rand(-0.7, -0.3),
life: rand(1.4, 2.4), age: 0, r: rand(0.8, 2),
c: Math.random() < 0.7 ? '#ffd76a' : '#6ae6ff',
});
}
}
// particles
for (let i = parts.length - 1; i >= 0; i--) {
const p = parts[i];
p.age += dt;
if (p.age >= p.life) { parts.splice(i, 1); continue; }
p.x += p.vx * dt * 60;
p.y += p.vy * dt * 60;
p.vy += 0.9 * dt;
const k = 1 - p.age / p.life;
ctx.globalAlpha = k;
ctx.fillStyle = p.c;
ctx.shadowColor = p.c;
ctx.shadowBlur = 8;
ctx.beginPath();
ctx.arc(p.x, p.y, p.r * k + 0.3, 0, Math.PI * 2);
ctx.fill();
}
ctx.globalAlpha = 1;
ctx.shadowBlur = 0;
}
/* ─────────────────────────── Floating numbers ─────────────────────────── */
const floatLayer = $('float-layer');
function floatNum(x, y, text, crit = false) {
while (floatLayer.childElementCount > 40) floatLayer.firstChild.remove();
const el = document.createElement('div');
el.className = 'float-num' + (crit ? ' crit' : '');
el.textContent = text;
el.style.left = x + rand(-26, 26) + 'px';
el.style.top = y + rand(-14, 6) + 'px';
el.addEventListener('animationend', () => el.remove());
floatLayer.appendChild(el);
}
/* ─────────────────────────── Toasts & modals ─────────────────────────── */
function toast(icon, title, msg) {
const stack = $('toast-stack');
while (stack.childElementCount >= 4) stack.firstChild.remove();
const el = document.createElement('div');
el.className = 'toast';
el.innerHTML = `<div class="toast-icon">${icon}</div>
<div><div class="toast-title"></div><div class="toast-msg"></div></div>`;
el.querySelector('.toast-title').textContent = title;
el.querySelector('.toast-msg').textContent = msg;
stack.appendChild(el);
setTimeout(() => el.remove(), 4800);
}
function modal({ icon, title, body, buttons }) {
const root = $('modal-root');
root.innerHTML = '';
const m = document.createElement('div');
m.className = 'modal';
m.innerHTML = `<div class="modal-icon">${icon}</div><h3></h3><p></p><div class="modal-btns"></div>`;
m.querySelector('h3').textContent = title;
m.querySelector('p').innerHTML = body;
const btnWrap = m.querySelector('.modal-btns');
buttons.forEach((b) => {
const btn = document.createElement('button');
btn.textContent = b.label;
if (b.className) btn.className = b.className;
btn.onclick = () => { root.innerHTML = ''; b.onClick && b.onClick(); };
btnWrap.appendChild(btn);
});
root.appendChild(m);
}
function flash() {
const f = $('flash');
f.classList.remove('go');
void f.offsetWidth;
f.classList.add('go');
}
/* ─────────────────────────── Buying ─────────────────────────── */
function bulkCost(b, k) {
return b.baseCost * Math.pow(COST_GROWTH, owned(b.id)) * (Math.pow(COST_GROWTH, k) - 1) / (COST_GROWTH - 1);
}
function maxAffordable(b) {
const priceBase = b.baseCost * Math.pow(COST_GROWTH, owned(b.id));
if (state.dust < priceBase) return 0;
return Math.floor(Math.log(1 + state.dust * (COST_GROWTH - 1) / priceBase) / Math.log(COST_GROWTH));
}
function qtyCount(b) {
if (qty === 'max') return Math.max(1, maxAffordable(b));
return parseInt(qty, 10);
}
function buyBuilding(b) {
const k = qtyCount(b);
if (k <= 0) return;
const cost = bulkCost(b, k);
if (state.dust < cost) { beep(160, 0.1, 'square', 0.06, -60); return; }
state.dust -= cost;
state.buildings[b.id] = owned(b.id) + k;
sfx.buy();
const r = genEls[b.id].card.getBoundingClientRect();
burst(r.left + 30, r.top + r.height / 2, 10, ['#6ae6ff', '#9a6aff', '#ffffff'], 2.2);
checkAchievements();
}
function buyUpgrade(u) {
if (has(u.id) || state.dust < u.cost) { if (!has(u.id)) beep(160, 0.1, 'square', 0.06, -60); return; }
state.dust -= u.cost;
state.upgrades[u.id] = 1;
sfx.buy();
toast(u.icon, 'Upgrade installed', `${u.name} — ${u.desc}`);
buildUpgrades();
checkAchievements();
}
/* ─────────────────────────── Generators UI ─────────────────────────── */
const genEls = {};
function buildGenerators() {
const list = $('generator-list');
list.innerHTML = '';
BUILDINGS.forEach((b, idx) => {
const card = document.createElement('div');
card.className = 'card';
card.innerHTML = `
<div class="card-icon">${b.icon}</div>
<div class="card-body">
<div class="card-name"><span class="g-name">${b.name}</span><span class="card-owned"></span></div>
<div class="card-desc">${b.desc}</div>
<div class="card-meta"><span class="card-cost"></span><span class="card-rate"></span></div>
</div>`;
card.addEventListener('click', () => buyBuilding(b));
list.appendChild(card);
genEls[b.id] = {
card, idx,
icon: card.querySelector('.card-icon'),
name: card.querySelector('.g-name'),
owned: card.querySelector('.card-owned'),
desc: card.querySelector('.card-desc'),
cost: card.querySelector('.card-cost'),
rate: card.querySelector('.card-rate'),
};
});
}
function buildingVisible(b, idx) {
return owned(b.id) > 0 || idx === 0 || state.runDust >= b.baseCost * 0.4 || (idx > 0 && owned(BUILDINGS[idx - 1].id) > 0);
}
function renderGenerators() {
for (const b of BUILDINGS) {
const el = genEls[b.id];
const idx = el.idx;
const vis = buildingVisible(b, idx);
el.card.classList.toggle('hidden', !vis && state.runDust < b.baseCost * 0.15 && idx !== 0 && owned(BUILDINGS[idx - 1].id) === 0);
const mystery = !vis;
el.card.classList.toggle('mystery', mystery);
el.name.textContent = mystery ? '???' : b.name;
el.desc.textContent = mystery ? 'Something stirs in the dark…' : b.desc;
el.icon.textContent = mystery ? '❓' : b.icon;
const k = qtyCount(b);
const cost = bulkCost(b, k);
const afford = state.dust >= cost && k > 0;
el.card.classList.toggle('affordable', afford && !mystery);
el.owned.textContent = owned(b.id) > 0 ? owned(b.id) : '';
el.cost.textContent = (qty === 'max' && k > 1 ? `×${k} ` : '') + `✨${fmt(cost)}`;
el.cost.classList.toggle('cant', !afford);
const each = b.baseRate * buildingMult(b.id) * globalMult();
el.rate.textContent = mystery ? '' : (owned(b.id) > 0
? `each ${fmt(each)}/s · total ${fmt(each * owned(b.id))}/s`
: `each ${fmt(each)}/s`);
}
}
/* ─────────────────────────── Upgrades UI ─────────────────────────── */
function buildUpgrades() {
const list = $('upgrade-list');
list.innerHTML = '';
const avail = UPGRADES.filter((u) => !has(u.id) && u.visible()).sort((a, b) => a.cost - b.cost);
const ownedUps = UPGRADES.filter((u) => has(u.id));
if (avail.length === 0 && ownedUps.length === 0) {
list.innerHTML = '<p style="color:var(--text-dim);font-size:0.85rem;text-align:center;padding:30px 10px;">No upgrades revealed yet.<br>Keep forging — the cosmos will provide.</p>';
}
for (const u of avail) {
const card = document.createElement('div');
card.className = 'card' + (state.dust >= u.cost ? ' affordable' : '');
card.innerHTML = `
<div class="card-icon">${u.icon}</div>
<div class="card-body">
<div class="card-name"><span>${u.name}</span></div>
<div class="card-desc">${u.desc}</div>
<div class="card-meta"><span class="card-cost ${state.dust < u.cost ? 'cant' : ''}">✨${fmt(u.cost)}</span></div>
</div>`;
card.addEventListener('click', () => buyUpgrade(u));
list.appendChild(card);
}
for (const u of ownedUps) {
const card = document.createElement('div');
card.className = 'card owned-upgrade';
card.innerHTML = `
<div class="card-icon">${u.icon}</div>
<div class="card-body">
<div class="card-name"><span>${u.name}</span></div>
<div class="card-desc">${u.desc}</div>
<div class="card-meta"><span class="card-cost">✓ OWNED</span></div>
</div>`;
list.appendChild(card);
}
}
function upgradeBadge() {
const n = UPGRADES.filter((u) => !has(u.id) && u.visible() && state.dust >= u.cost).length;
const badge = $('upg-badge');
badge.classList.toggle('hidden', n === 0);
badge.textContent = n;
}
/* ─────────────────────────── Prestige UI ─────────────────────────── */
function renderPrestige() {
$('prestige-cores').textContent = fmt(state.cores);
$('prestige-bonus').textContent = `+${state.cores * 5}%`;
const pending = coresPending();
$('prestige-pending').textContent = `+${pending}`;
const prev = Math.pow(coresPending(), 2) * 1e6;
const next = nextCoreAt();
const prog = pending > 0 || state.runDust > 0 ? (state.runDust - prev) / (next - prev) : 0;
$('prestige-progress').style.width = clamp(prog * 100, 0, 100) + '%';
$('prestige-next').textContent = `Next core at ${fmt(next)} stardust forged this run`;
$('btn-prestige').disabled = pending < 1;
$('chip-cores').classList.toggle('hidden', state.cores === 0 && pending === 0);
$('core-count').textContent = fmt(state.cores);
$('prestige-history').innerHTML = state.prestiges > 0
? `Supernovas so far: <b>${state.prestiges}</b> · Lifetime stardust: <b>${fmt(state.allTime)}</b>`
: 'Reach 1.00M stardust this run to earn your first core.';
}
function doPrestige() {
const pending = coresPending();
if (pending < 1) return;
modal({
icon: '💫',
title: 'GO SUPERNOVA?',
body: `This will reset your stardust, generators and upgrades.<br><br>You will gain <b>+${pending} Stellar Core${pending > 1 ? 's' : ''}</b> (permanent +${pending * 5}% production).`,
buttons: [
{ label: 'Cancel' },
{
label: '💫 Detonate', className: 'primary',
onClick: () => {
state.cores += pending;
state.prestiges += 1;
state.dust = 0; state.runDust = 0;
state.buildings = {}; state.upgrades = {};
state.runStart = Date.now();
combo = 0;
flash();
document.body.classList.add('shake');
setTimeout(() => document.body.classList.remove('shake'), 550);
sfx.nova();
const c = starCenter();
burst(c.x, c.y, 120, ['#ffffff', '#ffd76a', '#9a6aff', '#6ae6ff'], 7);
toast('💫', 'SUPERNOVA!', `+${pending} Stellar Cores. The forge begins anew, stronger.`);
buildUpgrades();
save();
checkAchievements();
},
},
],
});
}
/* ─────────────────────────── Stats & achievements UI ─────────────────────────── */
function renderStats() {
const rows = [
['Stardust in bank', fmt(state.dust)],
['Forged this run', fmt(state.runDust)],
['Forged all time', fmt(state.allTime)],
['Production', fmt(pps()) + '/s'],
['Click power', fmt(clickPower())],
['Clicks', fmt(state.clicks)],
['Generators owned', fmt(totalBuildings())],
['Upgrades owned', Object.keys(state.upgrades).length + ' / ' + UPGRADES.length],
['Stellar cores', fmt(state.cores)],
['Supernovas', state.prestiges],
['Comets caught', state.comets],
['Best combo', '×' + (1 + Math.min(state.bestCombo, 50) * 0.08).toFixed(1)],
['Time this run', fmtTime((Date.now() - state.runStart) / 1000)],
['Total time played', fmtTime((Date.now() - state.totalStart) / 1000)],
];
$('stats-list').innerHTML = rows.map(([k, v]) => `<div class="stat-row"><span>${k}</span><b>${v}</b></div>`).join('');
const got = ACHIEVEMENTS.filter((a) => state.ach[a.id]).length;
$('ach-count').textContent = `${got} / ${ACHIEVEMENTS.length}`;
const grid = $('ach-grid');
grid.innerHTML = '';
for (const a of ACHIEVEMENTS) {
const el = document.createElement('div');
const un = !!state.ach[a.id];
el.className = 'ach' + (un ? ' unlocked' : '');
el.textContent = un ? a.icon : '🔒';
el.title = `${a.name} — ${a.desc}${un ? '' : ' (locked)'}`;
grid.appendChild(el);
}
}
function checkAchievements() {
for (const a of ACHIEVEMENTS) {
if (!state.ach[a.id] && a.check()) {
state.ach[a.id] = 1;
toast(a.icon, `Achievement: ${a.name}`, a.desc);
sfx.ach();
}
}
}
/* ─────────────────────────── Comet event ─────────────────────────── */
const cometEl = $('comet');
const comet = { active: false, t: 0, dur: 9, sx: 0, sy: 0, ex: 0, ey: 0, nextAt: performance.now() / 1000 + 60 };
function cometInterval() {
return rand(90, 200) * (has('comet-up') ? 0.6 : 1);
}
function updateComet(now, dt) {
if (!comet.active) {
if (now >= comet.nextAt && !document.hidden) {
const stage = $('stage').getBoundingClientRect();
comet.active = true;
comet.t = 0;
comet.sx = -70;
comet.sy = rand(stage.height * 0.08, stage.height * 0.4);
comet.ex = stage.width + 70;
comet.ey = rand(stage.height * 0.5, stage.height * 0.9);
cometEl.classList.remove('hidden');
}
return;
}
comet.t += dt;
const p = comet.t / comet.dur;
if (p >= 1) {
comet.active = false;
cometEl.classList.add('hidden');
comet.nextAt = now + cometInterval();
return;
}
const x = comet.sx + (comet.ex - comet.sx) * p;
const y = comet.sy + (comet.ey - comet.sy) * p - Math.sin(p * Math.PI) * 60;
cometEl.style.left = x + 'px';
cometEl.style.top = y + 'px';
// sparkle trail
if (state.settings.particles && Math.random() < 0.5) {
const stage = $('stage').getBoundingClientRect();
parts.push({
x: stage.left + x + rand(-8, 8), y: stage.top + y + rand(-8, 8),
vx: rand(-0.4, 0.4), vy: rand(-0.2, 0.6),
life: rand(0.4, 0.9), age: 0, r: rand(1, 2.4),
c: Math.random() < 0.5 ? '#ffd76a' : '#ffffff',
});
}
}
cometEl.addEventListener('pointerdown', (e) => {
if (!comet.active) return;
comet.active = false;
cometEl.classList.add('hidden');
comet.nextAt = performance.now() / 1000 + cometInterval();
let reward = Math.max(pps() * 600, state.dust * 0.1, 25);
if (has('comet-up')) reward *= 1.5;
gainDust(reward);
state.comets += 1;
sfx.comet();
flash();
burst(e.clientX, e.clientY, 60, ['#ffd76a', '#ffffff', '#6ae6ff'], 5);
toast('☄️', 'Comet caught!', `+${fmt(reward)} stardust from the golden wanderer.`);
checkAchievements();
});
/* ─────────────────────────── Core actions ─────────────────────────── */
function gainDust(n) {
state.dust += n;
state.runDust += n;
state.allTime += n;
}
function starClick(clientX, clientY) {
const now = performance.now() / 1000;
if (now - lastClickTime <= 1.2) combo++;
else combo = 0;
lastClickTime = now;
state.bestCombo = Math.max(state.bestCombo, Math.min(combo, 50));
const isCrit = Math.random() < 0.07;
const gain = clickPower() * comboMult() * (isCrit ? 7 : 1);
gainDust(gain);
state.clicks++;
// visuals
const star = $('star');
star.classList.remove('pulse');
void star.offsetWidth;
star.classList.add('pulse');
const stage = $('stage').getBoundingClientRect();
floatNum(clientX - stage.left, clientY - stage.top, `+${fmt(gain)}${isCrit ? ' CRIT' : ''}`, isCrit);
burst(clientX, clientY, isCrit ? 34 : 12, isCrit ? ['#ffffff', '#ffd76a', '#fff3c4'] : ['#ffd76a', '#ff9d3c', '#fff3c4'], isCrit ? 5 : 3);
isCrit ? sfx.crit() : sfx.click();
if (combo === 50) checkAchievements();
}
$('star').addEventListener('pointerdown', (e) => {
e.preventDefault();
starClick(e.clientX, e.clientY);
});
window.addEventListener('keydown', (e) => {
if (e.code === 'Space' && !e.repeat) {
e.preventDefault();
const c = starCenter();
starClick(c.x + rand(-30, 30), c.y + rand(-30, 30));
}
});
/* ─────────────────────────── Tabs & top bar ─────────────────────────── */
document.querySelectorAll('.tab').forEach((tab) => {
tab.addEventListener('click', () => {
document.querySelectorAll('.tab').forEach((t) => t.classList.toggle('active', t === tab));
document.querySelectorAll('.tab-page').forEach((p) => p.classList.toggle('active', p.id === 'tab-' + tab.dataset.tab));
if (tab.dataset.tab === 'upgrades') buildUpgrades();
if (tab.dataset.tab === 'stats') renderStats();
if (tab.dataset.tab === 'prestige') renderPrestige();
});
});
document.querySelectorAll('.qty').forEach((btn) => {
btn.addEventListener('click', () => {
qty = btn.dataset.qty;
document.querySelectorAll('.qty').forEach((b) => b.classList.toggle('active', b === btn));
renderGenerators();
});
});
$('btn-prestige').addEventListener('click', doPrestige);
$('btn-sound').addEventListener('click', () => {
state.settings.sound = !state.settings.sound;
$('btn-sound').textContent = state.settings.sound ? '🔊' : '🔇';
$('btn-sound').classList.toggle('off', !state.settings.sound);
save();
});
$('btn-particles').addEventListener('click', () => {
state.settings.particles = !state.settings.particles;
$('btn-particles').classList.toggle('off', !state.settings.particles);
if (!state.settings.particles) parts.length = 0;
save();
});
$('btn-save').addEventListener('click', () => {
save();
const btn = $('btn-save');
btn.textContent = '✅';
setTimeout(() => { btn.textContent = '💾'; }, 900);
});
$('btn-wipe').addEventListener('click', () => {
modal({
icon: '🗑️',
title: 'WIPE SAVE?',
body: 'This permanently deletes <b>everything</b> — stardust, generators, cores, achievements. There is no undo.',
buttons: [
{ label: 'Keep my forge' },
{
label: 'Wipe it all', className: 'danger',
onClick: () => { localStorage.removeItem(SAVE_KEY); location.reload(); },
},
],
});
});
/* ─────────────────────────── Loops ─────────────────────────── */
let uiTimer = 0, achTimer = 0, statTimer = 0;
function tick() {
const now = performance.now();
const dt = Math.min((now - lastTick) / 1000, 2); // cap dt to avoid spikes
lastTick = now;
const nowS = now / 1000;
// production
const gain = pps() * dt;
if (gain > 0) gainDust(gain);
// combo decay
if (combo > 0 && nowS - lastClickTime > 1.2) combo = 0;
updateComet(nowS, dt);
// throttled UI updates
uiTimer += dt;
if (uiTimer >= 0.1) {
uiTimer = 0;
renderHeader();
renderGenerators();
renderPrestige();
upgradeBadge();
renderCombo();
}
achTimer += dt;
if (achTimer >= 0.5) {
achTimer = 0;
checkAchievements();
}
statTimer += dt;
if (statTimer >= 0.5) {
statTimer = 0;
if ($('tab-stats').classList.contains('active')) renderStats();
}
}
function renderHeader() {
$('dust-count').textContent = fmt(state.dust);
$('pps-count').textContent = fmt(pps());
const cp = clickPower();
$('click-power').innerHTML = `+<b>${fmt(cp)}</b> per click`;
}
function renderCombo() {
const wrap = $('combo-wrap');
if (combo <= 1) { wrap.classList.add('hidden'); return; }
wrap.classList.remove('hidden');
$('combo-bar').style.width = (Math.min(combo, 50) / 50) * 100 + '%';
$('combo-label').textContent = `COMBO ×${comboMult().toFixed(1)}`;
}
let lastFrame = performance.now();
function frame(now) {
const dt = Math.min((now - lastFrame) / 1000, 0.1);
lastFrame = now;
drawFrame(dt);
requestAnimationFrame(frame);
}
/* ─────────────────────────── Boot ─────────────────────────── */
function boot() {
const hadSave = load();
buildGenerators();
buildUpgrades();
renderHeader();
renderPrestige();
renderStats();
$('btn-sound').textContent = state.settings.sound ? '🔊' : '🔇';
$('btn-sound').classList.toggle('off', !state.settings.sound);
$('btn-particles').classList.toggle('off', !state.settings.particles);
// offline earnings
if (hadSave) {
const elapsed = (Date.now() - state.lastSeen) / 1000;
const rate = pps();
if (elapsed > 60 && rate > 0) {
const capped = Math.min(elapsed, 8 * 3600);
const gain = rate * capped * 0.5;
gainDust(gain);
modal({
icon: '🌙',
title: 'WELCOME BACK',
body: `You were away for <b>${fmtTime(elapsed)}</b>.<br>Your generators kept humming at 50% efficiency:<br><br><b style="font-size:1.3rem;">+${fmt(gain)} ✨</b>`,
buttons: [{ label: 'Collect', className: 'primary' }],
});
}
}
lastTick = performance.now();
setInterval(tick, 50);
requestAnimationFrame((t) => { lastFrame = t; frame(t); });
saveTimer = setInterval(save, 15000);
window.addEventListener('beforeunload', save);
document.addEventListener('visibilitychange', () => { if (document.hidden) save(); });
}
boot();