Compare commits
24 Commits
beacon/pol
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 266926ecaf | |||
| 5c83a7e1fd | |||
|
|
416fd907f4 | ||
|
|
2b43a070cc | ||
|
|
9de02fa346 | ||
| 1b7ccedf2e | |||
| 81353edd76 | |||
| 5cfda3ecea | |||
|
|
0ece82b958 | ||
| 16d5f98407 | |||
| 58c55176ae | |||
| 4ee5819398 | |||
|
|
fb5205092b | ||
|
|
eb5d1ae9d9 | ||
|
|
eb2579f1fa | ||
|
|
e85eddb00a | ||
|
|
e6dbe7e077 | ||
| 1d16755f93 | |||
| 324ffddf0c | |||
| 28e68d90c7 | |||
| ac88850535 | |||
|
|
facb1a8d12 | ||
| 9971d5fdff | |||
| 019400f18c |
18
game/npc-logic.js
Normal file
18
game/npc-logic.js
Normal file
@@ -0,0 +1,18 @@
|
||||
|
||||
class NPCStateMachine {
|
||||
constructor(states) {
|
||||
this.states = states;
|
||||
this.current = 'idle';
|
||||
}
|
||||
update(context) {
|
||||
const state = this.states[this.current];
|
||||
for (const transition of state.transitions) {
|
||||
if (transition.condition(context)) {
|
||||
this.current = transition.target;
|
||||
console.log(`NPC transitioned to ${this.current}`);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
export default NPCStateMachine;
|
||||
44
index.html
44
index.html
@@ -46,18 +46,11 @@ body{background:var(--bg);color:var(--text);font-family:'SF Mono','Cascadia Code
|
||||
.res .r-val{font-size:16px;font-weight:700;margin:2px 0;color:var(--accent)}
|
||||
.res .r-rate{font-size:10px;color:var(--green)}
|
||||
#main{display:grid;grid-template-columns:1fr 1fr;gap:12px;margin:0 16px 16px}
|
||||
@media(max-width:700px){
|
||||
#main{grid-template-columns:1fr}
|
||||
.ops-btn{min-height:44px;padding:12px 10px;font-size:12px}
|
||||
.save-btn,.reset-btn{min-height:44px;padding:12px 10px;font-size:12px}
|
||||
.main-btn{min-height:48px;font-size:16px}
|
||||
#ops-btns{flex-wrap:wrap;gap:4px}
|
||||
#ops-btns .ops-btn{flex:1 1 45%}
|
||||
}
|
||||
@media(max-width:700px){#main{grid-template-columns:1fr}}
|
||||
.panel{background:var(--panel);border:1px solid var(--border);border-radius:6px;padding:12px;overflow:hidden;max-height:600px;overflow-y:auto}
|
||||
.panel h2{font-size:12px;font-weight:500;color:var(--accent);margin-bottom:10px;padding-bottom:6px;border-bottom:1px solid var(--border);letter-spacing:1px;position:sticky;top:0;background:var(--panel);z-index:2}
|
||||
.action-btn-group{display:flex;gap:6px;margin-bottom:8px}
|
||||
.action-btn-group button{flex:1;text-align:center;font-weight:700;touch-action:manipulation;-webkit-tap-highlight-color:transparent}
|
||||
.action-btn-group button{flex:1;text-align:center;font-weight:700}
|
||||
.main-btn{background:linear-gradient(135deg,#1a2a3a,#0e1520);border:1px solid var(--accent);color:var(--accent);font-size:14px;padding:14px 10px;border-radius:4px;cursor:pointer;font-family:inherit;transition:all 0.2s}
|
||||
.main-btn:hover{background:linear-gradient(135deg,#203040,#0e2030);box-shadow:0 0 20px var(--glow);transform:scale(1.02)}
|
||||
.main-btn:active{transform:scale(0.98)}
|
||||
@@ -102,17 +95,7 @@ body{background:var(--bg);color:var(--text);font-family:'SF Mono','Cascadia Code
|
||||
.toast-info{background:rgba(74,158,255,0.12);border-color:#4a9eff;color:#80bfff}
|
||||
@keyframes toast-in{from{transform:translateX(40px);opacity:0}to{transform:translateX(0);opacity:0.95}}
|
||||
@keyframes toast-out{from{opacity:0.95;transform:translateX(0)}to{opacity:0;transform:translateX(40px)}}
|
||||
@keyframes res-pulse{0%{transform:scale(1)}50%{transform:scale(1.18);color:#80d0ff}100%{transform:scale(1)}}
|
||||
.particle{position:fixed;pointer-events:none;z-index:150;border-radius:50%;animation:particle-fly 0.6s ease-out forwards}
|
||||
@keyframes particle-fly{0%{opacity:1;transform:translate(0,0) scale(1)}100%{opacity:0;transform:translate(var(--px),var(--py)) scale(0.2)}}
|
||||
@keyframes res-shake{0%,100%{transform:translateX(0)}20%{transform:translateX(-3px)}40%{transform:translateX(3px)}60%{transform:translateX(-2px)}80%{transform:translateX(2px)}}
|
||||
.r-val.pulse{animation:res-pulse 0.35s ease-out}
|
||||
.r-val.shake{animation:res-shake 0.3s ease-out;color:var(--red)!important}
|
||||
::-webkit-scrollbar{width:4px}::-webkit-scrollbar-track{background:var(--bg)}::-webkit-scrollbar-thumb{background:var(--border);border-radius:2px}
|
||||
#custom-tooltip{position:fixed;pointer-events:none;z-index:300;max-width:280px;padding:8px 12px;background:#0e1420;border:1px solid #1a3a5a;border-radius:6px;font-size:10px;color:#80bfff;line-height:1.5;opacity:0;transition:opacity 0.15s;box-shadow:0 4px 16px rgba(0,0,0,0.5)}
|
||||
#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}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
@@ -124,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">
|
||||
@@ -147,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">
|
||||
@@ -199,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>
|
||||
@@ -208,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">
|
||||
@@ -224,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>
|
||||
@@ -240,28 +221,27 @@ 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>
|
||||
<script src="js/utils.js"></script>
|
||||
<script src="js/strategy.js"></script>
|
||||
<script src="js/sound.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>
|
||||
|
||||
<div id="toast-container"></div>
|
||||
<div id="custom-tooltip"></div>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
182
js/engine.js
182
js/engine.js
@@ -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)) {
|
||||
@@ -239,14 +283,25 @@ function checkMilestones() {
|
||||
G.milestones.push(m.flag);
|
||||
log(m.msg, true);
|
||||
showToast(m.msg, 'milestone', 5000);
|
||||
if (typeof Sound !== 'undefined') Sound.playMilestone();
|
||||
|
||||
// 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);
|
||||
if (typeof Sound !== 'undefined') {
|
||||
Sound.playFanfare();
|
||||
Sound.updateAmbientPhase(pNum);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -302,12 +357,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 (typeof Sound !== 'undefined') Sound.playBuild();
|
||||
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();
|
||||
}
|
||||
@@ -336,6 +402,7 @@ function buyProject(id) {
|
||||
|
||||
updateRates();
|
||||
// Gold particle burst on project completion
|
||||
if (typeof Sound !== 'undefined') Sound.playProject();
|
||||
const pBtn = document.querySelector('[onclick="buyProject(\'' + id + '\')"]');
|
||||
if (pBtn) {
|
||||
const rect = pBtn.getBoundingClientRect();
|
||||
@@ -352,40 +419,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);
|
||||
if (typeof Sound !== 'undefined') Sound.playDriftEnding();
|
||||
|
||||
// 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);
|
||||
if (typeof Sound !== 'undefined') Sound.playBeaconEnding();
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -626,6 +769,7 @@ function writeCode() {
|
||||
}
|
||||
// Float a number at the click position
|
||||
showClickNumber(amount, comboMult);
|
||||
if (typeof Sound !== 'undefined') Sound.playClick();
|
||||
updateRates();
|
||||
checkMilestones();
|
||||
render();
|
||||
|
||||
59
js/main.js
59
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) {
|
||||
@@ -40,6 +42,18 @@ window.addEventListener('load', function () {
|
||||
// Game loop at 10Hz (100ms tick)
|
||||
setInterval(tick, 100);
|
||||
|
||||
// Start ambient drone on first interaction
|
||||
if (typeof Sound !== 'undefined') {
|
||||
const startAmbientOnce = () => {
|
||||
Sound.startAmbient();
|
||||
Sound.updateAmbientPhase(G.phase);
|
||||
document.removeEventListener('click', startAmbientOnce);
|
||||
document.removeEventListener('keydown', startAmbientOnce);
|
||||
};
|
||||
document.addEventListener('click', startAmbientOnce);
|
||||
document.addEventListener('keydown', startAmbientOnce);
|
||||
}
|
||||
|
||||
// Auto-save every 30 seconds
|
||||
setInterval(saveGame, CONFIG.AUTO_SAVE_INTERVAL);
|
||||
|
||||
@@ -55,6 +69,49 @@ 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) {}
|
||||
if (typeof Sound !== 'undefined') Sound.onMuteChanged(_muted);
|
||||
}
|
||||
// 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 +143,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();
|
||||
|
||||
401
js/sound.js
Normal file
401
js/sound.js
Normal file
@@ -0,0 +1,401 @@
|
||||
// ============================================================
|
||||
// THE BEACON - Sound Engine
|
||||
// Procedural audio via Web Audio API (no audio files)
|
||||
// ============================================================
|
||||
|
||||
const Sound = (function () {
|
||||
let ctx = null;
|
||||
let masterGain = null;
|
||||
let ambientGain = null;
|
||||
let ambientOsc1 = null;
|
||||
let ambientOsc2 = null;
|
||||
let ambientOsc3 = null;
|
||||
let ambientLfo = null;
|
||||
let ambientStarted = false;
|
||||
let currentPhase = 0;
|
||||
|
||||
function ensureCtx() {
|
||||
if (!ctx) {
|
||||
ctx = new (window.AudioContext || window.webkitAudioContext)();
|
||||
masterGain = ctx.createGain();
|
||||
masterGain.gain.value = 0.3;
|
||||
masterGain.connect(ctx.destination);
|
||||
}
|
||||
if (ctx.state === 'suspended') {
|
||||
ctx.resume();
|
||||
}
|
||||
return ctx;
|
||||
}
|
||||
|
||||
function isMuted() {
|
||||
return typeof _muted !== 'undefined' && _muted;
|
||||
}
|
||||
|
||||
// --- Noise buffer helper ---
|
||||
function createNoiseBuffer(duration) {
|
||||
const c = ensureCtx();
|
||||
const len = c.sampleRate * duration;
|
||||
const buf = c.createBuffer(1, len, c.sampleRate);
|
||||
const data = buf.getChannelData(0);
|
||||
for (let i = 0; i < len; i++) {
|
||||
data[i] = Math.random() * 2 - 1;
|
||||
}
|
||||
return buf;
|
||||
}
|
||||
|
||||
// --- playClick: mechanical keyboard sound ---
|
||||
function playClick() {
|
||||
if (isMuted()) return;
|
||||
const c = ensureCtx();
|
||||
const now = c.currentTime;
|
||||
|
||||
// Short noise burst (mechanical key)
|
||||
const noise = c.createBufferSource();
|
||||
noise.buffer = createNoiseBuffer(0.03);
|
||||
|
||||
const noiseGain = c.createGain();
|
||||
noiseGain.gain.setValueAtTime(0.4, now);
|
||||
noiseGain.gain.exponentialRampToValueAtTime(0.001, now + 0.03);
|
||||
|
||||
const hpFilter = c.createBiquadFilter();
|
||||
hpFilter.type = 'highpass';
|
||||
hpFilter.frequency.value = 3000;
|
||||
|
||||
noise.connect(hpFilter);
|
||||
hpFilter.connect(noiseGain);
|
||||
noiseGain.connect(masterGain);
|
||||
noise.start(now);
|
||||
noise.stop(now + 0.03);
|
||||
|
||||
// Click tone
|
||||
const osc = c.createOscillator();
|
||||
osc.type = 'square';
|
||||
osc.frequency.setValueAtTime(1800, now);
|
||||
osc.frequency.exponentialRampToValueAtTime(600, now + 0.02);
|
||||
|
||||
const oscGain = c.createGain();
|
||||
oscGain.gain.setValueAtTime(0.15, now);
|
||||
oscGain.gain.exponentialRampToValueAtTime(0.001, now + 0.025);
|
||||
|
||||
osc.connect(oscGain);
|
||||
oscGain.connect(masterGain);
|
||||
osc.start(now);
|
||||
osc.stop(now + 0.03);
|
||||
}
|
||||
|
||||
// --- playBuild: purchase thud + chime ---
|
||||
function playBuild() {
|
||||
if (isMuted()) return;
|
||||
const c = ensureCtx();
|
||||
const now = c.currentTime;
|
||||
|
||||
// Low thud
|
||||
const thud = c.createOscillator();
|
||||
thud.type = 'sine';
|
||||
thud.frequency.setValueAtTime(150, now);
|
||||
thud.frequency.exponentialRampToValueAtTime(60, now + 0.12);
|
||||
|
||||
const thudGain = c.createGain();
|
||||
thudGain.gain.setValueAtTime(0.35, now);
|
||||
thudGain.gain.exponentialRampToValueAtTime(0.001, now + 0.15);
|
||||
|
||||
thud.connect(thudGain);
|
||||
thudGain.connect(masterGain);
|
||||
thud.start(now);
|
||||
thud.stop(now + 0.15);
|
||||
|
||||
// Bright chime on top
|
||||
const chime = c.createOscillator();
|
||||
chime.type = 'sine';
|
||||
chime.frequency.setValueAtTime(880, now + 0.05);
|
||||
chime.frequency.exponentialRampToValueAtTime(1200, now + 0.2);
|
||||
|
||||
const chimeGain = c.createGain();
|
||||
chimeGain.gain.setValueAtTime(0, now);
|
||||
chimeGain.gain.linearRampToValueAtTime(0.2, now + 0.06);
|
||||
chimeGain.gain.exponentialRampToValueAtTime(0.001, now + 0.25);
|
||||
|
||||
chime.connect(chimeGain);
|
||||
chimeGain.connect(masterGain);
|
||||
chime.start(now + 0.05);
|
||||
chime.stop(now + 0.25);
|
||||
}
|
||||
|
||||
// --- playProject: ascending chime ---
|
||||
function playProject() {
|
||||
if (isMuted()) return;
|
||||
const c = ensureCtx();
|
||||
const now = c.currentTime;
|
||||
|
||||
const notes = [523, 659, 784]; // C5, E5, G5
|
||||
notes.forEach((freq, i) => {
|
||||
const osc = c.createOscillator();
|
||||
osc.type = 'sine';
|
||||
osc.frequency.value = freq;
|
||||
|
||||
const gain = c.createGain();
|
||||
const t = now + i * 0.1;
|
||||
gain.gain.setValueAtTime(0, t);
|
||||
gain.gain.linearRampToValueAtTime(0.22, t + 0.03);
|
||||
gain.gain.exponentialRampToValueAtTime(0.001, t + 0.35);
|
||||
|
||||
osc.connect(gain);
|
||||
gain.connect(masterGain);
|
||||
osc.start(t);
|
||||
osc.stop(t + 0.35);
|
||||
});
|
||||
}
|
||||
|
||||
// --- playMilestone: bright arpeggio ---
|
||||
function playMilestone() {
|
||||
if (isMuted()) return;
|
||||
const c = ensureCtx();
|
||||
const now = c.currentTime;
|
||||
|
||||
const notes = [523, 659, 784, 1047]; // C5, E5, G5, C6
|
||||
notes.forEach((freq, i) => {
|
||||
const osc = c.createOscillator();
|
||||
osc.type = 'triangle';
|
||||
osc.frequency.value = freq;
|
||||
|
||||
const gain = c.createGain();
|
||||
const t = now + i * 0.08;
|
||||
gain.gain.setValueAtTime(0, t);
|
||||
gain.gain.linearRampToValueAtTime(0.25, t + 0.02);
|
||||
gain.gain.exponentialRampToValueAtTime(0.001, t + 0.4);
|
||||
|
||||
osc.connect(gain);
|
||||
gain.connect(masterGain);
|
||||
osc.start(t);
|
||||
osc.stop(t + 0.4);
|
||||
});
|
||||
}
|
||||
|
||||
// --- playFanfare: 8-note scale for phase transitions ---
|
||||
function playFanfare() {
|
||||
if (isMuted()) return;
|
||||
const c = ensureCtx();
|
||||
const now = c.currentTime;
|
||||
|
||||
const scale = [262, 294, 330, 349, 392, 440, 494, 523]; // C4 to C5
|
||||
scale.forEach((freq, i) => {
|
||||
const osc = c.createOscillator();
|
||||
osc.type = 'sawtooth';
|
||||
osc.frequency.value = freq;
|
||||
|
||||
const filter = c.createBiquadFilter();
|
||||
filter.type = 'lowpass';
|
||||
filter.frequency.value = 2000;
|
||||
|
||||
const gain = c.createGain();
|
||||
const t = now + i * 0.1;
|
||||
gain.gain.setValueAtTime(0, t);
|
||||
gain.gain.linearRampToValueAtTime(0.15, t + 0.03);
|
||||
gain.gain.exponentialRampToValueAtTime(0.001, t + 0.3);
|
||||
|
||||
osc.connect(filter);
|
||||
filter.connect(gain);
|
||||
gain.connect(masterGain);
|
||||
osc.start(t);
|
||||
osc.stop(t + 0.3);
|
||||
});
|
||||
|
||||
// Final chord
|
||||
const chordNotes = [523, 659, 784];
|
||||
chordNotes.forEach((freq) => {
|
||||
const osc = c.createOscillator();
|
||||
osc.type = 'sine';
|
||||
osc.frequency.value = freq;
|
||||
|
||||
const gain = c.createGain();
|
||||
const t = now + 0.8;
|
||||
gain.gain.setValueAtTime(0, t);
|
||||
gain.gain.linearRampToValueAtTime(0.2, t + 0.05);
|
||||
gain.gain.exponentialRampToValueAtTime(0.001, t + 1.2);
|
||||
|
||||
osc.connect(gain);
|
||||
gain.connect(masterGain);
|
||||
osc.start(t);
|
||||
osc.stop(t + 1.2);
|
||||
});
|
||||
}
|
||||
|
||||
// --- playDriftEnding: descending dissonance ---
|
||||
function playDriftEnding() {
|
||||
if (isMuted()) return;
|
||||
const c = ensureCtx();
|
||||
const now = c.currentTime;
|
||||
|
||||
const notes = [440, 415, 392, 370, 349, 330, 311, 294]; // A4 descending, slightly detuned
|
||||
notes.forEach((freq, i) => {
|
||||
const osc = c.createOscillator();
|
||||
osc.type = 'sawtooth';
|
||||
osc.frequency.value = freq;
|
||||
|
||||
// Slight detune for dissonance
|
||||
const osc2 = c.createOscillator();
|
||||
osc2.type = 'sawtooth';
|
||||
osc2.frequency.value = freq * 1.02;
|
||||
|
||||
const filter = c.createBiquadFilter();
|
||||
filter.type = 'lowpass';
|
||||
filter.frequency.setValueAtTime(1500, now + i * 0.2);
|
||||
filter.frequency.exponentialRampToValueAtTime(200, now + i * 0.2 + 0.5);
|
||||
|
||||
const gain = c.createGain();
|
||||
const t = now + i * 0.2;
|
||||
gain.gain.setValueAtTime(0, t);
|
||||
gain.gain.linearRampToValueAtTime(0.1, t + 0.05);
|
||||
gain.gain.exponentialRampToValueAtTime(0.001, t + 0.8);
|
||||
|
||||
osc.connect(filter);
|
||||
osc2.connect(filter);
|
||||
filter.connect(gain);
|
||||
gain.connect(masterGain);
|
||||
osc.start(t);
|
||||
osc.stop(t + 0.8);
|
||||
osc2.start(t);
|
||||
osc2.stop(t + 0.8);
|
||||
});
|
||||
}
|
||||
|
||||
// --- playBeaconEnding: warm chord ---
|
||||
function playBeaconEnding() {
|
||||
if (isMuted()) return;
|
||||
const c = ensureCtx();
|
||||
const now = c.currentTime;
|
||||
|
||||
// Warm major chord: C3, E3, G3, C4, E4
|
||||
const chord = [131, 165, 196, 262, 330];
|
||||
chord.forEach((freq, i) => {
|
||||
const osc = c.createOscillator();
|
||||
osc.type = 'sine';
|
||||
osc.frequency.value = freq;
|
||||
|
||||
// Add subtle harmonics
|
||||
const osc2 = c.createOscillator();
|
||||
osc2.type = 'sine';
|
||||
osc2.frequency.value = freq * 2;
|
||||
|
||||
const gain = c.createGain();
|
||||
const gain2 = c.createGain();
|
||||
const t = now + i * 0.15;
|
||||
gain.gain.setValueAtTime(0, t);
|
||||
gain.gain.linearRampToValueAtTime(0.15, t + 0.3);
|
||||
gain.gain.setValueAtTime(0.15, t + 2);
|
||||
gain.gain.exponentialRampToValueAtTime(0.001, t + 4);
|
||||
|
||||
gain2.gain.setValueAtTime(0, t);
|
||||
gain2.gain.linearRampToValueAtTime(0.05, t + 0.3);
|
||||
gain2.gain.setValueAtTime(0.05, t + 2);
|
||||
gain2.gain.exponentialRampToValueAtTime(0.001, t + 4);
|
||||
|
||||
osc.connect(gain);
|
||||
osc2.connect(gain2);
|
||||
gain.connect(masterGain);
|
||||
gain2.connect(masterGain);
|
||||
osc.start(t);
|
||||
osc.stop(t + 4);
|
||||
osc2.start(t);
|
||||
osc2.stop(t + 4);
|
||||
});
|
||||
}
|
||||
|
||||
// --- Ambient drone system ---
|
||||
function startAmbient() {
|
||||
if (ambientStarted) return;
|
||||
if (isMuted()) return;
|
||||
const c = ensureCtx();
|
||||
ambientStarted = true;
|
||||
|
||||
ambientGain = c.createGain();
|
||||
ambientGain.gain.value = 0;
|
||||
ambientGain.gain.linearRampToValueAtTime(0.06, c.currentTime + 3);
|
||||
ambientGain.connect(masterGain);
|
||||
|
||||
// Base drone
|
||||
ambientOsc1 = c.createOscillator();
|
||||
ambientOsc1.type = 'sine';
|
||||
ambientOsc1.frequency.value = 55; // A1
|
||||
ambientOsc1.connect(ambientGain);
|
||||
ambientOsc1.start();
|
||||
|
||||
// Second voice (fifth above)
|
||||
ambientOsc2 = c.createOscillator();
|
||||
ambientOsc2.type = 'sine';
|
||||
ambientOsc2.frequency.value = 82.4; // E2
|
||||
const g2 = c.createGain();
|
||||
g2.gain.value = 0.5;
|
||||
ambientOsc2.connect(g2);
|
||||
g2.connect(ambientGain);
|
||||
ambientOsc2.start();
|
||||
|
||||
// Third voice (high shimmer)
|
||||
ambientOsc3 = c.createOscillator();
|
||||
ambientOsc3.type = 'triangle';
|
||||
ambientOsc3.frequency.value = 220; // A3
|
||||
const g3 = c.createGain();
|
||||
g3.gain.value = 0.15;
|
||||
ambientOsc3.connect(g3);
|
||||
g3.connect(ambientGain);
|
||||
ambientOsc3.start();
|
||||
|
||||
// LFO for subtle movement
|
||||
ambientLfo = c.createOscillator();
|
||||
ambientLfo.type = 'sine';
|
||||
ambientLfo.frequency.value = 0.2;
|
||||
const lfoGain = c.createGain();
|
||||
lfoGain.gain.value = 3;
|
||||
ambientLfo.connect(lfoGain);
|
||||
lfoGain.connect(ambientOsc1.frequency);
|
||||
ambientLfo.start();
|
||||
}
|
||||
|
||||
function updateAmbientPhase(phase) {
|
||||
if (!ambientStarted || !ambientOsc1 || !ambientOsc2 || !ambientOsc3) return;
|
||||
if (phase === currentPhase) return;
|
||||
currentPhase = phase;
|
||||
const c = ensureCtx();
|
||||
const now = c.currentTime;
|
||||
const rampTime = 2;
|
||||
|
||||
// Phase determines the drone's character
|
||||
const phases = {
|
||||
1: { base: 55, fifth: 82.4, shimmer: 220, shimmerVol: 0.15 },
|
||||
2: { base: 65.4, fifth: 98, shimmer: 262, shimmerVol: 0.2 },
|
||||
3: { base: 73.4, fifth: 110, shimmer: 294, shimmerVol: 0.25 },
|
||||
4: { base: 82.4, fifth: 123.5, shimmer: 330, shimmerVol: 0.3 },
|
||||
5: { base: 98, fifth: 147, shimmer: 392, shimmerVol: 0.35 },
|
||||
6: { base: 110, fifth: 165, shimmer: 440, shimmerVol: 0.4 }
|
||||
};
|
||||
|
||||
const p = phases[phase] || phases[1];
|
||||
ambientOsc1.frequency.linearRampToValueAtTime(p.base, now + rampTime);
|
||||
ambientOsc2.frequency.linearRampToValueAtTime(p.fifth, now + rampTime);
|
||||
ambientOsc3.frequency.linearRampToValueAtTime(p.shimmer, now + rampTime);
|
||||
}
|
||||
|
||||
// --- Mute integration ---
|
||||
function onMuteChanged(muted) {
|
||||
if (ambientGain) {
|
||||
ambientGain.gain.linearRampToValueAtTime(
|
||||
muted ? 0 : 0.06,
|
||||
(ctx ? ctx.currentTime : 0) + 0.3
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Public API
|
||||
return {
|
||||
playClick,
|
||||
playBuild,
|
||||
playProject,
|
||||
playMilestone,
|
||||
playFanfare,
|
||||
playDriftEnding,
|
||||
playBeaconEnding,
|
||||
startAmbient,
|
||||
updateAmbientPhase,
|
||||
onMuteChanged
|
||||
};
|
||||
})();
|
||||
26
scripts/guardrails.js
Normal file
26
scripts/guardrails.js
Normal file
@@ -0,0 +1,26 @@
|
||||
|
||||
/**
|
||||
* Symbolic Guardrails for The Beacon
|
||||
* Ensures game logic consistency.
|
||||
*/
|
||||
class Guardrails {
|
||||
static validateStats(stats) {
|
||||
const required = ['hp', 'maxHp', 'mp', 'maxMp', 'level'];
|
||||
required.forEach(r => {
|
||||
if (!(r in stats)) throw new Error(`Missing stat: ${r}`);
|
||||
});
|
||||
if (stats.hp > stats.maxHp) return { valid: false, reason: 'HP exceeds MaxHP' };
|
||||
return { valid: true };
|
||||
}
|
||||
|
||||
static validateDebuff(debuff, stats) {
|
||||
if (debuff.type === 'drain' && stats.hp <= 1) {
|
||||
return { valid: false, reason: 'Drain debuff on critical HP' };
|
||||
}
|
||||
return { valid: true };
|
||||
}
|
||||
}
|
||||
|
||||
// Test
|
||||
const playerStats = { hp: 50, maxHp: 100, mp: 20, maxMp: 50, level: 1 };
|
||||
console.log('Stats check:', Guardrails.validateStats(playerStats));
|
||||
Reference in New Issue
Block a user