mirror of
https://github.com/Such-Software/wownerogue.git
synced 2026-10-18 06:41:27 +02:00
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.
237 lines
9.0 KiB
JavaScript
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;
|
|
} |