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

695 lines
23 KiB
JavaScript

/*
* Aquarium — exhibit behaviour.
*
* This file is the exhibit. It owns the tank model, the simulation, the
* canvas rendering, and the native controls. It has no contract awareness
* whatsoever: it does not know what a target ID is, it never emits an event,
* and it would work identically if the contract layer were deleted.
*
* That is deliberate. The contract adapter is a layer added on top of a
* working exhibit (Authoring Guide §X), not a scaffold the exhibit is built
* inside of.
*
* The one thing this file does provide for the layer above it is a set of
* absolute, idempotent setters and real actions. Those are the exhibit's own
* services — the same functions the native UI calls — so UI-originated and
* host-originated changes share exactly one code path (Authoring Guide §H).
*/
(function () {
'use strict';
var TAU = Math.PI * 2;
var SPECIES = [
{ id: 'clownfish', label: 'Clownfish', color: '#ff9a3c', size: 9, speed: 1.15, schooling: 0.35 },
{ id: 'blue-tang', label: 'Blue Tang', color: '#3f8cff', size: 12, speed: 0.95, schooling: 0.55 },
{ id: 'neon-tetra', label: 'Neon Tetra', color: '#4fe3ff', size: 6, speed: 1.45, schooling: 0.85 },
{ id: 'angelfish', label: 'Angelfish', color: '#f2e06a', size: 14, speed: 0.7, schooling: 0.2 },
{ id: 'guppy', label: 'Guppy', color: '#ff6fae', size: 7, speed: 1.3, schooling: 0.45 }
];
var LIGHTING = [
{ id: 'daylight', label: 'Daylight', tint: '#bfe9ff', ambient: 1.0, surface: 0.85 },
{ id: 'twilight', label: 'Twilight', tint: '#6f7fd0', ambient: 0.62, surface: 0.5 },
{ id: 'moonlight', label: 'Moonlight', tint: '#3d5a9c', ambient: 0.34, surface: 0.28 },
{ id: 'blackwater', label: 'Blackwater', tint: '#2a1f3d', ambient: 0.18, surface: 0.12 }
];
var FEED_AMOUNT = 0.22;
var FEED_DECAY_PER_SECOND = 0.035;
var MAX_FISH = 40;
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;
}
/* ------------------------------------------------------------------ *
* Tank model
* ------------------------------------------------------------------ */
function Tank() {
this.species = 'clownfish';
this.population = 12;
this.lighting = 'daylight';
this.current = 0.35;
this.temperature = 0.5;
this.foodLevel = 0.0;
this.paused = false;
this.fish = [];
this.bubbles = [];
this.plants = [];
this.time = 0;
this._spawnAll();
this._seedScenery();
}
Tank.prototype._spawnAll = function () {
this.fish = [];
for (var i = 0; i < this.population; i++) this.fish.push(this._makeFish(i));
};
Tank.prototype._makeFish = function (index) {
var species = findById(SPECIES, this.species);
var lane = (index % 5) / 5;
return {
x: Math.random(),
y: 0.18 + lane * 0.62 + (Math.random() - 0.5) * 0.08,
vx: (Math.random() < 0.5 ? -1 : 1) * (0.02 + Math.random() * 0.03),
vy: (Math.random() - 0.5) * 0.012,
phase: Math.random() * TAU,
wobble: 0.6 + Math.random() * 0.8,
size: species.size * (0.8 + Math.random() * 0.45),
energy: 0.5 + Math.random() * 0.5
};
};
Tank.prototype._seedScenery = function () {
this.plants = [];
for (var i = 0; i < 7; i++) {
this.plants.push({
x: 0.06 + i * 0.14 + (Math.random() - 0.5) * 0.05,
height: 0.18 + Math.random() * 0.3,
phase: Math.random() * TAU,
blades: 3 + Math.floor(Math.random() * 3)
});
}
this.bubbles = [];
for (var b = 0; b < 26; b++) {
this.bubbles.push({
x: Math.random(),
y: Math.random(),
r: 0.002 + Math.random() * 0.006,
speed: 0.02 + Math.random() * 0.05,
drift: Math.random() * TAU
});
}
};
/* ------------------------------------------------------------------ *
* Simulation
* ------------------------------------------------------------------ */
Tank.prototype.step = function (dt) {
if (this.paused) return;
this.time += dt;
var species = findById(SPECIES, this.species);
var light = findById(LIGHTING, this.lighting);
/* Food decays on its own, so a feed is a real, observable event rather
* than a flag that stays set forever. */
if (this.foodLevel > 0) {
this.foodLevel = Math.max(0, this.foodLevel - FEED_DECAY_PER_SECOND * dt);
}
var currentForce = (this.current - 0.5) * 0.09;
var tempFactor = 0.6 + this.temperature * 0.9;
var hunger = this.foodLevel;
for (var i = 0; i < this.fish.length; i++) {
var f = this.fish[i];
f.phase += dt * f.wobble * 2.2;
/* Schooling: drift toward the shoal's average position. */
var avgX = 0, avgY = 0;
for (var j = 0; j < this.fish.length; j++) {
avgX += this.fish[j].x;
avgY += this.fish[j].y;
}
avgX /= this.fish.length;
avgY /= this.fish.length;
var pull = species.schooling * 0.35;
f.vx += (avgX - f.x) * pull * dt;
f.vy += (avgY - f.y) * pull * dt;
/* Feeding: food sinks from the surface, so fish rise toward it. */
if (hunger > 0.01) {
f.vy -= hunger * 0.5 * dt;
f.energy = clamp(f.energy + hunger * dt * 0.6, 0, 1);
}
/* Water current pushes horizontally. */
f.vx += currentForce * dt;
/* Temperature sets overall activity. */
var speed = species.speed * tempFactor * (0.7 + f.energy * 0.6);
f.vx = clamp(f.vx, -0.09 * speed, 0.09 * speed);
f.vy = clamp(f.vy, -0.05 * speed, 0.05 * speed);
f.x += f.vx * dt * 6;
f.y += f.vy * dt * 6;
/* Wander and keep off the glass. */
f.vy += Math.sin(f.phase) * 0.004 * dt;
if (f.x < 0.04) { f.x = 0.04; f.vx = Math.abs(f.vx); }
if (f.x > 0.96) { f.x = 0.96; f.vx = -Math.abs(f.vx); }
if (f.y < 0.1) { f.y = 0.1; f.vy = Math.abs(f.vy); }
if (f.y > 0.9) { f.y = 0.9; f.vy = -Math.abs(f.vy); }
f.energy = clamp(f.energy - dt * 0.02, 0.15, 1);
}
/* Bubbles rise faster in warmer, more turbulent water. */
var bubbleSpeed = 0.5 + this.temperature * 0.8 + Math.abs(this.current - 0.5) * 0.6;
for (var b = 0; b < this.bubbles.length; b++) {
var bubble = this.bubbles[b];
bubble.y -= bubble.speed * bubbleSpeed * dt;
bubble.drift += dt * 1.4;
bubble.x += Math.sin(bubble.drift) * 0.004 * dt * 6;
if (bubble.y < -0.02) {
bubble.y = 1.02;
bubble.x = Math.random();
}
}
/* Light level feeds back into the scene's own ambient value. */
this.ambient = light.ambient;
};
/* ------------------------------------------------------------------ *
* Rendering
* ------------------------------------------------------------------ */
function Renderer(canvas, tank) {
this.canvas = canvas;
this.ctx = canvas.getContext('2d');
this.tank = tank;
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 tank = this.tank;
var light = findById(LIGHTING, tank.lighting);
var species = findById(SPECIES, tank.species);
ctx.clearRect(0, 0, w, h);
/* Water column. */
var grad = ctx.createLinearGradient(0, 0, 0, h);
grad.addColorStop(0, shade(light.tint, 0.55 * light.ambient + 0.12));
grad.addColorStop(0.55, shade(light.tint, 0.3 * light.ambient + 0.06));
grad.addColorStop(1, shade(light.tint, 0.12 * light.ambient + 0.03));
ctx.fillStyle = grad;
ctx.fillRect(0, 0, w, h);
/* Caustic light shafts from the surface. */
ctx.save();
ctx.globalAlpha = 0.1 * light.surface;
ctx.fillStyle = '#ffffff';
for (var s = 0; s < 6; s++) {
var sx = ((s / 6) + Math.sin(tank.time * 0.25 + s) * 0.03) * w;
ctx.beginPath();
ctx.moveTo(sx, 0);
ctx.lineTo(sx + w * 0.05, 0);
ctx.lineTo(sx + w * 0.12, h);
ctx.lineTo(sx - w * 0.02, h);
ctx.closePath();
ctx.fill();
}
ctx.restore();
/* Substrate. */
ctx.fillStyle = shade(light.tint, 0.1);
ctx.beginPath();
ctx.moveTo(0, h);
for (var x = 0; x <= w; x += 12) {
var y = h - 26 - Math.sin(x * 0.02) * 6 - Math.sin(x * 0.007) * 9;
ctx.lineTo(x, y);
}
ctx.lineTo(w, h);
ctx.closePath();
ctx.fill();
/* Plants. */
for (var p = 0; p < tank.plants.length; p++) {
var plant = tank.plants[p];
var baseX = plant.x * w;
var baseY = h - 24;
var sway = Math.sin(tank.time * 0.9 + plant.phase) * (0.02 + tank.current * 0.05);
ctx.strokeStyle = shade('#2f7d4f', 0.35 + light.ambient * 0.5);
ctx.lineWidth = 2.5;
for (var bl = 0; bl < plant.blades; bl++) {
var offset = (bl - (plant.blades - 1) / 2) * 7;
ctx.beginPath();
ctx.moveTo(baseX + offset, baseY);
ctx.quadraticCurveTo(
baseX + offset + sway * w * 0.5,
baseY - plant.height * h * 0.6,
baseX + offset + sway * w,
baseY - plant.height * h
);
ctx.stroke();
}
}
/* Fish. */
for (var i = 0; i < tank.fish.length; i++) {
var f = tank.fish[i];
var fx = f.x * w;
var fy = f.y * h;
var facing = f.vx >= 0 ? 1 : -1;
var size = f.size * (0.85 + light.ambient * 0.3);
ctx.save();
ctx.translate(fx, fy);
ctx.scale(facing, 1);
/* Tail. */
ctx.fillStyle = shade(species.color, 0.55 + light.ambient * 0.4);
ctx.beginPath();
ctx.moveTo(-size * 0.9, 0);
ctx.lineTo(-size * 1.7, -size * 0.55 + Math.sin(f.phase) * size * 0.18);
ctx.lineTo(-size * 1.7, size * 0.55 + Math.sin(f.phase) * size * 0.18);
ctx.closePath();
ctx.fill();
/* Body. */
ctx.fillStyle = shade(species.color, 0.6 + light.ambient * 0.45);
ctx.beginPath();
ctx.ellipse(0, 0, size, size * 0.52, 0, 0, TAU);
ctx.fill();
/* Eye. */
ctx.fillStyle = 'rgba(10,12,18,0.85)';
ctx.beginPath();
ctx.arc(size * 0.5, -size * 0.1, Math.max(1, size * 0.13), 0, TAU);
ctx.fill();
ctx.restore();
}
/* Bubbles. */
ctx.strokeStyle = 'rgba(255,255,255,' + (0.12 + light.ambient * 0.22) + ')';
ctx.lineWidth = 1;
for (var b = 0; b < tank.bubbles.length; b++) {
var bubble = tank.bubbles[b];
ctx.beginPath();
ctx.arc(bubble.x * w, bubble.y * h, bubble.r * w, 0, TAU);
ctx.stroke();
}
/* Food particles sinking from the surface. */
if (tank.foodLevel > 0.01) {
ctx.fillStyle = 'rgba(226,196,120,' + (0.35 + tank.foodLevel * 0.5) + ')';
for (var k = 0; k < 40; k++) {
var seed = k * 12.9898;
var px = (Math.sin(seed) * 0.5 + 0.5) * w;
var py = ((Math.cos(seed * 1.7) * 0.5 + 0.5) * 0.5 + 0.5) * h;
var sink = (tank.time * 0.06 + (k / 40)) % 1;
ctx.beginPath();
ctx.arc(px, (py * 0.3 + sink * 0.7) * h, 1.6, 0, TAU);
ctx.fill();
}
}
/* Surface line. */
ctx.strokeStyle = 'rgba(255,255,255,' + (0.1 + light.surface * 0.25) + ')';
ctx.lineWidth = 2;
ctx.beginPath();
for (var sx2 = 0; sx2 <= w; sx2 += 8) {
var sy = 8 + Math.sin(sx2 * 0.03 + tank.time * 1.6) * 3;
if (sx2 === 0) ctx.moveTo(sx2, sy); else ctx.lineTo(sx2, sy);
}
ctx.stroke();
if (tank.paused) {
ctx.fillStyle = 'rgba(0,0,0,0.35)';
ctx.fillRect(0, 0, w, h);
ctx.fillStyle = 'rgba(255,255,255,0.75)';
ctx.font = '600 13px "Segoe UI", sans-serif';
ctx.textAlign = 'center';
ctx.fillText('PAUSED', w / 2, h / 2);
ctx.textAlign = 'start';
}
};
/** Lighten/darken a hex colour by a 0..1 factor. */
function shade(hex, factor) {
var n = parseInt(hex.slice(1), 16);
var r = (n >> 16) & 255;
var g = (n >> 8) & 255;
var b = n & 255;
var f = clamp(factor, 0, 1.6);
r = Math.round(clamp(r * f, 0, 255));
g = Math.round(clamp(g * f, 0, 255));
b = Math.round(clamp(b * f, 0, 255));
return 'rgb(' + r + ',' + g + ',' + b + ')';
}
/* ------------------------------------------------------------------ *
* Exhibit
* ------------------------------------------------------------------ */
function AquariumExhibit(options) {
this.canvas = options.canvas;
this.tank = new Tank();
this.renderer = new Renderer(this.canvas, this.tank);
this.announcer = options.announcer;
this.onChange = options.onChange || function () {};
this._lastFrame = 0;
this._running = false;
this._boundFrame = this._frame.bind(this);
}
AquariumExhibit.prototype.start = function () {
if (this._running) return;
this._running = true;
this._lastFrame = performance.now();
requestAnimationFrame(this._boundFrame);
};
AquariumExhibit.prototype._frame = function (now) {
if (!this._running) return;
var dt = Math.min(0.05, (now - this._lastFrame) / 1000);
this._lastFrame = now;
this.tank.step(dt);
this.renderer.draw();
requestAnimationFrame(this._boundFrame);
};
AquariumExhibit.prototype.resize = function () {
this.renderer.resize();
};
/* ------------------------------------------------------------------ *
* Absolute, idempotent setters.
*
* Each returns { changed: boolean }. Returning changed:false for a no-op
* is what lets the contract layer leave stateRevision untouched, and it is
* also what stops a host-set value from being silently reverted the next
* time a local control is touched (Authoring Guide §W).
* ------------------------------------------------------------------ */
AquariumExhibit.prototype.setSpecies = function (id) {
if (this.tank.species === id) return { changed: false };
this.tank.species = id;
/* Restocking is a real consequence of changing species, so the fish are
* respawned rather than recoloured in place. */
this.tank._spawnAll();
this.onChange();
return { changed: true };
};
AquariumExhibit.prototype.setPopulation = function (count) {
count = Math.round(count);
if (this.tank.population === count) return { changed: false };
var previous = this.tank.population;
this.tank.population = count;
if (count > previous) {
for (var i = previous; i < count; i++) this.tank.fish.push(this.tank._makeFish(i));
} else {
this.tank.fish.length = count;
}
this.onChange();
return { changed: true };
};
AquariumExhibit.prototype.setLighting = function (id) {
if (this.tank.lighting === id) return { changed: false };
this.tank.lighting = id;
this.onChange();
return { changed: true };
};
AquariumExhibit.prototype.setCurrent = function (value) {
if (this.tank.current === value) return { changed: false };
this.tank.current = value;
this.onChange();
return { changed: true };
};
AquariumExhibit.prototype.setTemperature = function (value) {
if (this.tank.temperature === value) return { changed: false };
this.tank.temperature = value;
this.onChange();
return { changed: true };
};
AquariumExhibit.prototype.setPaused = function (on) {
if (this.tank.paused === on) return { changed: false };
this.tank.paused = on;
this.onChange();
return { changed: true };
};
/* ------------------------------------------------------------------ *
* Real actions.
* ------------------------------------------------------------------ */
AquariumExhibit.prototype.feed = function () {
this.tank.foodLevel = clamp(this.tank.foodLevel + FEED_AMOUNT, 0, 1);
for (var i = 0; i < this.tank.fish.length; i++) {
this.tank.fish[i].energy = clamp(this.tank.fish[i].energy + 0.25, 0, 1);
}
this.announcer.flash('Feeding — food dispersing through the water column.');
this.onChange();
return { ok: true };
};
AquariumExhibit.prototype.startle = function () {
for (var i = 0; i < this.tank.fish.length; i++) {
var f = this.tank.fish[i];
f.vx += (Math.random() - 0.5) * 0.12;
f.vy += (Math.random() - 0.5) * 0.08;
f.energy = 1;
}
this.announcer.flash('Startle — the shoal scatters.');
this.onChange();
return { ok: true };
};
AquariumExhibit.prototype.cleanGlass = function () {
this.tank.bubbles.length = 0;
for (var b = 0; b < 26; b++) {
this.tank.bubbles.push({
x: Math.random(),
y: Math.random(),
r: 0.002 + Math.random() * 0.006,
speed: 0.02 + Math.random() * 0.05,
drift: Math.random() * TAU
});
}
this.announcer.flash('Glass cleaned — clarity restored.');
this.onChange();
return { ok: true };
};
/* ------------------------------------------------------------------ *
* Reads
* ------------------------------------------------------------------ */
AquariumExhibit.prototype.read = function (targetId) {
var t = this.tank;
switch (targetId) {
case 'tank.species': return t.species;
case 'tank.population': return t.population;
case 'tank.lighting': return t.lighting;
case 'tank.current': return t.current;
case 'tank.temperature': return t.temperature;
case 'tank.paused': return t.paused;
case 'telemetry.fish-count': return t.fish.length;
case 'telemetry.food-level': return Math.round(t.foodLevel * 1000) / 1000;
case 'telemetry.water-temp': return Math.round((18 + t.temperature * 10) * 10) / 10;
default: return null;
}
};
/* ------------------------------------------------------------------ *
* Native UI
*
* Every handler below calls the exhibit's own service method. None of them
* dispatches a synthetic DOM event, and none of them writes state directly
* (Authoring Guide §W).
* ------------------------------------------------------------------ */
function AquariumUI(exhibit, announcer) {
this.exhibit = exhibit;
this.announcer = announcer;
this.nodes = {
species: document.getElementById('species-select'),
population: document.getElementById('population-range'),
populationReadout: document.getElementById('population-readout'),
lighting: document.getElementById('lighting-select'),
current: document.getElementById('current-range'),
currentReadout: document.getElementById('current-readout'),
temperature: document.getElementById('temperature-range'),
temperatureReadout: document.getElementById('temperature-readout'),
pause: document.getElementById('pause-button'),
feed: document.getElementById('feed-button'),
startle: document.getElementById('startle-button'),
clean: document.getElementById('clean-button')
};
this._buildOptions();
this._bind();
this.sync();
}
AquariumUI.prototype._buildOptions = function () {
var speciesSelect = this.nodes.species;
for (var i = 0; i < SPECIES.length; i++) {
var opt = document.createElement('option');
opt.value = SPECIES[i].id;
opt.textContent = SPECIES[i].label;
speciesSelect.appendChild(opt);
}
var lightSelect = this.nodes.lighting;
for (var j = 0; j < LIGHTING.length; j++) {
var lopt = document.createElement('option');
lopt.value = LIGHTING[j].id;
lopt.textContent = LIGHTING[j].label;
lightSelect.appendChild(lopt);
}
};
AquariumUI.prototype._bind = function () {
var self = this;
var ex = this.exhibit;
this.nodes.species.addEventListener('change', function (e) {
self.dispatch('tank.species', e.target.value);
});
this.nodes.population.addEventListener('input', function (e) {
self.dispatch('tank.population', Number(e.target.value));
});
this.nodes.lighting.addEventListener('change', function (e) {
self.dispatch('tank.lighting', e.target.value);
});
this.nodes.current.addEventListener('input', function (e) {
self.dispatch('tank.current', Number(e.target.value));
});
this.nodes.temperature.addEventListener('input', function (e) {
self.dispatch('tank.temperature', Number(e.target.value));
});
this.nodes.pause.addEventListener('click', function () {
self.dispatch('tank.paused', !ex.tank.paused);
});
this.nodes.feed.addEventListener('click', function () {
self.invoke('action.feed');
});
this.nodes.startle.addEventListener('click', function () {
self.invoke('action.startle');
});
this.nodes.clean.addEventListener('click', function () {
self.invoke('action.clean-glass');
});
};
/**
* The UI's route into the canonical path. `dispatch` is installed by the
* contract adapter; until then the UI still works, because the fallback
* calls the exhibit service directly. Either way the exhibit's own setter
* is the only thing that mutates state.
*/
AquariumUI.prototype.dispatch = function (targetId, value) {
if (typeof this.dispatchOverride === 'function') {
this.dispatchOverride(targetId, value);
return;
}
this._fallbackSet(targetId, value);
};
AquariumUI.prototype.invoke = function (targetId) {
if (typeof this.invokeOverride === 'function') {
this.invokeOverride(targetId);
return;
}
this._fallbackInvoke(targetId);
};
AquariumUI.prototype._fallbackSet = function (targetId, value) {
var ex = this.exhibit;
switch (targetId) {
case 'tank.species': ex.setSpecies(value); break;
case 'tank.population': ex.setPopulation(value); break;
case 'tank.lighting': ex.setLighting(value); break;
case 'tank.current': ex.setCurrent(value); break;
case 'tank.temperature': ex.setTemperature(value); break;
case 'tank.paused': ex.setPaused(value); break;
default: break;
}
this.sync();
};
AquariumUI.prototype._fallbackInvoke = function (targetId) {
var ex = this.exhibit;
if (targetId === 'action.feed') ex.feed();
else if (targetId === 'action.startle') ex.startle();
else if (targetId === 'action.clean-glass') ex.cleanGlass();
this.sync();
};
/** Reflect current exhibit state into the controls. */
AquariumUI.prototype.sync = function () {
var t = this.exhibit.tank;
var n = this.nodes;
if (n.species.value !== t.species) n.species.value = t.species;
if (Number(n.population.value) !== t.population) n.population.value = String(t.population);
if (n.lighting.value !== t.lighting) n.lighting.value = t.lighting;
if (Number(n.current.value) !== t.current) n.current.value = String(t.current);
if (Number(n.temperature.value) !== t.temperature) n.temperature.value = String(t.temperature);
n.populationReadout.textContent = t.population + ' fish';
n.currentReadout.textContent = window.XZBTShell.formatPercent(t.current);
n.temperatureReadout.textContent = window.XZBTShell.formatNumber(18 + t.temperature * 10, 1) + ' \u00b0C';
n.pause.setAttribute('aria-pressed', t.paused ? 'true' : 'false');
n.pause.textContent = t.paused ? 'Resume' : 'Pause';
};
/* ------------------------------------------------------------------ *
* Exports
* ------------------------------------------------------------------ */
window.AquariumExhibit = AquariumExhibit;
window.AquariumUI = AquariumUI;
window.AquariumData = { SPECIES: SPECIES, LIGHTING: LIGHTING, MAX_FISH: MAX_FISH };
})();