generated from Labyricorn/labyricorn-project-template
Post Step 4 Completion
This commit is contained in:
@@ -0,0 +1,54 @@
|
||||
/*
|
||||
* Aquarium — composition root.
|
||||
*
|
||||
* The last file to load, and the only one that knows about all the others.
|
||||
* It builds the exhibit, builds the UI, then hands both to the contract
|
||||
* adapter. If the adapter were removed, the first two lines would still
|
||||
* produce a complete, working aquarium.
|
||||
*/
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
var Shell = window.XZBTShell;
|
||||
|
||||
var announcer = new Shell.Announcer({
|
||||
node: Shell.el('announcement'),
|
||||
idleText: 'Tank settled \u2014 awaiting instruction.',
|
||||
maxLength: 120
|
||||
});
|
||||
|
||||
var exhibit = new window.AquariumExhibit({
|
||||
canvas: Shell.el('tank-canvas'),
|
||||
announcer: announcer
|
||||
});
|
||||
|
||||
var ui = new window.AquariumUI(exhibit, announcer);
|
||||
|
||||
/* Contract layer. Optional by construction: everything above already
|
||||
* works without it. */
|
||||
var core = window.AquariumContract.create(exhibit, ui);
|
||||
|
||||
/* Transport layer. Also optional: with no host attached, nothing here
|
||||
* ever fires and the exhibit is unchanged. */
|
||||
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(); });
|
||||
|
||||
/* Exposed for the conformance harness and for manual console poking. */
|
||||
window.__exhibit = {
|
||||
core: core,
|
||||
exhibit: exhibit,
|
||||
ui: ui,
|
||||
transport: transport,
|
||||
announcer: announcer
|
||||
};
|
||||
})();
|
||||
@@ -0,0 +1,217 @@
|
||||
/*
|
||||
* Aquarium — XZBT Exhibit Contract 5.2 adapter.
|
||||
*
|
||||
* This file is the only place in the exhibit that knows the contract exists.
|
||||
* It does three things and nothing else:
|
||||
*
|
||||
* 1. declares the public target catalog against the exhibit's real model
|
||||
* 2. binds each target to a real exhibit service (setter / reader / action)
|
||||
* 3. routes the native UI through the canonical mutation path
|
||||
*
|
||||
* It holds no state of its own. Every value it reports is read live from the
|
||||
* exhibit, so there is no shadow copy that can drift (Authoring Guide §W).
|
||||
*/
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
var Core = window.XZBTContractCore;
|
||||
|
||||
var IDENTITY = {
|
||||
product: 'Aquarium',
|
||||
version: '1.0.0',
|
||||
build: 'reference-exhibit'
|
||||
};
|
||||
|
||||
/* ------------------------------------------------------------------ *
|
||||
* Public target catalog.
|
||||
*
|
||||
* IDs are canonical and domain-shaped. They are not DOM ids, CSS
|
||||
* selectors, or widget names (Authoring Guide §W).
|
||||
* ------------------------------------------------------------------ */
|
||||
|
||||
var DESCRIPTORS = [
|
||||
{
|
||||
id: 'tank.species',
|
||||
kind: 'selection',
|
||||
label: 'Species',
|
||||
description: 'The species currently stocked in the tank.',
|
||||
options: [
|
||||
{ value: 'clownfish', label: 'Clownfish' },
|
||||
{ value: 'blue-tang', label: 'Blue Tang' },
|
||||
{ value: 'neon-tetra', label: 'Neon Tetra' },
|
||||
{ value: 'angelfish', label: 'Angelfish' },
|
||||
{ value: 'guppy', label: 'Guppy' }
|
||||
],
|
||||
readable: true, writable: true, restorable: true,
|
||||
category: 'tank', requires: []
|
||||
},
|
||||
{
|
||||
id: 'tank.population',
|
||||
kind: 'range',
|
||||
label: 'Population',
|
||||
description: 'Number of fish in the tank.',
|
||||
min: 1, max: 40, step: 1, unit: 'fish',
|
||||
readable: true, writable: true, restorable: true,
|
||||
category: 'tank', requires: []
|
||||
},
|
||||
{
|
||||
id: 'tank.lighting',
|
||||
kind: 'selection',
|
||||
label: 'Lighting',
|
||||
description: 'Lighting programme for the tank.',
|
||||
options: [
|
||||
{ value: 'daylight', label: 'Daylight' },
|
||||
{ value: 'twilight', label: 'Twilight' },
|
||||
{ value: 'moonlight', label: 'Moonlight' },
|
||||
{ value: 'blackwater', label: 'Blackwater' }
|
||||
],
|
||||
readable: true, writable: true, restorable: true,
|
||||
category: 'tank', requires: []
|
||||
},
|
||||
{
|
||||
id: 'tank.current',
|
||||
kind: 'range',
|
||||
label: 'Water current',
|
||||
description: 'Strength of the circulation pump, 0 (still) to 1 (strong).',
|
||||
min: 0, max: 1, step: 0.01,
|
||||
readable: true, writable: true, restorable: true,
|
||||
category: 'tank', requires: []
|
||||
},
|
||||
{
|
||||
id: 'tank.temperature',
|
||||
kind: 'range',
|
||||
label: 'Temperature',
|
||||
description: 'Heater setting, 0 (18 C) to 1 (28 C).',
|
||||
min: 0, max: 1, step: 0.01,
|
||||
readable: true, writable: true, restorable: true,
|
||||
category: 'tank', requires: []
|
||||
},
|
||||
{
|
||||
id: 'tank.paused',
|
||||
kind: 'state',
|
||||
valueType: 'boolean',
|
||||
label: 'Paused',
|
||||
description: 'Whether the simulation is frozen.',
|
||||
readable: true, writable: true, restorable: true,
|
||||
category: 'tank', requires: []
|
||||
},
|
||||
{
|
||||
id: 'action.feed',
|
||||
kind: 'impulse',
|
||||
label: 'Feed',
|
||||
description: 'Scatter food across the surface.',
|
||||
readable: false, writable: false, restorable: false,
|
||||
category: 'action', requires: []
|
||||
},
|
||||
{
|
||||
id: 'action.startle',
|
||||
kind: 'impulse',
|
||||
label: 'Startle',
|
||||
description: 'Send a pressure wave through the tank.',
|
||||
readable: false, writable: false, restorable: false,
|
||||
category: 'action', requires: []
|
||||
},
|
||||
{
|
||||
id: 'action.clean-glass',
|
||||
kind: 'impulse',
|
||||
label: 'Clean glass',
|
||||
description: 'Run the glass-cleaning cycle.',
|
||||
readable: false, writable: false, restorable: false,
|
||||
category: 'action', requires: []
|
||||
},
|
||||
{
|
||||
id: 'telemetry.fish-count',
|
||||
kind: 'state',
|
||||
valueType: 'number',
|
||||
label: 'Fish count',
|
||||
description: 'Live number of fish currently rendered.',
|
||||
readable: true, writable: false, restorable: false,
|
||||
category: 'telemetry', requires: []
|
||||
},
|
||||
{
|
||||
id: 'telemetry.food-level',
|
||||
kind: 'state',
|
||||
valueType: 'number',
|
||||
label: 'Food level',
|
||||
description: 'Remaining food in the water column, 0 to 1.',
|
||||
readable: true, writable: false, restorable: false,
|
||||
category: 'telemetry', requires: []
|
||||
},
|
||||
{
|
||||
id: 'telemetry.water-temp',
|
||||
kind: 'state',
|
||||
valueType: 'number',
|
||||
unit: 'C',
|
||||
label: 'Water temperature',
|
||||
description: 'Actual water temperature in degrees Celsius.',
|
||||
readable: true, writable: false, restorable: false,
|
||||
category: 'telemetry', requires: []
|
||||
}
|
||||
];
|
||||
|
||||
/* ------------------------------------------------------------------ *
|
||||
* Wiring
|
||||
* ------------------------------------------------------------------ */
|
||||
|
||||
function createAquariumContract(exhibit, ui) {
|
||||
var catalog = new Core.Catalog(DESCRIPTORS);
|
||||
|
||||
/* The exhibit has no capability that can fail: it is pure canvas and
|
||||
* needs no device, no network, and no permission. Reporting `ready`
|
||||
* once is honest; inventing loading/error transitions would not be
|
||||
* (Authoring Guide §M). */
|
||||
var capabilities = new Core.CapabilityRegistry();
|
||||
capabilities.declare('render', 'ready');
|
||||
|
||||
var setters = {
|
||||
'tank.species': function (v) { return exhibit.setSpecies(v); },
|
||||
'tank.population': function (v) { return exhibit.setPopulation(v); },
|
||||
'tank.lighting': function (v) { return exhibit.setLighting(v); },
|
||||
'tank.current': function (v) { return exhibit.setCurrent(v); },
|
||||
'tank.temperature': function (v) { return exhibit.setTemperature(v); },
|
||||
'tank.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.feed': function () { return exhibit.feed(); },
|
||||
'action.startle': function () { return exhibit.startle(); },
|
||||
'action.clean-glass': function () { return exhibit.cleanGlass(); }
|
||||
};
|
||||
|
||||
var core = new Core.ContractCore({
|
||||
identity: IDENTITY,
|
||||
catalog: catalog,
|
||||
capabilities: capabilities,
|
||||
setters: setters,
|
||||
readers: readers,
|
||||
actions: actions
|
||||
});
|
||||
|
||||
/* Route the native UI through the canonical path. From here on, a
|
||||
* slider drag and a host `set` are the same transaction, and both are
|
||||
* reported with the same source semantics (Authoring Guide §H). */
|
||||
ui.dispatchOverride = function (targetId, value) {
|
||||
core.applyMutation(targetId, value, 'ui');
|
||||
ui.sync();
|
||||
};
|
||||
ui.invokeOverride = function (targetId) {
|
||||
core.invokeAction(targetId, {}, 'ui');
|
||||
ui.sync();
|
||||
};
|
||||
|
||||
/* Keep the controls honest when the exhibit changes for any reason. */
|
||||
exhibit.onChange = function () { ui.sync(); };
|
||||
|
||||
return core;
|
||||
}
|
||||
|
||||
window.AquariumContract = { create: createAquariumContract, IDENTITY: IDENTITY };
|
||||
})();
|
||||
@@ -0,0 +1,694 @@
|
||||
/*
|
||||
* 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 };
|
||||
})();
|
||||
@@ -0,0 +1,88 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Aquarium — 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>Aquarium</h1>
|
||||
<span class="subtitle">Reef tank — live simulation</span>
|
||||
<span class="status" id="contract-status">standalone</span>
|
||||
</header>
|
||||
|
||||
<main class="stage">
|
||||
<canvas id="tank-canvas" aria-label="Aquarium view"></canvas>
|
||||
</main>
|
||||
|
||||
<aside class="panel">
|
||||
<section>
|
||||
<h2>Stocking</h2>
|
||||
<div class="control">
|
||||
<label for="species-select">Species</label>
|
||||
<select id="species-select"></select>
|
||||
</div>
|
||||
<div class="control">
|
||||
<label for="population-range">
|
||||
<span>Population</span>
|
||||
<span class="readout" id="population-readout">12 fish</span>
|
||||
</label>
|
||||
<input type="range" id="population-range" min="1" max="40" step="1" value="12">
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>Environment</h2>
|
||||
<div class="control">
|
||||
<label for="lighting-select">Lighting</label>
|
||||
<select id="lighting-select"></select>
|
||||
</div>
|
||||
<div class="control">
|
||||
<label for="current-range">
|
||||
<span>Water current</span>
|
||||
<span class="readout" id="current-readout">35%</span>
|
||||
</label>
|
||||
<input type="range" id="current-range" min="0" max="1" step="0.01" value="0.35">
|
||||
</div>
|
||||
<div class="control">
|
||||
<label for="temperature-range">
|
||||
<span>Temperature</span>
|
||||
<span class="readout" id="temperature-readout">23.0 °C</span>
|
||||
</label>
|
||||
<input type="range" id="temperature-range" min="0" max="1" step="0.01" value="0.5">
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>Actions</h2>
|
||||
<div class="button-row">
|
||||
<button id="feed-button" type="button">Feed</button>
|
||||
<button id="startle-button" type="button">Startle</button>
|
||||
<button id="clean-button" type="button">Clean glass</button>
|
||||
</div>
|
||||
<div class="toggle-row" style="margin-top:12px">
|
||||
<span>Simulation</span>
|
||||
<button id="pause-button" type="button" aria-pressed="false">Pause</button>
|
||||
</div>
|
||||
</section>
|
||||
</aside>
|
||||
|
||||
<footer class="exhibit-footer">
|
||||
<span class="announcement-label">Tank</span>
|
||||
<span class="announcement-text is-idle" id="announcement">Tank settled — 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,54 @@
|
||||
/* Aquarium — domain styling only. Layout and controls come from the shell. */
|
||||
|
||||
body {
|
||||
background: #071018;
|
||||
color: #dff1ff;
|
||||
}
|
||||
|
||||
.exhibit-header {
|
||||
background: linear-gradient(90deg, rgba(30, 90, 130, 0.35), rgba(255, 255, 255, 0.03));
|
||||
border-color: rgba(120, 200, 255, 0.22);
|
||||
}
|
||||
|
||||
.exhibit-header h1 { color: #bfe9ff; }
|
||||
|
||||
.stage {
|
||||
border-color: rgba(120, 200, 255, 0.25);
|
||||
box-shadow: inset 0 0 60px rgba(0, 40, 70, 0.8);
|
||||
}
|
||||
|
||||
.panel {
|
||||
background: rgba(20, 60, 90, 0.22);
|
||||
border-color: rgba(120, 200, 255, 0.18);
|
||||
}
|
||||
|
||||
.panel h2 { color: #8fd4ff; }
|
||||
|
||||
input[type="range"] { accent-color: #4fc3ff; }
|
||||
|
||||
button {
|
||||
background: rgba(79, 195, 255, 0.12);
|
||||
border-color: rgba(120, 200, 255, 0.35);
|
||||
color: #dff1ff;
|
||||
}
|
||||
|
||||
button:hover { background: rgba(79, 195, 255, 0.24); }
|
||||
|
||||
button[aria-pressed="true"] {
|
||||
background: rgba(79, 195, 255, 0.4);
|
||||
border-color: rgba(160, 225, 255, 0.7);
|
||||
}
|
||||
|
||||
select {
|
||||
background: rgba(4, 24, 38, 0.85);
|
||||
border-color: rgba(120, 200, 255, 0.3);
|
||||
}
|
||||
|
||||
.exhibit-footer {
|
||||
background: rgba(20, 60, 90, 0.22);
|
||||
border-color: rgba(120, 200, 255, 0.18);
|
||||
}
|
||||
|
||||
.exhibit-footer .announcement-label { color: #8fd4ff; opacity: 0.7; }
|
||||
|
||||
.exhibit-header .status { color: #8fd4ff; }
|
||||
Reference in New Issue
Block a user