polish: smooth phase transitions, enhanced endings, accessibility (#57) #75

Merged
Timmy merged 1 commits from burn/20260412-0757-polish into main 2026-04-12 12:10:33 +00:00
3 changed files with 239 additions and 20 deletions

View File

@@ -106,6 +106,32 @@ body{background:var(--bg);color:var(--text);font-family:'SF Mono','Cascadia Code
#custom-tooltip.visible{opacity:1}
#custom-tooltip .tt-label{color:var(--accent);font-weight:600;margin-bottom:3px;font-size:10px}
#custom-tooltip .tt-edu{color:#888;font-style:italic;font-size:9px;border-top:1px solid #1a2a3a;padding-top:4px;margin-top:4px}
#phase-transition{position:fixed;top:0;left:0;right:0;bottom:0;z-index:95;display:flex;justify-content:center;align-items:center;flex-direction:column;pointer-events:none;opacity:0;transition:opacity 0.4s ease;background:rgba(8,8,16,0)}
#phase-transition.active{opacity:1;pointer-events:auto;background:rgba(8,8,16,0.92)}
#phase-transition .pt-phase{font-size:10px;color:#555;letter-spacing:4px;text-transform:uppercase;margin-bottom:8px;transform:translateY(10px);opacity:0;transition:all 0.5s ease 0.2s}
#phase-transition.active .pt-phase{transform:translateY(0);opacity:1}
#phase-transition .pt-name{font-size:28px;color:#ffd700;letter-spacing:6px;font-weight:300;text-shadow:0 0 40px rgba(255,215,0,0.3);transform:translateY(10px);opacity:0;transition:all 0.5s ease 0.35s}
#phase-transition.active .pt-name{transform:translateY(0);opacity:1}
#phase-transition .pt-desc{font-size:11px;color:#888;margin-top:12px;font-style:italic;transform:translateY(10px);opacity:0;transition:all 0.5s ease 0.5s}
#phase-transition.active .pt-desc{transform:translateY(0);opacity:1}
#phase-transition .pt-particles{position:absolute;top:50%;left:50%;width:0;height:0}
#beacon-ending-particles{position:fixed;top:0;left:0;right:0;bottom:0;pointer-events:none;z-index:99}
.beacon-particle{position:absolute;border-radius:50%;pointer-events:none}
@keyframes beacon-float{0%{transform:translate(0,0) scale(1);opacity:0.8}100%{transform:translate(var(--bx),var(--by)) scale(0);opacity:0}}
@keyframes drift-glitch{0%,100%{transform:translate(0);filter:none}20%{transform:translate(-2px,1px);filter:hue-rotate(90deg)}40%{transform:translate(2px,-1px);filter:saturate(2)}60%{transform:translate(-1px,-2px);filter:contrast(1.5)}80%{transform:translate(1px,2px);filter:hue-rotate(-90deg)}}
#drift-ending h2{animation:drift-glitch 0.3s ease-in-out infinite}
#drift-ending.fade-in{animation:tutorial-fade-in 1.5s ease-out}
@keyframes beacon-ray{0%{transform:rotate(var(--ray-angle)) scaleY(0);opacity:0}50%{opacity:0.3}100%{transform:rotate(var(--ray-angle)) scaleY(1);opacity:0}}
#ui-toggles{position:fixed;bottom:16px;left:16px;z-index:50;display:flex;gap:6px}
.toggle-btn{width:28px;height:28px;background:#0e0e1a;border:1px solid #333;color:#555;font-size:12px;border-radius:50%;cursor:pointer;display:flex;align-items:center;justify-content:center;font-family:inherit;transition:all 0.2s}
.toggle-btn:hover{border-color:#555;color:#888}
.toggle-btn.active{border-color:#4a9eff;color:#4a9eff}
.toggle-btn.muted{border-color:#f44336;color:#f44336}
body.high-contrast{--bg:#000;--panel:#0a0a0a;--border:#fff;--text:#fff;--dim:#ccc;--accent:#0ff;--glow:#0ff333;--gold:#ff0;--green:#0f0;--red:#f00;--purple:#f0f}
body.high-contrast .res .r-val{color:#fff}
body.high-contrast .main-btn{border-color:#0ff;color:#0ff}
body.high-contrast .build-btn{border-color:#fff;color:#fff}
body.high-contrast .ops-btn{border-color:#f0f;color:#f0f}
</style>
</head>
<body>
@@ -201,6 +227,10 @@ 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="ui-toggles" role="toolbar" aria-label="Game settings">
<button id="mute-btn" class="toggle-btn" onclick="toggleMute()" aria-label="Toggle sound" title="Toggle sound (M)">🔊</button>
<button id="contrast-btn" class="toggle-btn" onclick="toggleContrast()" aria-label="Toggle high contrast mode" title="High contrast (C)"></button>
</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 style="background:#0e0e1a;border:1px solid #1a3a5a;border-radius:8px;padding:24px 32px;max-width:420px;width:100%">
@@ -216,6 +246,8 @@ Events Resolved: <span id="st-resolved">0</span>
<div style="display:flex;justify-content:space-between"><span style="color:#555">Save Game</span><span style="color:#4a9eff;font-family:monospace">Ctrl+S</span></div>
<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"><span style="color:#555">Toggle Sound</span><span style="color:#4a9eff;font-family:monospace">M</span></div>
<div style="display:flex;justify-content:space-between"><span style="color:#555">High Contrast</span><span style="color:#4a9eff;font-family:monospace">C</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>
@@ -233,7 +265,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 onclick="if(confirm('Start over? The old save will be lost.')){localStorage.removeItem('the-beacon-v2');location.reload()}" aria-label="Play again, reset all progress">PLAY AGAIN</button>
</div>
<script src="js/data.js"></script>
@@ -254,6 +286,11 @@ The light is on. The room is empty."
</div>
</div>
<div id="phase-transition" aria-live="assertive" role="status">
<div class="pt-phase">PHASE 2</div>
<div class="pt-name">THE FOUNDATION</div>
<div class="pt-desc">Your code begins to take shape.</div>
</div>
<div id="toast-container"></div>
<div id="custom-tooltip"></div>
</body>

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,12 +352,22 @@ 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();
spawnParticles(rect.left + rect.width / 2, rect.top + rect.height / 2, '#4a9eff', 10);
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();
}
@@ -352,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);
}

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();