/* * 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 }; })();