Files
jwinterm fad2a695bd Wownerogue
A browser-based roguelike synchronized with crypto block timing. A run is a race
against the next block: reach the exit before the block lands, or the house wins.
The same codebase runs the solo dungeon, the social Tavern, and operator-selected
PvP and race rulesets, with separate free and paid leaderboards.

Self-hostable under the MIT License. See README.md to run it, docs/ARCHITECTURE.md
for the design, and docs/DEPLOY.md to operate an instance.
2026-07-27 08:23:01 -04:00

237 lines
9.0 KiB
JavaScript

/**
* AudioAlerts: optional user-enabled audio notifications for key events
* - Payment confirmed (game about to start soon / queued)
* - Game start (focus attention)
* Persists preference in localStorage and uses Web Audio or HTMLAudio fallback.
*/
const AudioAlerts = {
_enabled: false,
_initialized: false,
_ctx: null,
_gain: null,
_volume: 0.4,
_lastPlay: 0,
_cooldownMs: 1500,
_audioTags: {}, // cache HTMLAudioElements
_audioDurations: {},
_sequentialBufferMs: 600,
_minGameStartDelayMs: 2800,
_gameStartHoldUntil: 0,
_pendingGameStartTimer: null,
_fileMap: {
request_coin: 'audio/Pleasesendcoin.m4a',
payment_detected: 'audio/Paymentdetected.m4a',
payment_confirmed: 'audio/Paymentconfirmed.m4a',
game_start: 'audio/Nowescapethedunge.m4a',
game_escaped: 'audio/Youescaped.m4a',
game_escaped_treasure: 'audio/Youescapedwiththe.m4a',
game_timeout: 'audio/Yourtimeranout.m4a',
game_monster: 'audio/Youwererektbythe.m4a'
},
init() {
if (this._initialized) return;
this._initialized = true;
// Load preference
try {
const saved = localStorage.getItem('wow_audioAlerts');
this._enabled = saved === '1';
} catch(_) {}
const toggle = document.getElementById('audioAlertsToggle');
if (toggle) {
toggle.checked = this._enabled;
toggle.addEventListener('change', () => {
this._enabled = !!toggle.checked;
try { localStorage.setItem('wow_audioAlerts', this._enabled ? '1':'0'); } catch(_) {}
if (this._enabled) this._lazyInitContext();
});
}
// Hook into socket events (if SocketHandlers already patched later)
if (typeof SocketHandlers !== 'undefined') {
this._patchSocketHandlers();
}
},
_patchSocketHandlers() {
if (SocketHandlers._audioAlertsPatched) return; // idempotent
SocketHandlers._audioAlertsPatched = true;
const origPaymentConfirmed = SocketHandlers.onPaymentConfirmed;
const origPaymentDetected = SocketHandlers.onPaymentDetected;
const origGameStart = SocketHandlers.onGameStart;
const origGameOver = SocketHandlers.onGameOver;
const origQueueJoined = SocketHandlers.onQueueJoined;
SocketHandlers.onPaymentConfirmed = function(data) {
if (origPaymentConfirmed) origPaymentConfirmed.call(SocketHandlers, data);
AudioAlerts.playPaymentConfirmed();
};
SocketHandlers.onPaymentDetected = function(data) {
if (origPaymentDetected) origPaymentDetected.call(SocketHandlers, data);
AudioAlerts.playFile('payment_detected');
};
SocketHandlers.onGameStart = function(data) {
if (origGameStart) origGameStart.call(SocketHandlers, data);
AudioAlerts.playGameStart();
};
SocketHandlers.onGameOver = function(data) {
if (origGameOver) origGameOver.call(SocketHandlers, data);
AudioAlerts.playGameOverSound(data);
};
// We'll trigger request_coin when user attempts start but is blocked for payment/credits; handled externally via public method
},
_lazyInitContext() {
if (this._ctx) return;
try {
const AudioCtx = window.AudioContext || window.webkitAudioContext;
if (!AudioCtx) return;
this._ctx = new AudioCtx();
this._gain = this._ctx.createGain();
this._gain.gain.value = this._volume;
this._gain.connect(this._ctx.destination);
} catch (e) {
console.warn('Audio context init failed:', e);
}
},
playPattern(type) {
if (!this._enabled) return;
const now = Date.now();
if (now - this._lastPlay < this._cooldownMs) return; // basic cooldown
this._lastPlay = now;
this._lazyInitContext();
if (!this._ctx) return;
if (this._ctx.state === 'suspended') {
// Attempt resume on user gesture contexts; safe to call
this._ctx.resume().catch(()=>{});
}
// Simple beep patterns
const pattern = type === 'start' ? [440, 660, 880] : [440, 660];
const dur = 0.18;
let t = this._ctx.currentTime + 0.01;
pattern.forEach((freq) => {
const osc = this._ctx.createOscillator();
const gainNode = this._ctx.createGain();
osc.frequency.value = freq;
osc.type = 'sine';
gainNode.gain.setValueAtTime(0.0001, t);
gainNode.gain.linearRampToValueAtTime(this._volume, t + 0.01);
gainNode.gain.exponentialRampToValueAtTime(0.0001, t + dur);
osc.connect(gainNode).connect(this._gain || this._ctx.destination);
osc.start(t);
osc.stop(t + dur + 0.02);
t += dur * 0.5; // slight overlap for chord-like effect
});
}
,
_getAudio(tag) {
if (!this._enabled) return null;
if (!this._audioTags[tag]) {
const src = this._fileMap[tag];
if (!src) return null;
const el = new Audio(src);
el.preload = 'auto';
el.addEventListener('loadedmetadata', () => {
if (isFinite(el.duration) && el.duration > 0) {
this._audioDurations[tag] = Math.ceil(el.duration * 1000);
}
}, { once: true });
this._audioTags[tag] = el;
}
return this._audioTags[tag];
},
playFile(tag, options = {}) {
if (!this._enabled) return null;
const force = !!options.force;
const now = Date.now();
// Per-tag cooldown so different event types can fire back-to-back (e.g. payment_confirmed -> game_start)
if (!this._audioTagTimes) this._audioTagTimes = {}; // tag -> last play timestamp
const lastForTag = this._audioTagTimes[tag] || 0;
const elapsed = now - lastForTag;
const allow = force || elapsed >= this._cooldownMs || (tag === 'game_start' && this._lastTag !== 'game_start');
if (!allow) return null;
this._audioTagTimes[tag] = now;
this._lastTag = tag;
const el = this._getAudio(tag);
if (!el) return null;
try {
el.pause();
el.currentTime = 0;
el.volume = this._volume;
el.play().catch(()=>{});
} catch(_) {}
return el;
},
playPaymentConfirmed() {
if (!this._enabled) return;
const el = this.playFile('payment_confirmed');
const storedDuration = this._audioDurations?.payment_confirmed || 0;
const measuredMs = (el && isFinite(el.duration) && el.duration > 0.2)
? Math.ceil(el.duration * 1000)
: storedDuration;
const holdMs = Math.max(
this._minGameStartDelayMs,
(measuredMs || 0) + this._sequentialBufferMs
);
this._gameStartHoldUntil = Date.now() + holdMs;
},
playGameStart() {
if (!this._enabled) return;
const now = Date.now();
if (this._pendingGameStartTimer) {
clearTimeout(this._pendingGameStartTimer);
this._pendingGameStartTimer = null;
}
const holdUntil = this._gameStartHoldUntil || 0;
if (holdUntil && now < holdUntil) {
const delay = Math.max(0, holdUntil - now);
this._pendingGameStartTimer = setTimeout(() => {
this._pendingGameStartTimer = null;
this.playFile('game_start');
}, delay);
return;
}
this.playFile('game_start');
},
playGameOverSound(gameOverData) {
if (!this._enabled) return;
if (!gameOverData) return;
let reason = (gameOverData.reason || '').toLowerCase();
let soundTag = '';
switch (reason) {
case 'escaped':
soundTag = gameOverData.treasure ? 'game_escaped_treasure' : 'game_escaped';
break;
case 'treasure_escape':
soundTag = 'game_escaped_treasure';
break;
case 'timeout':
soundTag = 'game_timeout';
break;
case 'monster':
case 'monster_caught':
case 'caught':
soundTag = 'game_monster';
break;
default:
if (gameOverData.status === 'won') {
soundTag = gameOverData.treasure ? 'game_escaped_treasure' : 'game_escaped';
} else if (gameOverData.status === 'lost') {
soundTag = reason === 'timeout' ? 'game_timeout' : 'game_monster';
}
}
if (soundTag) {
this.playFile(soundTag, { force: true });
}
},
// Public helper to be invoked when server denies start due to payment/credits
playRequestCoin() { this.playFile('request_coin'); }
};
if (typeof module !== 'undefined' && module.exports) {
module.exports = AudioAlerts;
}