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