Post Step 4 Completion

This commit is contained in:
2026-09-14 07:57:18 -07:00
parent db973a86f3
commit 446bf3533e
52 changed files with 32034 additions and 3 deletions
@@ -0,0 +1,44 @@
/*
* Haunted House — composition root. Last file to load.
*/
(function () {
'use strict';
var Shell = window.XZBTShell;
var announcer = new Shell.Announcer({
node: Shell.el('announcement'),
idleText: 'The house is quiet \u2014 awaiting instruction.',
maxLength: 120
});
var exhibit = new window.HauntedHouseExhibit({
canvas: Shell.el('room-canvas'),
announcer: announcer
});
var ui = new window.HauntedHouseUI(exhibit, announcer);
var core = window.HauntedHouseContract.create(exhibit, ui);
var transport = new window.XZBTHostTransport({
core: core,
onMessage: function (request, response) {
if (response.type === 'hello.result') {
Shell.setText(Shell.el('contract-status'), 'host attached');
}
}
});
exhibit.start();
window.addEventListener('resize', function () { exhibit.resize(); });
window.__exhibit = {
core: core,
exhibit: exhibit,
ui: ui,
transport: transport,
announcer: announcer
};
})();
@@ -0,0 +1,218 @@
/*
* Haunted House — XZBT Exhibit Contract 5.2 adapter.
*
* This adapter is the one that has to be honest about capabilities. The
* exhibit's audio engine cannot start until the browser has seen a user
* gesture, so `audio` is declared `available` at boot and moves to
* `loading` then `ready` when it really does. The adapter mirrors the
* exhibit's real state into the capability registry; it never invents a
* transition (Authoring Guide §M).
*
* The audio-dependent actions are gated on that capability, so invoking
* `action.moan` before the engine is ready returns CAPABILITY_UNAVAILABLE
* rather than silently doing nothing.
*/
(function () {
'use strict';
var Core = window.XZBTContractCore;
var IDENTITY = {
product: 'Haunted House',
version: '1.0.0',
build: 'reference-exhibit'
};
var DESCRIPTORS = [
{
id: 'room.current',
kind: 'selection',
label: 'Room',
description: 'Room the visitor is standing in.',
options: [
{ value: 'foyer', label: 'Foyer' },
{ value: 'library', label: 'Library' },
{ value: 'cellar', label: 'Cellar' },
{ value: 'attic', label: 'Attic' },
{ value: 'nursery', label: 'Nursery' }
],
readable: true, writable: true, restorable: true,
category: 'room', requires: []
},
{
id: 'room.spirit',
kind: 'selection',
label: 'Spirit',
description: 'Which presence is currently resident.',
options: [
{ value: 'restless', label: 'Restless' },
{ value: 'vengeful', label: 'Vengeful' },
{ value: 'mourning', label: 'Mourning' },
{ value: 'mischievous', label: 'Mischievous' }
],
readable: true, writable: true, restorable: true,
category: 'room', requires: []
},
{
id: 'room.haunt-level',
kind: 'range',
label: 'Haunt level',
description: 'Intensity of the haunting, 0 (dormant) to 1 (manifest).',
min: 0, max: 1, step: 0.01,
readable: true, writable: true, restorable: true,
category: 'room', requires: []
},
{
id: 'room.fog-density',
kind: 'range',
label: 'Fog density',
description: 'Density of the ground fog.',
min: 0, max: 1, step: 0.01,
readable: true, writable: true, restorable: true,
category: 'room', requires: []
},
{
id: 'room.candlelight',
kind: 'range',
label: 'Candlelight',
description: 'Strength of the candle pool of light.',
min: 0, max: 1, step: 0.01,
readable: true, writable: true, restorable: true,
category: 'room', requires: []
},
{
id: 'room.audio-enabled',
kind: 'state',
valueType: 'boolean',
label: 'Audio enabled',
description: 'Whether the house is permitted to make sound.',
readable: true, writable: true, restorable: true,
category: 'room', requires: []
},
{
id: 'room.paused',
kind: 'state',
valueType: 'boolean',
label: 'Held',
description: 'Whether the haunt simulation is frozen.',
readable: true, writable: true, restorable: true,
category: 'room', requires: []
},
{
id: 'action.moan',
kind: 'impulse',
label: 'Moan',
description: 'Emit a long descending moan.',
readable: false, writable: false, restorable: false,
category: 'action', requires: ['audio']
},
{
id: 'action.slam-door',
kind: 'impulse',
label: 'Slam door',
description: 'Slam a door somewhere in the house.',
readable: false, writable: false, restorable: false,
category: 'action', requires: ['audio']
},
{
id: 'action.manifest',
kind: 'impulse',
label: 'Manifest',
description: 'Bring the apparition to full manifestation.',
readable: false, writable: false, restorable: false,
category: 'action', requires: []
},
{
id: 'action.calm',
kind: 'impulse',
label: 'Calm',
description: 'Settle the house back to dormancy.',
readable: false, writable: false, restorable: false,
category: 'action', requires: []
},
{
id: 'telemetry.audio-state',
kind: 'state',
valueType: 'string',
label: 'Audio engine state',
description: 'Live state of the Web Audio engine.',
readable: true, writable: false, restorable: false,
category: 'telemetry', requires: []
},
{
id: 'telemetry.room-damp',
kind: 'state',
valueType: 'number',
label: 'Room damping',
description: 'How strongly the current room suppresses the haunt.',
readable: true, writable: false, restorable: false,
category: 'telemetry', requires: []
}
];
function createHauntedHouseContract(exhibit, ui) {
var catalog = new Core.Catalog(DESCRIPTORS);
var capabilities = new Core.CapabilityRegistry();
capabilities.declare('render', 'ready');
/* Honest initial state: the engine exists but the browser will not let
* it start until a gesture arrives. */
capabilities.declare('audio', 'available');
var setters = {
'room.current': function (v) { return exhibit.setRoom(v); },
'room.spirit': function (v) { return exhibit.setSpirit(v); },
'room.haunt-level': function (v) { return exhibit.setHauntLevel(v); },
'room.fog-density': function (v) { return exhibit.setFogDensity(v); },
'room.candlelight': function (v) { return exhibit.setCandlelight(v); },
'room.audio-enabled': function (v) { return exhibit.setAudioEnabled(v); },
'room.paused': function (v) { return exhibit.setPaused(v); }
};
var readers = {};
var ids = catalog.ids();
for (var i = 0; i < ids.length; i++) {
(function (id) {
readers[id] = function () { return exhibit.read(id); };
})(ids[i]);
}
var actions = {
'action.moan': function () { return exhibit.moan(); },
'action.slam-door': function () { return exhibit.slamDoor(); },
'action.manifest': function () { return exhibit.manifest(); },
'action.calm': function () { return exhibit.calm(); }
};
var core = new Core.ContractCore({
identity: IDENTITY,
catalog: catalog,
capabilities: capabilities,
setters: setters,
readers: readers,
actions: actions
});
/* Mirror the exhibit's real audio lifecycle into the contract. This is
* a genuine state change driven by the browser, not a scripted one. */
exhibit.audio.onStateChange = function (state) {
core.setCapabilityState('audio', state);
ui.sync();
};
ui.dispatchOverride = function (targetId, value) {
core.applyMutation(targetId, value, 'ui');
ui.sync();
};
ui.invokeOverride = function (targetId) {
core.invokeAction(targetId, {}, 'ui');
ui.sync();
};
exhibit.onChange = function () { ui.sync(); };
return core;
}
window.HauntedHouseContract = { create: createHauntedHouseContract, IDENTITY: IDENTITY };
})();
@@ -0,0 +1,764 @@
/*
* 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 };
})();
@@ -0,0 +1,95 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Haunted House — XZBT Reference Exhibit</title>
<link rel="stylesheet" href="../shared/exhibit-shell.css">
<link rel="stylesheet" href="style.css">
</head>
<body>
<div class="exhibit">
<header class="exhibit-header">
<h1>Haunted House</h1>
<span class="subtitle">Fifth room &mdash; live haunt</span>
<span class="status" id="contract-status">standalone</span>
</header>
<main class="stage">
<canvas id="room-canvas" aria-label="Haunted house room view"></canvas>
</main>
<aside class="panel">
<section>
<h2>Presence</h2>
<div class="control">
<label for="room-select">Room</label>
<select id="room-select"></select>
</div>
<div class="control">
<label for="spirit-select">Spirit</label>
<select id="spirit-select"></select>
</div>
<div class="control">
<label for="haunt-range">
<span>Haunt level</span>
<span class="readout" id="haunt-readout">30%</span>
</label>
<input type="range" id="haunt-range" min="0" max="1" step="0.01" value="0.3">
</div>
</section>
<section>
<h2>Atmosphere</h2>
<div class="control">
<label for="fog-range">
<span>Fog density</span>
<span class="readout" id="fog-readout">25%</span>
</label>
<input type="range" id="fog-range" min="0" max="1" step="0.01" value="0.25">
</div>
<div class="control">
<label for="candle-range">
<span>Candlelight</span>
<span class="readout" id="candle-readout">55%</span>
</label>
<input type="range" id="candle-range" min="0" max="1" step="0.01" value="0.55">
</div>
<div class="toggle-row">
<span>Audio engine: <strong id="audio-state" data-state="available">available</strong></span>
<button id="audio-button" type="button" aria-pressed="true">Audio on</button>
</div>
<div class="toggle-row">
<span>Simulation</span>
<button id="pause-button" type="button" aria-pressed="false">Hold</button>
</div>
</section>
<section>
<h2>Actions</h2>
<div class="button-row">
<button id="moan-button" type="button">Moan</button>
<button id="slam-button" type="button">Slam door</button>
</div>
<div class="button-row" style="margin-top:6px">
<button id="manifest-button" type="button">Manifest</button>
<button id="calm-button" type="button">Calm</button>
</div>
</section>
</aside>
<footer class="exhibit-footer">
<span class="announcement-label">House</span>
<span class="announcement-text is-idle" id="announcement">The house is quiet &mdash; awaiting instruction.</span>
</footer>
</div>
<script src="../shared/contract-core.js"></script>
<script src="../shared/exhibit-shell.js"></script>
<script src="../shared/host-transport.js"></script>
<script src="exhibit.js"></script>
<script src="contract-adapter.js"></script>
<script src="boot.js"></script>
</body>
</html>
@@ -0,0 +1,65 @@
/* Haunted House — domain styling only. */
body {
background: #0d0a08;
color: #f0e6d8;
}
.exhibit-header {
background: linear-gradient(90deg, rgba(120, 70, 30, 0.35), rgba(255, 255, 255, 0.03));
border-color: rgba(220, 170, 110, 0.22);
}
.exhibit-header h1 { color: #f0d9b5; }
.stage {
border-color: rgba(220, 170, 110, 0.25);
box-shadow: inset 0 0 70px rgba(0, 0, 0, 0.9);
}
.panel {
background: rgba(90, 60, 30, 0.2);
border-color: rgba(220, 170, 110, 0.18);
}
.panel h2 { color: #e0b98a; }
input[type="range"] { accent-color: #e0a860; }
button {
background: rgba(224, 168, 96, 0.12);
border-color: rgba(220, 170, 110, 0.35);
color: #f0e6d8;
}
button:hover { background: rgba(224, 168, 96, 0.24); }
button[aria-pressed="true"] {
background: rgba(224, 168, 96, 0.4);
border-color: rgba(245, 210, 160, 0.7);
}
select {
background: rgba(20, 14, 10, 0.85);
border-color: rgba(220, 170, 110, 0.3);
}
.exhibit-footer {
background: rgba(90, 60, 30, 0.2);
border-color: rgba(220, 170, 110, 0.18);
}
.exhibit-footer .announcement-label { color: #e0b98a; opacity: 0.7; }
.exhibit-header .status { color: #e0b98a; }
/* The audio engine state is a real capability, so it is shown plainly. */
#audio-state {
font-weight: 600;
letter-spacing: 0.04em;
}
#audio-state[data-state="available"] { color: #d8c090; }
#audio-state[data-state="loading"] { color: #ffd27a; }
#audio-state[data-state="ready"] { color: #9fe0a0; }
#audio-state[data-state="error"] { color: #ff8a7a; }