generated from Labyricorn/labyricorn-project-template
765 lines
26 KiB
JavaScript
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 };
|
|
})();
|