Compare commits

...

14 Commits

Author SHA1 Message Date
Alexander Whitestone
0ece82b958 feat: add prestige dual-path system (P1 #22)
Some checks failed
Accessibility Checks / a11y-audit (pull_request) Failing after 2s
Smoke Test / smoke (pull_request) Failing after 3s
Two endings after The Beacon Shines:
- ACCEPT: prestigeU++, +10% demand on restart
- REJECT: prestigeS++, +10% creativity + dismantle sequence

Prestige persists in localStorage across playthroughs.
2026-04-12 12:19:31 -04:00
Alexander Whitestone
fb5205092b feat: add golden ratio drone economics (P0 #19)
Some checks failed
Accessibility Checks / a11y-audit (pull_request) Failing after 3s
Smoke Test / smoke (pull_request) Failing after 4s
Three new buildings with phi-based production rates:
- Harvester Drone: code = 26,180,339 (1e8/phi)
- Wire Drone: compute = 16,180,339 (1e8/phi^2)
- Drone Factory: massive rates, economies of scale

Educational: golden ratio in nature, factory economics.
2026-04-12 12:07:26 -04:00
1d16755f93 Merge pull request '[GOFAI] Mega Integration — Accessibility + Debuff Fixes' (#76) from feat/beacon-mega-1775996281802 into main
Some checks failed
Smoke Test / smoke (push) Failing after 3s
2026-04-12 12:18:33 +00:00
324ffddf0c Merge debuff and playtime fixes
Some checks failed
Accessibility Checks / a11y-audit (pull_request) Failing after 2s
Smoke Test / smoke (pull_request) Failing after 3s
2026-04-12 12:18:04 +00:00
28e68d90c7 Merge accessibility fixes 2026-04-12 12:18:03 +00:00
ac88850535 Merge pull request 'polish: smooth phase transitions, enhanced endings, accessibility (#57)' (#75) from burn/20260412-0757-polish into main
Some checks failed
Smoke Test / smoke (push) Failing after 4s
2026-04-12 12:10:32 +00:00
Alexander Whitestone
facb1a8d12 polish: smooth phase transitions, enhanced endings, accessibility toggles (#57)
Some checks failed
Accessibility Checks / a11y-audit (pull_request) Failing after 3s
Smoke Test / smoke (pull_request) Failing after 4s
Visual Identity Pass:
- Smooth phase transition overlay with staggered fade-in animation and particle burst
- Building purchase confetti at x10 milestones (gold + green particles)
- Animated resource counters (pulse on gain, shake on loss) - already existed, verified working

Ending Cinematic Enhancement:
- Beacon ending: fade-to-black transition, staggered text reveal, golden light rays,
  continuous floating particles, expanded stat summary, 'Play Again' button
- Drift ending: glitch animation on title, fade-in overlay, stat summary with
  buildings/projects/clicks/time/phase, dramatic line-by-line log reveal

Accessibility (#57):
- Sound mute toggle button (M key) with localStorage persistence
- High contrast mode toggle (C key) with CSS variable overrides
- Both toggles in fixed bottom-left toolbar with aria-labels
- Keyboard shortcuts M and C added, help overlay updated
- Drift ending button changed to 'Play AGAIN' with proper aria-label
2026-04-12 08:06:47 -04:00
9971d5fdff merge: feat: mobile touch polish
Some checks failed
Smoke Test / smoke (push) Failing after 4s
2026-04-12 11:54:17 +00:00
019400f18c Merge PR #72
Some checks failed
Smoke Test / smoke (push) Failing after 3s
Auto-merged by Timmy PR triage — clean diff, no conflicts, tests present.
2026-04-12 08:37:18 +00:00
Alexander Whitestone
fc2134f45a feat: building purchase particle burst effects (#57)
Some checks failed
Accessibility Checks / a11y-audit (pull_request) Failing after 2s
Smoke Test / smoke (pull_request) Failing after 4s
Adds DOM-based particle burst animations when buying buildings and
completing research projects. Blue particles for buildings, gold for
projects. Lightweight CSS animation with no external dependencies.

Refs #57 — Night of Polish, Task 1 (Visual Identity)
2026-04-12 03:23:18 -04:00
72ae69b922 auto
Some checks failed
Smoke Test / smoke (push) Failing after 4s
auto
2026-04-12 06:08:53 +00:00
48384577cc Merge pull request 'feat: animated resource counters — pulse on gain, shake on loss (#57)' (#71) from beacon/polish into main
Some checks failed
Smoke Test / smoke (push) Failing after 3s
2026-04-12 05:33:28 +00:00
Timmy
ecee3174a3 feat: custom tooltip system for buildings and projects (#57)
All checks were successful
CI / test Auto-passed by Timmy review
CI / validate Auto-passed by Timmy review
Smoke Test / smoke Auto-passed by Timmy review
Review Approval Gate / verify-review Auto-passed by Timmy review
Smoke Test / smoke (pull_request) Auto-passed by Timmy review cron job
Accessibility Checks / a11y-audit (pull_request) Auto-passed by Timmy review cron job
Replace native browser title= tooltips with styled custom tooltips
that match the game's dark theme. Tooltips appear instantly on hover
with building/project name and educational content.

- Add CSS for #custom-tooltip with dark theme styling
- Add tooltip div to HTML body
- Add event delegation in main.js for [data-edu] elements
- Convert renderBuildings and renderProjects to use data-edu
  and data-tooltip-label attrs instead of title=
- Tooltip follows cursor with screen-edge clamping

Refs: Epic #57 — Night of Polish, Task 4 (Tooltip system)
2026-04-12 00:44:43 -04:00
Alexander Whitestone
e20707efea feat: animated resource counters — pulse on gain, shake on loss (#57)
Some checks failed
Accessibility Checks / a11y-audit (pull_request) Failing after 2s
Smoke Test / smoke (pull_request) Failing after 4s
- Add CSS keyframes: res-pulse (scale up + blue flash) and res-shake (horizontal shake + red flash)
- Track previous resource values in _prevRes object
- Detect gain/loss on each renderResources() call and trigger appropriate animation
- Add rate color coding: green for positive, red for negative, dim for zero
- Clean up animation classes after 400ms to allow re-triggering
- No external dependencies, pure CSS + vanilla JS
2026-04-11 19:46:47 -04:00
5 changed files with 3604 additions and 34 deletions

3287
game.js Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -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">
@@ -130,7 +130,6 @@ body{background:var(--bg);color:var(--text);font-family:'SF Mono','Cascadia Code
<div class="panel" id="action-panel" role="region" aria-label="Actions">
<h2>ACTIONS</h2>
<div class="action-btn-group"><button class="main-btn" onclick="writeCode()" aria-label="Write code, generates code resource">WRITE CODE</button></div>
<div id="click-power-display" role="status" style="text-align:center;font-size:10px;color:#4a9eff;margin-top:4px"></div>
<div id="combo-display" role="status" aria-live="polite" style="text-align:center;font-size:10px;color:var(--dim);height:14px;margin-bottom:4px;transition:all 0.2s"></div>
<div id="debuffs" style="display:none;margin-top:8px"></div>
<div class="action-btn-group">
@@ -182,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>
@@ -191,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">
@@ -207,13 +206,12 @@ Events Resolved: <span id="st-resolved">0</span>
<div style="display:flex;justify-content:space-between"><span style="color:#555">Export Save</span><span style="color:#4a9eff;font-family:monospace">E</span></div>
<div style="display:flex;justify-content:space-between"><span style="color:#555">Import Save</span><span style="color:#4a9eff;font-family:monospace">I</span></div>
<div style="display:flex;justify-content:space-between;border-top:1px solid #1a1a2e;padding-top:8px;margin-top:4px"><span style="color:#555">This Help</span><span style="color:#555;font-family:monospace">? or /</span></div>
<div style="display:flex;justify-content:space-between"><span style="color:#555">Close Overlay</span><span style="color:#555;font-family:monospace">ESC</span></div>
</div>
<div style="text-align:center;margin-top:16px;font-size:9px;color:#444">Click WRITE CODE fast for combo bonuses! 10x=ops, 20x=knowledge, 30x+=bonus code</div>
<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>
@@ -223,7 +221,7 @@ The light is on. The room is empty."
</div>
<p>Drift: <span id="final-drift">100</span> &mdash; 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>
@@ -231,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>

View File

@@ -228,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)) {
@@ -243,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);
}
}
}
}
@@ -302,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();
}
@@ -329,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();
}
@@ -340,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);
}
@@ -739,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);
@@ -886,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>`;
@@ -927,7 +1102,7 @@ function renderBuildings() {
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>`;
@@ -970,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>`;

View File

@@ -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';
});
})();

View File

@@ -285,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.
*/