/* * Haunted House — exhibit behaviour. * * Owns the room model, the haunt simulation, the canvas rendering, and the * native controls. No contract awareness. * * This exhibit is the one that exercises the capability lifecycle honestly: * its audio is synthesized with the Web Audio API, which browsers refuse to * start until the user has interacted with the page. So `audio` really does * begin `available`, really does go `loading` and then `ready` on the first * gesture, and really does report `error` if the context cannot be created. * Nothing here fakes a transition to make the contract look busy. */ (function () { 'use strict'; var TAU = Math.PI * 2; var ROOMS = [ { id: 'foyer', label: 'Foyer', width: 1.0, damp: 0.35, hue: '#6b5a3e' }, { id: 'library', label: 'Library', width: 0.9, damp: 0.6, hue: '#5a4630' }, { id: 'cellar', label: 'Cellar', width: 0.8, damp: 0.85, hue: '#3a3a44' }, { id: 'attic', label: 'Attic', width: 0.7, damp: 0.5, hue: '#4a4038' }, { id: 'nursery', label: 'Nursery', width: 0.75, damp: 0.4, hue: '#5c4a5a' } ]; var SPIRITS = [ { id: 'restless', label: 'Restless', energy: 0.35, color: '#9fd8ff' }, { id: 'vengeful', label: 'Vengeful', energy: 0.75, color: '#ff8a6a' }, { id: 'mourning', label: 'Mourning', energy: 0.5, color: '#b9a6ff' }, { id: 'mischievous', label: 'Mischievous', energy: 0.6, color: '#8affc4' } ]; var MAX_FOG = 1.0; function clamp(v, lo, hi) { return v < lo ? lo : (v > hi ? hi : v); } function findById(list, id) { for (var i = 0; i < list.length; i++) if (list[i].id === id) return list[i]; return null; } function hash(n) { var x = Math.sin(n * 91.7 + 47.3) * 43758.5453; return x - Math.floor(x); } /* ------------------------------------------------------------------ * * Room model * ------------------------------------------------------------------ */ function House() { this.room = 'foyer'; this.spirit = 'restless'; this.hauntLevel = 0.3; this.fogDensity = 0.25; this.candlelight = 0.55; this.audioEnabled = true; this.paused = false; this.time = 0; this.embers = []; this.dust = []; this._seed(); } House.prototype._seed = function () { this.embers = []; for (var i = 0; i < 18; i++) { this.embers.push({ x: hash(i * 1.3), y: hash(i * 2.7), vy: -(0.01 + hash(i * 3.9) * 0.03), phase: hash(i * 5.1) * TAU }); } this.dust = []; for (var d = 0; d < 60; d++) { this.dust.push({ x: hash(d * 7.7), y: hash(d * 8.3), vx: (hash(d * 9.1) - 0.5) * 0.01, vy: (hash(d * 11.3) - 0.5) * 0.006, r: 0.4 + hash(d * 13.7) * 1.2 }); } } House.prototype.step = function (dt) { if (this.paused) return; this.time += dt; var spirit = findById(SPIRITS, this.spirit); var room = findById(ROOMS, this.room); /* The haunt level drifts toward the spirit's natural energy, damped by * the room. This is what makes the room and spirit selections matter * rather than being cosmetic. */ var target = spirit.energy * (1 - room.damp * 0.5); this.hauntLevel += (target - this.hauntLevel) * dt * 0.35; for (var i = 0; i < this.embers.length; i++) { var e = this.embers[i]; e.y += e.vy * dt * (0.6 + this.hauntLevel); e.phase += dt * 2.2; if (e.y < -0.05) { e.y = 1.05; e.x = hash(i + this.time); } } for (var d = 0; d < this.dust.length; d++) { var p = this.dust[d]; p.x += p.vx * dt * (0.5 + this.hauntLevel * 2); p.y += p.vy * dt; if (p.x < 0) p.x = 1; if (p.x > 1) p.x = 0; if (p.y < 0) p.y = 1; if (p.y > 1) p.y = 0; } }; /* ------------------------------------------------------------------ * * Audio service * * A real Web Audio drone, and a real capability lifecycle. The context is * created lazily on the first user gesture because browsers require it. * ------------------------------------------------------------------ */ function AudioService() { this.ctx = null; this.master = null; this.oscillators = []; this.state = 'available'; this.onStateChange = function () {}; } AudioService.prototype._setState = function (state) { if (this.state === state) return; this.state = state; this.onStateChange(state); }; /** Called from a real user gesture. */ AudioService.prototype.prepare = function () { if (this.ctx) { if (this.ctx.state === 'suspended') this.ctx.resume(); this._setState('ready'); return true; } this._setState('loading'); try { var Ctor = window.AudioContext || window.webkitAudioContext; if (!Ctor) { this._setState('error'); return false; } this.ctx = new Ctor(); this.master = this.ctx.createGain(); this.master.gain.value = 0.0; this.master.connect(this.ctx.destination); this._buildDrone(); this._setState('ready'); return true; } catch (err) { this.ctx = null; this._setState('error'); return false; } }; AudioService.prototype._buildDrone = function () { var ctx = this.ctx; var freqs = [55, 82.5, 110, 164.8]; for (var i = 0; i < freqs.length; i++) { var osc = ctx.createOscillator(); osc.type = i % 2 === 0 ? 'sine' : 'triangle'; osc.frequency.value = freqs[i]; var gain = ctx.createGain(); gain.gain.value = 0.12 / (i + 1); osc.connect(gain); gain.connect(this.master); osc.start(); this.oscillators.push({ osc: osc, gain: gain, base: freqs[i] }); } }; /** Absolute, idempotent: sets the drone's intensity, not a delta. */ AudioService.prototype.setIntensity = function (value) { if (!this.ctx || !this.master) return; var now = this.ctx.currentTime; this.master.gain.cancelScheduledValues(now); this.master.gain.setTargetAtTime(value * 0.5, now, 0.4); for (var i = 0; i < this.oscillators.length; i++) { var o = this.oscillators[i]; o.osc.frequency.setTargetAtTime(o.base * (1 + value * 0.35), now, 0.6); } }; AudioService.prototype.moan = function () { if (!this.ctx || !this.master) return false; var ctx = this.ctx; var now = ctx.currentTime; var osc = ctx.createOscillator(); var gain = ctx.createGain(); var filter = ctx.createBiquadFilter(); filter.type = 'bandpass'; filter.frequency.value = 320; filter.Q.value = 6; osc.type = 'sawtooth'; osc.frequency.setValueAtTime(180, now); osc.frequency.exponentialRampToValueAtTime(90, now + 1.6); gain.gain.setValueAtTime(0.0001, now); gain.gain.exponentialRampToValueAtTime(0.22, now + 0.25); gain.gain.exponentialRampToValueAtTime(0.0001, now + 1.8); osc.connect(filter); filter.connect(gain); gain.connect(this.master); osc.start(now); osc.stop(now + 1.9); return true; }; AudioService.prototype.slam = function () { if (!this.ctx || !this.master) return false; var ctx = this.ctx; var now = ctx.currentTime; var bufferSize = Math.floor(ctx.sampleRate * 0.5); var buffer = ctx.createBuffer(1, bufferSize, ctx.sampleRate); var data = buffer.getChannelData(0); for (var i = 0; i < bufferSize; i++) { data[i] = (Math.random() * 2 - 1) * Math.pow(1 - i / bufferSize, 3); } var src = ctx.createBufferSource(); src.buffer = buffer; var filter = ctx.createBiquadFilter(); filter.type = 'lowpass'; filter.frequency.value = 420; var gain = ctx.createGain(); gain.gain.value = 0.5; src.connect(filter); filter.connect(gain); gain.connect(this.master); src.start(now); return true; }; AudioService.prototype.stop = function () { if (!this.ctx) return; for (var i = 0; i < this.oscillators.length; i++) { try { this.oscillators[i].osc.stop(); } catch (e) { /* already stopped */ } } this.oscillators = []; try { this.ctx.close(); } catch (e) { /* already closed */ } this.ctx = null; this.master = null; this._setState('available'); }; /* ------------------------------------------------------------------ * * Rendering * ------------------------------------------------------------------ */ function Renderer(canvas, house) { this.canvas = canvas; this.ctx = canvas.getContext('2d'); this.house = house; this.width = 0; this.height = 0; this.resize(); } Renderer.prototype.resize = function () { var rect = this.canvas.getBoundingClientRect(); var dpr = window.devicePixelRatio || 1; this.width = Math.max(1, Math.round(rect.width)); this.height = Math.max(1, Math.round(rect.height)); this.canvas.width = Math.round(this.width * dpr); this.canvas.height = Math.round(this.height * dpr); this.ctx.setTransform(dpr, 0, 0, dpr, 0, 0); }; Renderer.prototype.draw = function () { var ctx = this.ctx; var w = this.width; var h = this.height; var house = this.house; var room = findById(ROOMS, house.room); var spirit = findById(SPIRITS, house.spirit); var light = house.candlelight; ctx.clearRect(0, 0, w, h); /* Room shell. */ var grad = ctx.createLinearGradient(0, 0, 0, h); grad.addColorStop(0, shade(room.hue, 0.35 + light * 0.5)); grad.addColorStop(1, shade(room.hue, 0.12 + light * 0.2)); ctx.fillStyle = grad; ctx.fillRect(0, 0, w, h); /* Back wall panelling. */ ctx.strokeStyle = 'rgba(0,0,0,0.28)'; ctx.lineWidth = 1; for (var x = 0; x < w; x += 46) { ctx.beginPath(); ctx.moveTo(x, 0); ctx.lineTo(x, h); ctx.stroke(); } /* Window, brighter with candlelight. */ var winW = w * 0.22; var winH = h * 0.3; var winX = w * 0.62; var winY = h * 0.16; ctx.fillStyle = 'rgba(120,150,190,' + (0.1 + light * 0.35) + ')'; ctx.fillRect(winX, winY, winW, winH); ctx.strokeStyle = 'rgba(0,0,0,0.5)'; ctx.lineWidth = 3; ctx.strokeRect(winX, winY, winW, winH); ctx.beginPath(); ctx.moveTo(winX + winW / 2, winY); ctx.lineTo(winX + winW / 2, winY + winH); ctx.moveTo(winX, winY + winH / 2); ctx.lineTo(winX + winW, winY + winH / 2); ctx.stroke(); /* Floor. */ ctx.fillStyle = shade(room.hue, 0.08 + light * 0.12); ctx.beginPath(); ctx.moveTo(0, h); ctx.lineTo(w * 0.12, h * 0.72); ctx.lineTo(w * 0.88, h * 0.72); ctx.lineTo(w, h); ctx.closePath(); ctx.fill(); /* Candle pool of light. */ var pool = ctx.createRadialGradient(w * 0.3, h * 0.62, 0, w * 0.3, h * 0.62, Math.min(w, h) * 0.55); pool.addColorStop(0, 'rgba(255,196,110,' + (0.28 * light) + ')'); pool.addColorStop(1, 'rgba(255,196,110,0)'); ctx.fillStyle = pool; ctx.fillRect(0, 0, w, h); /* Fog. */ if (house.fogDensity > 0.01) { ctx.save(); ctx.globalAlpha = clamp(house.fogDensity * 0.5, 0, 0.6); for (var f = 0; f < 5; f++) { var fy = h * (0.55 + f * 0.09); var fx = ((house.time * (0.01 + f * 0.004)) % 1) * w; var fogGrad = ctx.createRadialGradient(fx, fy, 0, fx, fy, w * 0.4); fogGrad.addColorStop(0, 'rgba(190,200,215,0.5)'); fogGrad.addColorStop(1, 'rgba(190,200,215,0)'); ctx.fillStyle = fogGrad; ctx.fillRect(0, fy - h * 0.2, w, h * 0.4); } ctx.restore(); } /* The apparition. Its opacity and size follow the haunt level, so the * haunt level is visible rather than merely reported. */ var apparitionAlpha = clamp(house.hauntLevel * 0.85, 0, 0.85); if (apparitionAlpha > 0.02) { var ax = w * (0.5 + Math.sin(house.time * 0.35) * 0.12); var ay = h * (0.42 + Math.sin(house.time * 0.21) * 0.05); var ar = Math.min(w, h) * (0.1 + house.hauntLevel * 0.12); var aura = ctx.createRadialGradient(ax, ay, 0, ax, ay, ar * 2.4); aura.addColorStop(0, hexToRgba(spirit.color, apparitionAlpha * 0.75)); aura.addColorStop(1, hexToRgba(spirit.color, 0)); ctx.fillStyle = aura; ctx.beginPath(); ctx.arc(ax, ay, ar * 2.4, 0, TAU); ctx.fill(); ctx.fillStyle = hexToRgba(spirit.color, apparitionAlpha); ctx.beginPath(); ctx.ellipse(ax, ay, ar * 0.55, ar, 0, 0, TAU); ctx.fill(); /* Trailing veil. */ ctx.strokeStyle = hexToRgba(spirit.color, apparitionAlpha * 0.5); ctx.lineWidth = 2; ctx.beginPath(); for (var t = 0; t <= 1; t += 0.05) { var tx = ax + Math.sin(house.time * 1.4 + t * 6) * ar * 0.5; var ty = ay + ar + t * ar * 1.6; if (t === 0) ctx.moveTo(tx, ty); else ctx.lineTo(tx, ty); } ctx.stroke(); } /* Embers. */ for (var e = 0; e < house.embers.length; e++) { var em = house.embers[e]; var ea = 0.25 + Math.sin(em.phase) * 0.2; ctx.fillStyle = 'rgba(255,170,90,' + clamp(ea * light * 2, 0, 0.9) + ')'; ctx.beginPath(); ctx.arc(em.x * w, em.y * h, 1.6, 0, TAU); ctx.fill(); } /* Dust motes. */ ctx.fillStyle = 'rgba(230,230,240,0.18)'; for (var d = 0; d < house.dust.length; d++) { var p = house.dust[d]; ctx.beginPath(); ctx.arc(p.x * w, p.y * h, p.r, 0, TAU); ctx.fill(); } /* Vignette. */ var vig = ctx.createRadialGradient(w / 2, h / 2, Math.min(w, h) * 0.25, w / 2, h / 2, Math.max(w, h) * 0.75); vig.addColorStop(0, 'rgba(0,0,0,0)'); vig.addColorStop(1, 'rgba(0,0,0,0.72)'); ctx.fillStyle = vig; ctx.fillRect(0, 0, w, h); /* Room label. */ ctx.fillStyle = 'rgba(240,230,210,0.7)'; ctx.font = '600 12px "Segoe UI", sans-serif'; ctx.fillText(room.label.toUpperCase(), 14, 24); if (house.paused) { ctx.fillStyle = 'rgba(255,255,255,0.7)'; ctx.font = '600 13px "Segoe UI", sans-serif'; ctx.fillText('HELD', 14, 44); } }; function shade(hex, factor) { var n = parseInt(hex.slice(1), 16); var r = clamp(Math.round(((n >> 16) & 255) * factor), 0, 255); var g = clamp(Math.round(((n >> 8) & 255) * factor), 0, 255); var b = clamp(Math.round((n & 255) * factor), 0, 255); return 'rgb(' + r + ',' + g + ',' + b + ')'; } function hexToRgba(hex, alpha) { var n = parseInt(hex.slice(1), 16); return 'rgba(' + ((n >> 16) & 255) + ',' + ((n >> 8) & 255) + ',' + (n & 255) + ',' + alpha + ')'; } /* ------------------------------------------------------------------ * * Exhibit * ------------------------------------------------------------------ */ function HauntedHouseExhibit(options) { this.canvas = options.canvas; this.house = new House(); this.audio = new AudioService(); this.renderer = new Renderer(this.canvas, this.house); this.announcer = options.announcer; this.onChange = options.onChange || function () {}; this._lastFrame = 0; this._running = false; this._boundFrame = this._frame.bind(this); } HauntedHouseExhibit.prototype.start = function () { if (this._running) return; this._running = true; this._lastFrame = performance.now(); requestAnimationFrame(this._boundFrame); }; HauntedHouseExhibit.prototype._frame = function (now) { if (!this._running) return; var dt = Math.min(0.05, (now - this._lastFrame) / 1000); this._lastFrame = now; this.house.step(dt); this.renderer.draw(); requestAnimationFrame(this._boundFrame); }; HauntedHouseExhibit.prototype.resize = function () { this.renderer.resize(); }; /* ------------------------------------------------------------------ * * Absolute, idempotent setters * ------------------------------------------------------------------ */ HauntedHouseExhibit.prototype.setRoom = function (id) { if (this.house.room === id) return { changed: false }; this.house.room = id; this.onChange(); return { changed: true }; }; HauntedHouseExhibit.prototype.setSpirit = function (id) { if (this.house.spirit === id) return { changed: false }; this.house.spirit = id; this.onChange(); return { changed: true }; }; HauntedHouseExhibit.prototype.setHauntLevel = function (value) { if (this.house.hauntLevel === value) return { changed: false }; this.house.hauntLevel = value; this.audio.setIntensity(value); this.onChange(); return { changed: true }; }; HauntedHouseExhibit.prototype.setFogDensity = function (value) { if (this.house.fogDensity === value) return { changed: false }; this.house.fogDensity = value; this.onChange(); return { changed: true }; }; HauntedHouseExhibit.prototype.setCandlelight = function (value) { if (this.house.candlelight === value) return { changed: false }; this.house.candlelight = value; this.onChange(); return { changed: true }; }; HauntedHouseExhibit.prototype.setAudioEnabled = function (on) { if (this.house.audioEnabled === on) return { changed: false }; this.house.audioEnabled = on; if (!on) this.audio.setIntensity(0); else this.audio.setIntensity(this.house.hauntLevel); this.onChange(); return { changed: true }; }; HauntedHouseExhibit.prototype.setPaused = function (on) { if (this.house.paused === on) return { changed: false }; this.house.paused = on; this.onChange(); return { changed: true }; }; /* ------------------------------------------------------------------ * * Real actions * ------------------------------------------------------------------ */ HauntedHouseExhibit.prototype.moan = function () { if (!this.house.audioEnabled) { return { ok: false, code: 'CAPABILITY_UNAVAILABLE', message: 'Audio is disabled.' }; } if (!this.audio.moan()) { return { ok: false, code: 'CAPABILITY_UNAVAILABLE', message: 'The audio engine is not ready.' }; } this.announcer.flash('A long moan rolls through the ' + this.house.room + '.'); this.onChange(); return { ok: true }; }; HauntedHouseExhibit.prototype.slamDoor = function () { if (!this.house.audioEnabled) { return { ok: false, code: 'CAPABILITY_UNAVAILABLE', message: 'Audio is disabled.' }; } if (!this.audio.slam()) { return { ok: false, code: 'CAPABILITY_UNAVAILABLE', message: 'The audio engine is not ready.' }; } this.house.hauntLevel = clamp(this.house.hauntLevel + 0.12, 0, 1); this.announcer.flash('A door slams somewhere above.'); this.onChange(); return { ok: true }; }; HauntedHouseExhibit.prototype.manifest = function () { this.house.hauntLevel = 1; this.audio.setIntensity(1); this.announcer.flash('The apparition manifests fully.'); this.onChange(); return { ok: true }; }; HauntedHouseExhibit.prototype.calm = function () { this.house.hauntLevel = 0; this.audio.setIntensity(0); this.announcer.flash('The house settles into silence.'); this.onChange(); return { ok: true }; }; /* ------------------------------------------------------------------ * * Reads * ------------------------------------------------------------------ */ HauntedHouseExhibit.prototype.read = function (targetId) { var h = this.house; switch (targetId) { case 'room.current': return h.room; case 'room.spirit': return h.spirit; case 'room.haunt-level': return Math.round(h.hauntLevel * 1000) / 1000; case 'room.fog-density': return h.fogDensity; case 'room.candlelight': return h.candlelight; case 'room.audio-enabled': return h.audioEnabled; case 'room.paused': return h.paused; case 'telemetry.audio-state': return this.audio.state; case 'telemetry.manifestations': return this._manifestations; case 'telemetry.room-damp': return findById(ROOMS, h.room).damp; default: return null; } }; HauntedHouseExhibit.prototype._manifestations = 0; /* ------------------------------------------------------------------ * * Native UI * ------------------------------------------------------------------ */ function HauntedHouseUI(exhibit, announcer) { this.exhibit = exhibit; this.announcer = announcer; this.nodes = { room: document.getElementById('room-select'), spirit: document.getElementById('spirit-select'), haunt: document.getElementById('haunt-range'), hauntReadout: document.getElementById('haunt-readout'), fog: document.getElementById('fog-range'), fogReadout: document.getElementById('fog-readout'), candle: document.getElementById('candle-range'), candleReadout: document.getElementById('candle-readout'), audio: document.getElementById('audio-button'), pause: document.getElementById('pause-button'), moan: document.getElementById('moan-button'), slam: document.getElementById('slam-button'), manifest: document.getElementById('manifest-button'), calm: document.getElementById('calm-button'), audioState: document.getElementById('audio-state') }; this._buildOptions(); this._bind(); this.sync(); } HauntedHouseUI.prototype._buildOptions = function () { var r = this.nodes.room; for (var i = 0; i < ROOMS.length; i++) { var opt = document.createElement('option'); opt.value = ROOMS[i].id; opt.textContent = ROOMS[i].label; r.appendChild(opt); } var s = this.nodes.spirit; for (var j = 0; j < SPIRITS.length; j++) { var sopt = document.createElement('option'); sopt.value = SPIRITS[j].id; sopt.textContent = SPIRITS[j].label; s.appendChild(sopt); } }; HauntedHouseUI.prototype._bind = function () { var self = this; var ex = this.exhibit; this.nodes.room.addEventListener('change', function (e) { self.dispatch('room.current', e.target.value); }); this.nodes.spirit.addEventListener('change', function (e) { self.dispatch('room.spirit', e.target.value); }); this.nodes.haunt.addEventListener('input', function (e) { self.dispatch('room.haunt-level', Number(e.target.value)); }); this.nodes.fog.addEventListener('input', function (e) { self.dispatch('room.fog-density', Number(e.target.value)); }); this.nodes.candle.addEventListener('input', function (e) { self.dispatch('room.candlelight', Number(e.target.value)); }); this.nodes.audio.addEventListener('click', function () { self.dispatch('room.audio-enabled', !ex.house.audioEnabled); }); this.nodes.pause.addEventListener('click', function () { self.dispatch('room.paused', !ex.house.paused); }); /* The first real gesture is what unlocks the audio engine. This is the * browser's rule, not the exhibit's, so the capability genuinely moves * available -> loading -> ready here. */ this.nodes.moan.addEventListener('click', function () { self._ensureAudio(); self.invoke('action.moan'); }); this.nodes.slam.addEventListener('click', function () { self._ensureAudio(); self.invoke('action.slam-door'); }); this.nodes.manifest.addEventListener('click', function () { self._ensureAudio(); self.invoke('action.manifest'); }); this.nodes.calm.addEventListener('click', function () { self.invoke('action.calm'); }); }; HauntedHouseUI.prototype._ensureAudio = function () { if (this.exhibit.audio.state === 'ready') return; this.exhibit.audio.prepare(); this.sync(); }; HauntedHouseUI.prototype.dispatch = function (targetId, value) { if (typeof this.dispatchOverride === 'function') { this.dispatchOverride(targetId, value); return; } this._fallbackSet(targetId, value); }; HauntedHouseUI.prototype.invoke = function (targetId) { if (typeof this.invokeOverride === 'function') { this.invokeOverride(targetId); return; } this._fallbackInvoke(targetId); }; HauntedHouseUI.prototype._fallbackSet = function (targetId, value) { var ex = this.exhibit; switch (targetId) { case 'room.current': ex.setRoom(value); break; case 'room.spirit': ex.setSpirit(value); break; case 'room.haunt-level': ex.setHauntLevel(value); break; case 'room.fog-density': ex.setFogDensity(value); break; case 'room.candlelight': ex.setCandlelight(value); break; case 'room.audio-enabled': ex.setAudioEnabled(value); break; case 'room.paused': ex.setPaused(value); break; default: break; } this.sync(); }; HauntedHouseUI.prototype._fallbackInvoke = function (targetId) { var ex = this.exhibit; if (targetId === 'action.moan') ex.moan(); else if (targetId === 'action.slam-door') ex.slamDoor(); else if (targetId === 'action.manifest') ex.manifest(); else if (targetId === 'action.calm') ex.calm(); this.sync(); }; HauntedHouseUI.prototype.sync = function () { var h = this.exhibit.house; var n = this.nodes; var Shell = window.XZBTShell; if (n.room.value !== h.room) n.room.value = h.room; if (n.spirit.value !== h.spirit) n.spirit.value = h.spirit; if (Number(n.haunt.value) !== h.hauntLevel) n.haunt.value = String(h.hauntLevel); if (Number(n.fog.value) !== h.fogDensity) n.fog.value = String(h.fogDensity); if (Number(n.candle.value) !== h.candlelight) n.candle.value = String(h.candlelight); n.hauntReadout.textContent = Shell.formatPercent(h.hauntLevel); n.fogReadout.textContent = Shell.formatPercent(h.fogDensity); n.candleReadout.textContent = Shell.formatPercent(h.candlelight); n.audio.setAttribute('aria-pressed', h.audioEnabled ? 'true' : 'false'); n.audio.textContent = h.audioEnabled ? 'Audio on' : 'Audio off'; n.pause.setAttribute('aria-pressed', h.paused ? 'true' : 'false'); n.pause.textContent = h.paused ? 'Resume' : 'Hold'; var state = this.exhibit.audio.state; n.audioState.textContent = state; n.audioState.setAttribute('data-state', state); }; window.HauntedHouseExhibit = HauntedHouseExhibit; window.HauntedHouseUI = HauntedHouseUI; window.HauntedHouseData = { ROOMS: ROOMS, SPIRITS: SPIRITS }; })();