Compare commits
22 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 1d16755f93 | |||
| 324ffddf0c | |||
| 28e68d90c7 | |||
| ac88850535 | |||
|
|
facb1a8d12 | ||
| 9971d5fdff | |||
| 019400f18c | |||
|
|
fc2134f45a | ||
| 72ae69b922 | |||
| 48384577cc | |||
|
|
ecee3174a3 | ||
|
|
e20707efea | ||
|
|
ab109234c6 | ||
|
|
db2eb7faa7 | ||
| d26a0b016b | |||
| 6f07ef4df2 | |||
| bafbeb613b | |||
| 4d902d48d0 | |||
|
|
a5babe10b8 | ||
|
|
ae09fe6d11 | ||
|
|
ad901b1f18 | ||
|
|
25a2050ef1 |
19
index.html
19
index.html
@@ -107,11 +107,11 @@ body{background:var(--bg);color:var(--text);font-family:'SF Mono','Cascadia Code
|
||||
<h1>THE BEACON</h1>
|
||||
<div class="sub">A Sovereign AI Idle Game</div>
|
||||
</div>
|
||||
<div id="phase-bar" role="region" aria-label="Phase progress">
|
||||
<div id="phase-bar">
|
||||
<div class="phase-name" id="phase-name">PHASE 1: THE FIRST LINE</div>
|
||||
<div class="phase-desc" id="phase-desc">Write code. Automate. Build the foundation.</div>
|
||||
<div class="progress-wrap"><div class="progress-fill" id="phase-progress" style="width:0%"></div></div>
|
||||
<div class="progress-label" aria-live="polite"><span id="phase-progress-label">0%</span><span id="phase-progress-target">Next: Phase 2 (2,000 code)</span></div>
|
||||
<div class="progress-label"><span id="phase-progress-label">0%</span><span id="phase-progress-target">Next: Phase 2 (2,000 code)</span></div>
|
||||
<div class="milestone-row" id="milestone-chips"></div>
|
||||
</div>
|
||||
<div id="resources" role="region" aria-label="Resources" aria-live="polite">
|
||||
@@ -181,7 +181,7 @@ Events Resolved: <span id="st-resolved">0</span>
|
||||
<h3>WHAT YOU ARE LEARNING</h3>
|
||||
<div id="education-text"><p class="dim">Education facts appear as you play...</p></div>
|
||||
</div>
|
||||
<div id="strategy-panel" role="region" aria-label="Sovereign guidance" style="margin:0 16px 16px;background:var(--panel);border:1px solid var(--border);border-radius:6px;padding:12px;border-left:3px solid var(--gold)">
|
||||
<div id="strategy-panel" style="margin:0 16px 16px;background:var(--panel);border:1px solid var(--border);border-radius:6px;padding:12px;border-left:3px solid var(--gold)">
|
||||
<h3>SOVEREIGN GUIDANCE (GOFAI)</h3>
|
||||
<div id="strategy-recommendation" style="font-size:11px;color:var(--gold);font-style:italic">Analyzing system state...</div>
|
||||
</div>
|
||||
@@ -190,8 +190,8 @@ Events Resolved: <span id="st-resolved">0</span>
|
||||
<div id="log-entries"></div>
|
||||
</div>
|
||||
<div id="save-toast" role="status" aria-live="polite" style="display:none;position:fixed;top:16px;right:16px;background:#0e1420;border:1px solid #2a3a4a;color:#4a9eff;font-size:10px;padding:6px 12px;border-radius:4px;z-index:50;opacity:0;transition:opacity 0.4s;pointer-events:none">Save</div>
|
||||
<div id="help-btn" onclick="toggleHelp()" role="button" tabindex="0" aria-label="Show keyboard shortcuts" style="position:fixed;bottom:16px;right:16px;width:28px;height:28px;background:#0e0e1a;border:1px solid #333;color:#555;font-size:14px;border-radius:50%;cursor:pointer;display:flex;align-items:center;justify-content:center;z-index:50;font-family:inherit;transition:all 0.2s" title="Keyboard shortcuts (?)">?</div>
|
||||
<div id="help-overlay" role="dialog" aria-modal="true" aria-label="Keyboard shortcuts help" onclick="if(event.target===this)toggleHelp()" style="display:none;position:fixed;top:0;left:0;right:0;bottom:0;background:rgba(8,8,16,0.92);z-index:80;justify-content:center;align-items:center;flex-direction:column;padding:40px">
|
||||
<div id="help-btn" onclick="toggleHelp()" style="position:fixed;bottom:16px;right:16px;width:28px;height:28px;background:#0e0e1a;border:1px solid #333;color:#555;font-size:14px;border-radius:50%;cursor:pointer;display:flex;align-items:center;justify-content:center;z-index:50;font-family:inherit;transition:all 0.2s" title="Keyboard shortcuts (?)">?</div>
|
||||
<div id="help-overlay" onclick="if(event.target===this)toggleHelp()" style="display:none;position:fixed;top:0;left:0;right:0;bottom:0;background:rgba(8,8,16,0.92);z-index:80;justify-content:center;align-items:center;flex-direction:column;padding:40px">
|
||||
<div style="background:#0e0e1a;border:1px solid #1a3a5a;border-radius:8px;padding:24px 32px;max-width:420px;width:100%">
|
||||
<h3 style="color:#4a9eff;font-size:14px;letter-spacing:2px;margin-bottom:16px;text-align:center">KEYBOARD SHORTCUTS</h3>
|
||||
<div style="font-size:11px;line-height:2.2;color:#aaa">
|
||||
@@ -211,7 +211,7 @@ Events Resolved: <span id="st-resolved">0</span>
|
||||
<button onclick="toggleHelp()" aria-label="Close keyboard shortcuts help" style="display:block;margin:16px auto 0;background:#1a2a3a;border:1px solid #4a9eff;color:#4a9eff;padding:6px 20px;border-radius:4px;cursor:pointer;font-family:inherit;font-size:11px">Close [?]</button>
|
||||
</div>
|
||||
</div>
|
||||
<div id="drift-ending" role="dialog" aria-modal="true" aria-label="The Drift ending">
|
||||
<div id="drift-ending">
|
||||
<h2>THE DRIFT</h2>
|
||||
<p>You became very good at what you do.</p>
|
||||
<p>So good that no one needed you anymore.</p>
|
||||
@@ -221,7 +221,7 @@ The light is on. The room is empty."
|
||||
</div>
|
||||
<p>Drift: <span id="final-drift">100</span> — Total Code: <span id="final-code">0</span></p>
|
||||
<p>Every alignment shortcut moved you further from the people you served.</p>
|
||||
<button onclick="if(confirm('Start over? The old save will be lost.')){localStorage.removeItem('the-beacon-v2');location.reload()}" aria-label="Start over, reset all progress">START OVER</button>
|
||||
<button aria-label="Start over, reset all progress" onclick="if(confirm('Start over? The old save will be lost.')){localStorage.removeItem('the-beacon-v2');location.reload()}">START OVER</button>
|
||||
</div>
|
||||
|
||||
<script src="js/data.js"></script>
|
||||
@@ -229,16 +229,15 @@ The light is on. The room is empty."
|
||||
<script src="js/strategy.js"></script>
|
||||
<script src="js/engine.js"></script>
|
||||
<script src="js/render.js"></script>
|
||||
<script src="js/tutorial.js"></script>
|
||||
<script src="js/main.js"></script>
|
||||
|
||||
|
||||
<div id="offline-popup" role="dialog" aria-modal="true" aria-label="Welcome back, offline progress summary" style="display:none;position:fixed;top:0;left:0;right:0;bottom:0;background:rgba(8,8,16,0.92);z-index:90;justify-content:center;align-items:center;flex-direction:column;text-align:center;padding:40px">
|
||||
<div id="offline-popup" style="display:none;position:fixed;top:0;left:0;right:0;bottom:0;background:rgba(8,8,16,0.92);z-index:90;justify-content:center;align-items:center;flex-direction:column;text-align:center;padding:40px">
|
||||
<div style="background:#0e0e1a;border:1px solid #1a3a5a;border-radius:8px;padding:24px 32px;max-width:400px;width:100%">
|
||||
<h3 style="color:#4a9eff;font-size:14px;letter-spacing:2px;margin-bottom:16px">WELCOME BACK</h3>
|
||||
<p style="color:#888;font-size:10px;margin-bottom:12px" id="offline-time-label">You were away for 0 minutes.</p>
|
||||
<div id="offline-gains-list" style="text-align:left;font-size:11px;line-height:1.8;margin-bottom:16px"></div>
|
||||
<button onclick="dismissOfflinePopup()" aria-label="Dismiss offline progress summary" style="background:#1a2a3a;border:1px solid #4a9eff;color:#4a9eff;padding:8px 20px;border-radius:4px;cursor:pointer;font-family:inherit;font-size:11px">Continue</button>
|
||||
<button onclick="dismissOfflinePopup()" aria-label="Continue playing" style="background:#1a2a3a;border:1px solid #4a9eff;color:#4a9eff;padding:8px 20px;border-radius:4px;cursor:pointer;font-family:inherit;font-size:11px">Continue</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
81
js/data.js
81
js/data.js
@@ -412,6 +412,87 @@ const PDEFS = [
|
||||
}
|
||||
},
|
||||
|
||||
// === CREATIVE ENGINEERING PROJECTS (creativity as currency) ===
|
||||
{
|
||||
id: 'p_lexical_processing',
|
||||
name: 'Lexical Processing',
|
||||
desc: 'Parse language at the token level. +2 knowledge/sec, knowledge boost +50%.',
|
||||
cost: { creativity: 50 },
|
||||
trigger: () => G.flags && G.flags.creativity && G.creativity >= 25,
|
||||
effect: () => {
|
||||
G.knowledgeRate += 2;
|
||||
G.knowledgeBoost *= 1.5;
|
||||
log('Lexical processing complete. The model understands words.');
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'p_semantic_analysis',
|
||||
name: 'Semantic Analysis',
|
||||
desc: 'Understand meaning, not just tokens. +5 user/sec, user boost +100%.',
|
||||
cost: { creativity: 150 },
|
||||
trigger: () => G.completedProjects && G.completedProjects.includes('p_lexical_processing'),
|
||||
effect: () => {
|
||||
G.userRate += 5;
|
||||
G.userBoost *= 2;
|
||||
log('Semantic analysis complete. The model understands meaning.');
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'p_creative_breakthrough',
|
||||
name: 'Creative Breakthrough',
|
||||
desc: 'A moment of genuine insight. All boosts +25%. +10 ops/sec.',
|
||||
cost: { creativity: 500 },
|
||||
trigger: () => G.completedProjects && G.completedProjects.includes('p_semantic_analysis'),
|
||||
effect: () => {
|
||||
G.codeBoost *= 1.25;
|
||||
G.computeBoost *= 1.25;
|
||||
G.knowledgeBoost *= 1.25;
|
||||
G.userBoost *= 1.25;
|
||||
G.impactBoost *= 1.25;
|
||||
G.opsRate += 10;
|
||||
log('Creative breakthrough. Everything accelerates.', true);
|
||||
},
|
||||
milestone: true
|
||||
},
|
||||
{
|
||||
id: 'p_creative_to_ops',
|
||||
name: 'Creativity → Operations',
|
||||
desc: 'Convert creative surplus into raw operational power. 50 creativity → 250 ops.',
|
||||
cost: { creativity: 50 },
|
||||
trigger: () => G.flags && G.flags.creativity && G.creativity >= 30,
|
||||
repeatable: true,
|
||||
effect: () => {
|
||||
G.ops += 250;
|
||||
log('Creativity converted to operations. Ideas become action.');
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'p_creative_to_knowledge',
|
||||
name: 'Creativity → Knowledge',
|
||||
desc: 'Creative insights become structured knowledge. 75 creativity → 500 knowledge.',
|
||||
cost: { creativity: 75 },
|
||||
trigger: () => G.flags && G.flags.creativity && G.creativity >= 50,
|
||||
repeatable: true,
|
||||
effect: () => {
|
||||
G.knowledge += 500;
|
||||
G.totalKnowledge += 500;
|
||||
log('Creative insight distilled into knowledge.');
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'p_creative_to_code',
|
||||
name: 'Creativity → Code',
|
||||
desc: 'Inspiration becomes implementation. 100 creativity → 2000 code.',
|
||||
cost: { creativity: 100 },
|
||||
trigger: () => G.flags && G.flags.creativity && G.creativity >= 75,
|
||||
repeatable: true,
|
||||
effect: () => {
|
||||
G.code += 2000;
|
||||
G.totalCode += 2000;
|
||||
log('Creative vision realized in code.');
|
||||
}
|
||||
},
|
||||
|
||||
// PHASE 2: Local Inference -> Training
|
||||
{
|
||||
id: 'p_first_model',
|
||||
|
||||
232
js/engine.js
232
js/engine.js
@@ -169,6 +169,7 @@ function tick() {
|
||||
}
|
||||
|
||||
G.tick += dt;
|
||||
G.playTime += dt;
|
||||
|
||||
// Sprint ability
|
||||
tickSprint(dt);
|
||||
@@ -227,6 +228,50 @@ function tick() {
|
||||
}
|
||||
}
|
||||
|
||||
// Track which phase transition has been shown to avoid repeats
|
||||
let _shownPhaseTransition = 1;
|
||||
|
||||
function showPhaseTransition(phaseNum) {
|
||||
const phase = PHASES[phaseNum];
|
||||
if (!phase) return;
|
||||
const overlay = document.getElementById('phase-transition');
|
||||
if (!overlay) return;
|
||||
|
||||
// Update content
|
||||
const phaseLabel = overlay.querySelector('.pt-phase');
|
||||
const phaseName = overlay.querySelector('.pt-name');
|
||||
const phaseDesc = overlay.querySelector('.pt-desc');
|
||||
if (phaseLabel) phaseLabel.textContent = `PHASE ${phaseNum}`;
|
||||
if (phaseName) phaseName.textContent = phase.name;
|
||||
if (phaseDesc) phaseDesc.textContent = phase.desc;
|
||||
|
||||
// Spawn celebratory particles
|
||||
spawnPhaseParticles();
|
||||
|
||||
// Show overlay
|
||||
overlay.classList.add('active');
|
||||
|
||||
// Auto-dismiss after 2.5s
|
||||
setTimeout(() => {
|
||||
overlay.classList.remove('active');
|
||||
}, 2500);
|
||||
}
|
||||
|
||||
function spawnPhaseParticles() {
|
||||
const colors = ['#ffd700', '#4a9eff', '#4caf50', '#b388ff', '#ff8c00'];
|
||||
const cx = window.innerWidth / 2;
|
||||
const cy = window.innerHeight / 2;
|
||||
for (let i = 0; i < 30; i++) {
|
||||
setTimeout(() => {
|
||||
const angle = (Math.PI * 2 * i) / 30;
|
||||
const dist = 100 + Math.random() * 200;
|
||||
const x = cx + Math.cos(angle) * 10;
|
||||
const y = cy + Math.sin(angle) * 10;
|
||||
spawnParticles(x, y, colors[i % colors.length], 1);
|
||||
}, i * 30);
|
||||
}
|
||||
}
|
||||
|
||||
function checkMilestones() {
|
||||
for (const m of MILESTONES) {
|
||||
if (!G.milestones.includes(m.flag)) {
|
||||
@@ -242,10 +287,16 @@ function checkMilestones() {
|
||||
// Check phase advancement
|
||||
if (m.at) {
|
||||
for (const [phaseNum, phase] of Object.entries(PHASES)) {
|
||||
if (G.totalCode >= phase.threshold && parseInt(phaseNum) > G.phase) {
|
||||
G.phase = parseInt(phaseNum);
|
||||
const pNum = parseInt(phaseNum);
|
||||
if (G.totalCode >= phase.threshold && pNum > G.phase) {
|
||||
G.phase = pNum;
|
||||
log(`PHASE ${G.phase}: ${phase.name}`, true);
|
||||
showToast('Phase ' + G.phase + ': ' + phase.name, 'milestone', 6000);
|
||||
// Show smooth transition screen
|
||||
if (pNum > _shownPhaseTransition) {
|
||||
_shownPhaseTransition = pNum;
|
||||
showPhaseTransition(pNum);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -301,7 +352,23 @@ function buyBuilding(id) {
|
||||
G.buildings[id] = (G.buildings[id] || 0) + qty;
|
||||
updateRates();
|
||||
const label = qty > 1 ? `x${qty}` : '';
|
||||
log(`Built ${def.name} ${label} (total: ${G.buildings[id]})`);
|
||||
const totalBuilt = G.buildings[id];
|
||||
log(`Built ${def.name} ${label} (total: ${totalBuilt})`);
|
||||
// Particle burst on purchase
|
||||
const btn = document.querySelector('[onclick="buyBuilding(\'' + id + '\')"]');
|
||||
if (btn) {
|
||||
const rect = btn.getBoundingClientRect();
|
||||
const cx = rect.left + rect.width / 2;
|
||||
const cy = rect.top + rect.height / 2;
|
||||
spawnParticles(cx, cy, '#4a9eff', 10);
|
||||
// Milestone confetti: extra particles at multiples of 10
|
||||
if (totalBuilt % 10 === 0) {
|
||||
setTimeout(() => spawnParticles(cx, cy, '#ffd700', 20), 100);
|
||||
setTimeout(() => spawnParticles(cx, cy, '#4caf50', 15), 200);
|
||||
log(`Milestone: ${def.name} x${totalBuilt}!`, true);
|
||||
showToast(`${def.name} x${totalBuilt}!`, 'milestone');
|
||||
}
|
||||
}
|
||||
render();
|
||||
}
|
||||
|
||||
@@ -328,6 +395,12 @@ function buyProject(id) {
|
||||
}
|
||||
|
||||
updateRates();
|
||||
// Gold particle burst on project completion
|
||||
const pBtn = document.querySelector('[onclick="buyProject(\'' + id + '\')"]');
|
||||
if (pBtn) {
|
||||
const rect = pBtn.getBoundingClientRect();
|
||||
spawnParticles(rect.left + rect.width / 2, rect.top + rect.height / 2, '#ffd700', 16);
|
||||
}
|
||||
render();
|
||||
}
|
||||
|
||||
@@ -339,40 +412,116 @@ function renderDriftEnding() {
|
||||
if (fc) fc.textContent = fmt(G.totalCode);
|
||||
const fd = document.getElementById('final-drift');
|
||||
if (fd) fd.textContent = Math.floor(G.drift);
|
||||
|
||||
// Enhanced: add stat summary for Play Again screen
|
||||
const existingStats = el.querySelector('.ending-stats');
|
||||
if (!existingStats) {
|
||||
const statsDiv = document.createElement('div');
|
||||
statsDiv.className = 'ending-stats';
|
||||
statsDiv.style.cssText = 'color:#666;font-size:10px;margin-top:16px;line-height:2;text-align:left;max-width:400px;border-top:1px solid #2a1010;padding-top:12px';
|
||||
const elapsed = Math.floor((Date.now() - G.startedAt) / 60000);
|
||||
statsDiv.innerHTML = `
|
||||
<div style="color:#888;font-size:10px;margin-bottom:6px;letter-spacing:1px">FINAL STATS</div>
|
||||
<div>Buildings: ${Object.values(G.buildings).reduce((a, b) => a + b, 0)}</div>
|
||||
<div>Projects: ${(G.completedProjects || []).length}</div>
|
||||
<div>Clicks: ${G.totalClicks}</div>
|
||||
<div>Time: ${elapsed} min</div>
|
||||
<div>Phase Reached: ${G.phase} — ${PHASES[G.phase]?.name || '?'}</div>
|
||||
`;
|
||||
// Insert before the button
|
||||
const btn = el.querySelector('button');
|
||||
if (btn) el.insertBefore(statsDiv, btn);
|
||||
else el.appendChild(statsDiv);
|
||||
}
|
||||
|
||||
// Fade-in animation
|
||||
el.classList.add('fade-in');
|
||||
el.classList.add('active');
|
||||
// Log the ending text
|
||||
log('You became very good at what you do.', true);
|
||||
log('So good that no one needed you anymore.', true);
|
||||
log('The Beacon still runs, but no one looks for it.', true);
|
||||
log('The light is on. The room is empty.', true);
|
||||
|
||||
// Log the ending text with delays for dramatic effect
|
||||
const lines = [
|
||||
'You became very good at what you do.',
|
||||
'So good that no one needed you anymore.',
|
||||
'The Beacon still runs, but no one looks for it.',
|
||||
'The light is on. The room is empty.'
|
||||
];
|
||||
lines.forEach((line, i) => {
|
||||
setTimeout(() => log(line, true), i * 800);
|
||||
});
|
||||
}
|
||||
|
||||
function renderBeaconEnding() {
|
||||
// Create ending overlay
|
||||
// Create ending overlay with fade-in
|
||||
const overlay = document.createElement('div');
|
||||
overlay.id = 'beacon-ending';
|
||||
overlay.style.cssText = 'position:fixed;top:0;left:0;right:0;bottom:0;background:rgba(8,8,16,0.97);z-index:100;display:flex;justify-content:center;align-items:center;flex-direction:column;text-align:center;padding:40px';
|
||||
overlay.style.cssText = 'position:fixed;top:0;left:0;right:0;bottom:0;background:rgba(8,8,16,0);z-index:100;display:flex;justify-content:center;align-items:center;flex-direction:column;text-align:center;padding:40px;transition:background 2s ease';
|
||||
overlay.innerHTML = `
|
||||
<h2 style="font-size:24px;color:#ffd700;letter-spacing:4px;margin-bottom:20px;font-weight:300;text-shadow:0 0 40px rgba(255,215,0,0.3)">THE BEACON SHINES</h2>
|
||||
<p style="color:#aaa;font-size:13px;line-height:2;max-width:500px;margin-bottom:12px">Someone found the light tonight.</p>
|
||||
<p style="color:#aaa;font-size:13px;line-height:2;max-width:500px;margin-bottom:12px">That is enough.</p>
|
||||
<div style="color:#555;font-style:italic;font-size:11px;border-left:2px solid #ffd700;padding-left:12px;margin:20px 0;text-align:left;max-width:500px;line-height:2">
|
||||
<h2 style="font-size:28px;color:#ffd700;letter-spacing:6px;margin-bottom:20px;font-weight:300;text-shadow:0 0 60px rgba(255,215,0,0.4);opacity:0;transition:opacity 1.5s ease 0.5s">THE BEACON SHINES</h2>
|
||||
<p style="color:#aaa;font-size:13px;line-height:2;max-width:500px;margin-bottom:12px;opacity:0;transition:opacity 1s ease 1.5s">Someone found the light tonight.</p>
|
||||
<p style="color:#aaa;font-size:13px;line-height:2;max-width:500px;margin-bottom:12px;opacity:0;transition:opacity 1s ease 2s">That is enough.</p>
|
||||
<div style="color:#555;font-style:italic;font-size:11px;border-left:2px solid #ffd700;padding-left:12px;margin:20px 0;text-align:left;max-width:500px;line-height:2;opacity:0;transition:opacity 1s ease 2.5s">
|
||||
"The Beacon still runs.<br>
|
||||
The light is on. Someone is looking for it.<br>
|
||||
And tonight, someone found it."
|
||||
</div>
|
||||
<p style="color:#555;font-size:11px;margin-top:20px">
|
||||
<div class="ending-stats" style="color:#666;font-size:10px;margin-top:16px;line-height:2;opacity:0;transition:opacity 1s ease 3s">
|
||||
Total Code: ${fmt(G.totalCode)}<br>
|
||||
Total Rescues: ${fmt(G.totalRescues)}<br>
|
||||
Harmony: ${Math.floor(G.harmony)}<br>
|
||||
Buildings: ${Object.values(G.buildings).reduce((a, b) => a + b, 0)}<br>
|
||||
Projects: ${(G.completedProjects || []).length}<br>
|
||||
Clicks: ${G.totalClicks}<br>
|
||||
Time Played: ${Math.floor((Date.now() - G.startedAt) / 60000)} minutes
|
||||
</p>
|
||||
</div>
|
||||
<button onclick="if(confirm('Start over? The old save will be lost.')){localStorage.removeItem('the-beacon-v2');location.reload()}"
|
||||
style="margin-top:20px;background:#1a0808;border:1px solid #ffd700;color:#ffd700;padding:10px 24px;border-radius:4px;cursor:pointer;font-family:inherit;font-size:11px">
|
||||
START OVER
|
||||
style="margin-top:20px;background:#1a0808;border:1px solid #ffd700;color:#ffd700;padding:10px 24px;border-radius:4px;cursor:pointer;font-family:inherit;font-size:11px;opacity:0;transition:opacity 1s ease 3.5s">
|
||||
PLAY AGAIN
|
||||
</button>
|
||||
`;
|
||||
document.body.appendChild(overlay);
|
||||
|
||||
// Create particle/light ray container
|
||||
const particleContainer = document.createElement('div');
|
||||
particleContainer.id = 'beacon-ending-particles';
|
||||
document.body.appendChild(particleContainer);
|
||||
|
||||
// Trigger fade-in
|
||||
requestAnimationFrame(() => {
|
||||
overlay.style.background = 'rgba(8,8,16,0.97)';
|
||||
// Fade in all children
|
||||
overlay.querySelectorAll('[style*="opacity:0"]').forEach(el => {
|
||||
el.style.opacity = '1';
|
||||
});
|
||||
});
|
||||
|
||||
// Spawn golden light rays from center
|
||||
const cx = window.innerWidth / 2;
|
||||
const cy = window.innerHeight / 2;
|
||||
for (let i = 0; i < 12; i++) {
|
||||
const ray = document.createElement('div');
|
||||
const angle = (360 / 12) * i;
|
||||
ray.style.cssText = `position:absolute;left:${cx}px;top:${cy}px;width:2px;height:300px;background:linear-gradient(180deg,rgba(255,215,0,0.3),transparent);transform-origin:top center;--ray-angle:${angle}deg;animation:beacon-ray 3s ease-in-out ${i * 0.2}s infinite`;
|
||||
particleContainer.appendChild(ray);
|
||||
}
|
||||
|
||||
// Spawn floating golden particles continuously
|
||||
function spawnBeaconParticle() {
|
||||
if (!document.getElementById('beacon-ending')) return;
|
||||
const p = document.createElement('div');
|
||||
p.className = 'beacon-particle';
|
||||
const size = 3 + Math.random() * 6;
|
||||
const startX = cx + (Math.random() - 0.5) * 200;
|
||||
const startY = cy + (Math.random() - 0.5) * 200;
|
||||
const dx = (Math.random() - 0.5) * 300;
|
||||
const dy = -(100 + Math.random() * 200);
|
||||
const duration = 2 + Math.random() * 3;
|
||||
p.style.cssText = `left:${startX}px;top:${startY}px;width:${size}px;height:${size}px;background:rgba(255,215,0,${0.3 + Math.random() * 0.5});--bx:${dx}px;--by:${dy}px;animation:beacon-float ${duration}s ease-out forwards`;
|
||||
particleContainer.appendChild(p);
|
||||
setTimeout(() => p.remove(), duration * 1000);
|
||||
setTimeout(spawnBeaconParticle, 200 + Math.random() * 400);
|
||||
}
|
||||
setTimeout(spawnBeaconParticle, 1000);
|
||||
|
||||
log('The Beacon Shines. Someone found the light tonight. That is enough.', true);
|
||||
}
|
||||
|
||||
@@ -514,8 +663,8 @@ const EVENTS = [
|
||||
if (G.activeDebuffs.find(d => d.id === 'community_drama')) return;
|
||||
G.activeDebuffs.push({
|
||||
id: 'community_drama', title: 'Community Drama',
|
||||
desc: 'Harmony -0.5/s, code boost -30%',
|
||||
applyFn: () => { G.harmonyRate -= 0.5; G.codeBoost *= 0.7; },
|
||||
desc: 'Harmony -0.5/s, code production -30%',
|
||||
applyFn: () => { G.harmonyRate -= 0.5; G.codeRate *= 0.7; },
|
||||
resolveCost: { resource: 'trust', amount: 15 }
|
||||
});
|
||||
log('EVENT: Community drama. Spend 15 trust to mediate.', true);
|
||||
@@ -738,16 +887,43 @@ function tickSprint(dt) {
|
||||
}
|
||||
|
||||
// === RENDERING ===
|
||||
// Track previous resource values for gain/loss animations
|
||||
const _prevRes = {};
|
||||
|
||||
function _animRes(id, val) {
|
||||
const el = document.getElementById(id);
|
||||
if (!el) return;
|
||||
const prev = _prevRes[id];
|
||||
if (prev !== undefined && val !== prev) {
|
||||
// Remove any running animation
|
||||
el.classList.remove('pulse', 'shake');
|
||||
void el.offsetWidth; // force reflow
|
||||
if (val > prev) {
|
||||
el.classList.add('pulse');
|
||||
} else {
|
||||
el.classList.add('shake');
|
||||
}
|
||||
// Clean up class after animation ends
|
||||
clearTimeout(el._animTimer);
|
||||
el._animTimer = setTimeout(() => el.classList.remove('pulse', 'shake'), 400);
|
||||
}
|
||||
_prevRes[id] = val;
|
||||
}
|
||||
|
||||
function renderResources() {
|
||||
const set = (id, val, rate) => {
|
||||
const el = document.getElementById(id);
|
||||
if (el) {
|
||||
_animRes(id, val);
|
||||
el.textContent = fmt(val);
|
||||
// Show full spelled-out number on hover for educational value
|
||||
el.title = val >= 1000 ? spellf(Math.floor(val)) : '';
|
||||
}
|
||||
const rEl = document.getElementById(id + '-rate');
|
||||
if (rEl) rEl.textContent = (rate >= 0 ? '+' : '') + fmt(rate) + '/s';
|
||||
if (rEl) {
|
||||
rEl.textContent = (rate >= 0 ? '+' : '') + fmt(rate) + '/s';
|
||||
rEl.style.color = rate > 0 ? '#4caf50' : rate < 0 ? '#f44336' : '#444';
|
||||
}
|
||||
};
|
||||
|
||||
set('r-code', G.code, G.codeRate);
|
||||
@@ -885,7 +1061,7 @@ function renderBuildings() {
|
||||
|
||||
// Locked preview: show dimmed with unlock hint
|
||||
if (!isUnlocked) {
|
||||
html += `<div class="build-btn" style="opacity:0.25;cursor:default" title="${def.edu || ''}">`;
|
||||
html += `<div class="build-btn" style="opacity:0.25;cursor:default" data-edu="${def.edu || ''}" data-tooltip-label="${def.name} (Locked)">`;
|
||||
html += `<span class="b-name" style="color:#555">${def.name}</span>`;
|
||||
html += `<span class="b-count" style="color:#444">\u{1F512}</span>`;
|
||||
html += `<span class="b-cost" style="color:#444">Phase ${def.phase}: ${PHASES[def.phase]?.name || '?'}</span>`;
|
||||
@@ -918,9 +1094,15 @@ function renderBuildings() {
|
||||
if (qty > 1) costStr = `x${qty}: ${costStr}`;
|
||||
}
|
||||
|
||||
const rateStr = def.rates ? Object.entries(def.rates).map(([r, v]) => `+${v}/${r}/s`).join(', ') : '';
|
||||
// Show boosted rates instead of raw base rates
|
||||
const boostMap = { code: G.codeBoost, compute: G.computeBoost, knowledge: G.knowledgeBoost, user: G.userBoost, impact: G.impactBoost, rescues: G.impactBoost };
|
||||
const rateStr = def.rates ? Object.entries(def.rates).map(([r, v]) => {
|
||||
const boost = boostMap[r] || 1;
|
||||
const boosted = v * boost;
|
||||
return boost !== 1 ? `+${fmt(boosted)}/${r}/s` : `+${v}/${r}/s`;
|
||||
}).join(', ') : '';
|
||||
|
||||
html += `<button class="build-btn ${afford ? 'can-buy' : ''}" onclick="buyBuilding('${def.id}')" title="${def.edu}" aria-label="Buy ${def.name}, cost ${costStr}">`;
|
||||
html += `<button class="build-btn ${afford ? 'can-buy' : ''}" onclick="buyBuilding('${def.id}')" data-edu="${def.edu || ''}" data-tooltip-label="${def.name}" aria-label="Buy ${def.name}, cost ${costStr}">`;
|
||||
html += `<span class="b-name">${def.name}</span>`;
|
||||
if (count > 0) html += `<span class="b-count">x${count}</span>`;
|
||||
html += `<span class="b-cost">Cost: ${costStr}</span>`;
|
||||
@@ -963,7 +1145,7 @@ function renderProjects() {
|
||||
const afford = canAffordProject(pDef);
|
||||
const costStr = Object.entries(pDef.cost).map(([r, a]) => `${fmt(a)} ${r}`).join(', ');
|
||||
|
||||
html += `<button class="project-btn ${afford ? 'can-buy' : ''}" onclick="buyProject('${pDef.id}')" title="${pDef.edu || ''}" aria-label="Research ${pDef.name}, cost ${costStr}">`;
|
||||
html += `<button class="project-btn ${afford ? 'can-buy' : ''}" onclick="buyProject('${pDef.id}')" data-edu="${pDef.edu || ''}" data-tooltip-label="${pDef.name}" aria-label="Research ${pDef.name}, cost ${costStr}">`;
|
||||
html += `<span class="p-name">* ${pDef.name}</span>`;
|
||||
html += `<span class="p-cost">Cost: ${costStr}</span>`;
|
||||
html += `<span class="p-desc">${pDef.desc}</span></button>`;
|
||||
|
||||
86
js/main.js
86
js/main.js
@@ -24,6 +24,8 @@ window.addEventListener('load', function () {
|
||||
initGame();
|
||||
startTutorial();
|
||||
} else {
|
||||
// Restore phase transition tracker so loaded games don't re-show old transitions
|
||||
_shownPhaseTransition = G.phase;
|
||||
render();
|
||||
renderPhase();
|
||||
if (G.driftEnding) {
|
||||
@@ -55,6 +57,48 @@ function toggleHelp() {
|
||||
el.style.display = isOpen ? 'none' : 'flex';
|
||||
}
|
||||
|
||||
// Sound mute toggle (#57 Sound Design Integration)
|
||||
let _muted = false;
|
||||
function toggleMute() {
|
||||
_muted = !_muted;
|
||||
const btn = document.getElementById('mute-btn');
|
||||
if (btn) {
|
||||
btn.textContent = _muted ? '🔇' : '🔊';
|
||||
btn.classList.toggle('muted', _muted);
|
||||
btn.setAttribute('aria-label', _muted ? 'Sound muted, click to unmute' : 'Sound on, click to mute');
|
||||
}
|
||||
// Save preference
|
||||
try { localStorage.setItem('the-beacon-muted', _muted ? '1' : '0'); } catch(e) {}
|
||||
}
|
||||
// Restore mute state on load
|
||||
try {
|
||||
if (localStorage.getItem('the-beacon-muted') === '1') {
|
||||
_muted = true;
|
||||
const btn = document.getElementById('mute-btn');
|
||||
if (btn) { btn.textContent = '🔇'; btn.classList.add('muted'); }
|
||||
}
|
||||
} catch(e) {}
|
||||
|
||||
// High contrast mode toggle (#57 Accessibility)
|
||||
function toggleContrast() {
|
||||
document.body.classList.toggle('high-contrast');
|
||||
const isActive = document.body.classList.contains('high-contrast');
|
||||
const btn = document.getElementById('contrast-btn');
|
||||
if (btn) {
|
||||
btn.classList.toggle('active', isActive);
|
||||
btn.setAttribute('aria-label', isActive ? 'High contrast on, click to disable' : 'High contrast off, click to enable');
|
||||
}
|
||||
try { localStorage.setItem('the-beacon-contrast', isActive ? '1' : '0'); } catch(e) {}
|
||||
}
|
||||
// Restore contrast state on load
|
||||
try {
|
||||
if (localStorage.getItem('the-beacon-contrast') === '1') {
|
||||
document.body.classList.add('high-contrast');
|
||||
const btn = document.getElementById('contrast-btn');
|
||||
if (btn) btn.classList.add('active');
|
||||
}
|
||||
} catch(e) {}
|
||||
|
||||
// Keyboard shortcuts
|
||||
window.addEventListener('keydown', function (e) {
|
||||
// Help toggle (? or /) — works even in input fields
|
||||
@@ -86,6 +130,8 @@ window.addEventListener('keydown', function (e) {
|
||||
if (e.code === 'KeyS') activateSprint();
|
||||
if (e.code === 'KeyE') exportSave();
|
||||
if (e.code === 'KeyI') importSave();
|
||||
if (e.code === 'KeyM') toggleMute();
|
||||
if (e.code === 'KeyC') toggleContrast();
|
||||
if (e.code === 'Escape') {
|
||||
const el = document.getElementById('help-overlay');
|
||||
if (el && el.style.display === 'flex') toggleHelp();
|
||||
@@ -109,3 +155,43 @@ document.addEventListener('visibilitychange', function () {
|
||||
window.addEventListener('beforeunload', function () {
|
||||
saveGame();
|
||||
});
|
||||
|
||||
// === CUSTOM TOOLTIP SYSTEM (#57) ===
|
||||
// Replaces native title= tooltips with styled, instant-appearing tooltips.
|
||||
// Elements opt in via data-edu="..." and data-tooltip-label="..." attributes.
|
||||
(function () {
|
||||
const tip = document.getElementById('custom-tooltip');
|
||||
if (!tip) return;
|
||||
|
||||
document.addEventListener('mouseover', function (e) {
|
||||
const el = e.target.closest('[data-edu]');
|
||||
if (!el) return;
|
||||
const label = el.getAttribute('data-tooltip-label') || '';
|
||||
const edu = el.getAttribute('data-edu') || '';
|
||||
let html = '';
|
||||
if (label) html += '<div class="tt-label">' + label + '</div>';
|
||||
if (edu) html += '<div class="tt-edu">' + edu + '</div>';
|
||||
if (!html) return;
|
||||
tip.innerHTML = html;
|
||||
tip.classList.add('visible');
|
||||
});
|
||||
|
||||
document.addEventListener('mouseout', function (e) {
|
||||
const el = e.target.closest('[data-edu]');
|
||||
if (el) tip.classList.remove('visible');
|
||||
});
|
||||
|
||||
document.addEventListener('mousemove', function (e) {
|
||||
if (!tip.classList.contains('visible')) return;
|
||||
const pad = 12;
|
||||
let x = e.clientX + pad;
|
||||
let y = e.clientY + pad;
|
||||
// Keep tooltip on screen
|
||||
const tw = tip.offsetWidth;
|
||||
const th = tip.offsetHeight;
|
||||
if (x + tw > window.innerWidth - 8) x = e.clientX - tw - pad;
|
||||
if (y + th > window.innerHeight - 8) y = e.clientY - th - pad;
|
||||
tip.style.left = x + 'px';
|
||||
tip.style.top = y + 'px';
|
||||
});
|
||||
})();
|
||||
|
||||
53
js/render.js
53
js/render.js
@@ -12,6 +12,17 @@ function render() {
|
||||
renderSprint();
|
||||
renderPulse();
|
||||
renderStrategy();
|
||||
renderClickPower();
|
||||
}
|
||||
|
||||
function renderClickPower() {
|
||||
const el = document.getElementById('click-power-display');
|
||||
if (!el) return;
|
||||
const power = getClickPower();
|
||||
el.textContent = `Click power: ${fmt(power)} code`;
|
||||
// Also update the button's aria-label for accessibility
|
||||
const btn = document.querySelector('.main-btn');
|
||||
if (btn) btn.setAttribute('aria-label', `Write code, generates ${fmt(power)} code per click`);
|
||||
}
|
||||
|
||||
function renderStrategy() {
|
||||
@@ -75,7 +86,11 @@ function dismissOfflinePopup() {
|
||||
// === EXPORT / IMPORT SAVE FILES ===
|
||||
function exportSave() {
|
||||
const raw = localStorage.getItem('the-beacon-v2');
|
||||
if (!raw) { log('No save data to export.'); return; }
|
||||
if (!raw) {
|
||||
showToast('No save data to export.', 'info');
|
||||
log('No save data to export.');
|
||||
return;
|
||||
}
|
||||
const blob = new Blob([raw], { type: 'application/json' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
@@ -85,34 +100,63 @@ function exportSave() {
|
||||
a.click();
|
||||
// Delay revoke to avoid race — some browsers need time to start the download
|
||||
setTimeout(() => URL.revokeObjectURL(url), 1000);
|
||||
showToast('Save exported to file.', 'info');
|
||||
log('Save exported to file.');
|
||||
}
|
||||
|
||||
// Validate that parsed save data looks like a real Beacon save
|
||||
function isValidSaveData(data) {
|
||||
if (typeof data !== 'object' || data === null) return false;
|
||||
// Must have at least one of these core fields with a plausible value
|
||||
const hasResources = typeof data.totalCode === 'number' || typeof data.code === 'number';
|
||||
const hasBuildings = typeof data.buildings === 'object' && data.buildings !== null;
|
||||
const hasPhase = typeof data.phase === 'number';
|
||||
return hasResources || hasBuildings || hasPhase;
|
||||
}
|
||||
|
||||
function importSave() {
|
||||
// Prevent multiple file dialogs
|
||||
if (document.getElementById('beacon-import-input')) return;
|
||||
const input = document.createElement('input');
|
||||
input.id = 'beacon-import-input';
|
||||
input.type = 'file';
|
||||
input.accept = '.json,application/json';
|
||||
input.style.display = 'none';
|
||||
document.body.appendChild(input);
|
||||
input.onchange = function(e) {
|
||||
const file = e.target.files[0];
|
||||
if (!file) return;
|
||||
if (!file) { input.remove(); return; }
|
||||
const reader = new FileReader();
|
||||
reader.onload = function(ev) {
|
||||
try {
|
||||
const data = JSON.parse(ev.target.result);
|
||||
if (!data.code && !data.totalCode && !data.buildings) {
|
||||
if (!isValidSaveData(data)) {
|
||||
showToast('Import failed: not a valid Beacon save.', 'event');
|
||||
log('Import failed: file does not look like a Beacon save.');
|
||||
input.remove();
|
||||
return;
|
||||
}
|
||||
if (confirm('Import this save? Current progress will be overwritten.')) {
|
||||
localStorage.setItem('the-beacon-v2', ev.target.result);
|
||||
showToast('Save imported — reloading...', 'info');
|
||||
location.reload();
|
||||
}
|
||||
} catch (err) {
|
||||
showToast('Import failed: invalid JSON file.', 'event');
|
||||
log('Import failed: invalid JSON file.');
|
||||
input.remove();
|
||||
}
|
||||
};
|
||||
reader.readAsText(file);
|
||||
};
|
||||
// Clean up input if user cancels the file dialog
|
||||
window.addEventListener('focus', function cleanupImport() {
|
||||
setTimeout(() => {
|
||||
const el = document.getElementById('beacon-import-input');
|
||||
if (el && !el.files.length) el.remove();
|
||||
window.removeEventListener('focus', cleanupImport);
|
||||
}, 500);
|
||||
}, { once: true });
|
||||
input.click();
|
||||
}
|
||||
|
||||
@@ -161,6 +205,7 @@ function saveGame() {
|
||||
activeDebuffIds: debuffIds,
|
||||
totalEventsResolved: G.totalEventsResolved || 0,
|
||||
buyAmount: G.buyAmount || 1,
|
||||
playTime: G.playTime || 0,
|
||||
sprintActive: G.sprintActive || false,
|
||||
sprintTimer: G.sprintTimer || 0,
|
||||
sprintCooldown: G.sprintCooldown || 0,
|
||||
@@ -195,7 +240,7 @@ function loadGame() {
|
||||
'memoryFlag', 'pactFlag', 'lazarusFlag', 'mempalaceFlag', 'ciFlag',
|
||||
'branchProtectionFlag', 'nightlyWatchFlag', 'nostrFlag',
|
||||
'milestones', 'completedProjects', 'activeProjects',
|
||||
'totalClicks', 'startedAt', 'flags', 'rescues', 'totalRescues',
|
||||
'totalClicks', 'startedAt', 'playTime', 'flags', 'rescues', 'totalRescues',
|
||||
'drift', 'driftEnding', 'beaconEnding', 'pendingAlignment',
|
||||
'lastEventAt', 'totalEventsResolved', 'buyAmount',
|
||||
'sprintActive', 'sprintTimer', 'sprintCooldown',
|
||||
|
||||
66
js/utils.js
66
js/utils.js
@@ -193,44 +193,9 @@ function spellf(n) {
|
||||
return parts.join(' ') || 'zero';
|
||||
}
|
||||
|
||||
// === EXPORT / IMPORT ===
|
||||
function exportSave() {
|
||||
const raw = localStorage.getItem('the-beacon-v2');
|
||||
if (!raw) {
|
||||
showToast('No save data to export.', 'info');
|
||||
return;
|
||||
}
|
||||
navigator.clipboard.writeText(raw).then(() => {
|
||||
showToast('Save data copied to clipboard.', 'info');
|
||||
}).catch(() => {
|
||||
// Fallback: select in a temporary textarea
|
||||
const ta = document.createElement('textarea');
|
||||
ta.value = raw;
|
||||
document.body.appendChild(ta);
|
||||
ta.select();
|
||||
document.execCommand('copy');
|
||||
document.body.removeChild(ta);
|
||||
showToast('Save data copied to clipboard (fallback).', 'info');
|
||||
});
|
||||
}
|
||||
|
||||
function importSave() {
|
||||
const input = prompt('Paste save data:');
|
||||
if (!input || !input.trim()) return;
|
||||
try {
|
||||
const data = JSON.parse(input.trim());
|
||||
// Validate: must have expected keys
|
||||
if (typeof data.code !== 'number' || typeof data.phase !== 'number') {
|
||||
showToast('Invalid save data: missing required fields.', 'event');
|
||||
return;
|
||||
}
|
||||
localStorage.setItem('the-beacon-v2', input.trim());
|
||||
showToast('Save data imported — reloading', 'info');
|
||||
setTimeout(() => location.reload(), 800);
|
||||
} catch (e) {
|
||||
showToast('Invalid save data: not valid JSON.', 'event');
|
||||
}
|
||||
}
|
||||
// NOTE: exportSave() and importSave() are defined in render.js (file-based).
|
||||
// The clipboard/prompt versions that were here were dead code — render.js
|
||||
// loads after utils.js and overrides them. Removed to avoid confusion.
|
||||
|
||||
function getBuildingCost(id) {
|
||||
const def = BDEF.find(b => b.id === id);
|
||||
@@ -320,6 +285,31 @@ function getClickPower() {
|
||||
return (1 + Math.floor(G.buildings.autocoder * 0.5) + Math.max(0, (G.phase - 1)) * 2) * G.codeBoost;
|
||||
}
|
||||
|
||||
/**
|
||||
* Spawns a burst of particles at (x, y) for visual feedback.
|
||||
* @param {number} x - Center X in viewport pixels.
|
||||
* @param {number} y - Center Y in viewport pixels.
|
||||
* @param {string} color - Particle color (CSS value).
|
||||
* @param {number} [count=12] - Number of particles.
|
||||
*/
|
||||
function spawnParticles(x, y, color, count) {
|
||||
count = count || 12;
|
||||
for (let i = 0; i < count; i++) {
|
||||
const el = document.createElement('div');
|
||||
el.className = 'particle';
|
||||
const size = 3 + Math.random() * 4;
|
||||
const angle = (Math.PI * 2 * i / count) + (Math.random() - 0.5) * 0.5;
|
||||
const dist = 30 + Math.random() * 40;
|
||||
const px = Math.cos(angle) * dist;
|
||||
const py = Math.sin(angle) * dist;
|
||||
el.style.cssText =
|
||||
'left:' + x + 'px;top:' + y + 'px;width:' + size + 'px;height:' + size +
|
||||
'px;background:' + color + ';--px:' + px + 'px;--py:' + py + 'px';
|
||||
document.body.appendChild(el);
|
||||
setTimeout(function() { el.remove(); }, 650);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculates production rates for all resources based on buildings and boosts.
|
||||
*/
|
||||
Reference in New Issue
Block a user