Files
XZBT-NGN/test-fixtures/reference-exhibits/haunted-house/exhibit.js
T
2026-09-14 07:57:18 -07:00

765 lines
26 KiB
JavaScript

/*
* 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 };
})();