API Unavailable
This demo needs Chrome 157 or later. On
Chromium 147–156 or Brave, turn on the
canvas-draw-element flag.
A real web form at the bottom of a koi pond: type a wish, toss a coin and watch your words sink, while ripples, caustics and weather bend the live HTML. By Baked Design Studio.
Source Code
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
<title>Wishing Pond</title>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600&display=swap" />
<style>
/* Layout: a koi pond fills the screen; a real, live form lies on the pond floor under the water. Single dark look, by choice. */
:root, :host {
--floor: #123A34;
--floor-edge: #08201C;
--card: #FFFFFF;
--ink: #0F1F1C;
--muted: #5B6B67;
--field: #EEF2F0;
--pond: #0E3B36;
--gold: #E8B341;
--bar: #061512;
--bar-ink: #DCE8E4;
--font: "Inter", ui-sans-serif, system-ui, -apple-system, "Segoe UI", sans-serif;
--mono: ui-monospace, "SF Mono", Menlo, monospace;
color-scheme: dark;
}
html, body { height: 100%; }
body, :host { background: var(--bar); color: var(--bar-ink); font: 15px/1.45 var(--font); -webkit-font-smoothing: antialiased; overflow: hidden; }
.app { height: 100%; display: grid; grid-template-rows: 1fr auto; }
.stage {
position: relative; min-height: 0; overflow: hidden;
background: radial-gradient(ellipse at 50% 45%, var(--floor), var(--floor-edge));
display: grid; place-items: center;
}
.stage canvas { position: absolute; inset: 0; width: 100%; height: 100%; display: block; }
#gl, #fx { pointer-events: none; }
.card.float { position: relative; z-index: 3; box-shadow: 0 20px 50px -20px rgb(0 0 0 / .6); }
/* The live card on the pond floor */
.card {
box-sizing: border-box;
width: min(340px, calc(100vw - 48px));
background: var(--card);
color: var(--ink);
border-radius: 22px;
padding: 22px;
display: grid;
gap: 14px;
}
.card h1 { margin: 0; font-size: 24px; line-height: 30px; font-weight: 600; }
.card p { margin: 0; font-size: 14px; line-height: 20px; color: var(--muted); }
.card input {
font: inherit; font-size: 17px; line-height: 24px; color: var(--ink);
background: var(--field); border: 0; border-radius: 12px; padding: 12px 14px;
outline: none; width: 100%; box-sizing: border-box;
}
.card input::placeholder { color: var(--muted); }
.card input:focus-visible { box-shadow: 0 0 0 2px var(--pond); }
.card button {
font: inherit; font-weight: 600; font-size: 15px; line-height: 20px;
height: 48px; border: 0; border-radius: 12px; cursor: pointer;
background: var(--pond); color: #FFFFFF;
transition: transform 150ms ease-out;
}
.card button:active { transform: scale(.96); }
.card button:focus-visible { outline: 2px solid var(--gold); outline-offset: 2px; }
.card .msg { min-height: 20px; color: var(--pond); font-weight: 500; }
/* Bottom strip */
.bar {
display: flex; flex-wrap: wrap; align-items: center; justify-content: space-between; gap: 10px 24px;
padding-block: 12px 14px;
padding-left: max(16px, env(safe-area-inset-left, 0px));
padding-right: max(16px, env(safe-area-inset-right, 0px));
background: var(--bar); color: var(--bar-ink);
}
.bar p { margin: 0; font-family: var(--mono); font-size: 12px; line-height: 18px; min-width: 0; max-width: 72ch; }
.bar button {
font-family: var(--mono); font-size: 12px; line-height: 16px; font-weight: 500;
height: 36px; padding: 0 16px; border-radius: 999px; border: 0;
background: var(--bar-ink); color: var(--bar); cursor: pointer; flex: none;
transition: transform 150ms ease-out;
}
.bar .ctls { display: flex; align-items: center; gap: 12px; flex: none; }
.bar button:active { transform: scale(.96); }
.bar button:focus-visible { outline: 2px solid var(--gold); outline-offset: 2px; }
.bar .end { display: flex; align-items: center; gap: 16px; flex: none; }
.bar .made { color: var(--bar-ink); font-family: var(--mono); font-size: 12px; line-height: 18px; text-decoration: underline; text-underline-offset: 3px; }
.bar .made:hover { color: #FFFFFF; }
body, :host { margin: 0; }
/* Mounted in a shadow root on html-in-canvas.dev, the host gives the demo its height. */
:host { display: block; height: min(78vh, 820px); min-height: 520px; }
:host(:fullscreen) { height: 100vh; }
</style>
</head>
<body>
<div class="app">
<div class="stage" id="stage">
<form class="card" id="card" novalidate autocomplete="off">
<h1>Wishing pond</h1>
<p>Type a wish and toss a coin in. Try words like rain, night or storm.</p>
<input id="wish" type="text" placeholder="I wish…" aria-label="Your wish">
<button type="submit">Toss a coin</button>
<p class="msg" id="msg" aria-live="polite"></p>
</form>
</div>
<footer class="bar">
<p id="mode">Checking for HTML-in-Canvas…</p>
<div class="ctls">
<a class="made" href="https://baked.design/">Made by Baked</a>
<button id="sound" type="button" aria-pressed="false">Sound on</button>
<button id="rain" type="button" aria-pressed="false">Rain</button>
</div>
</footer>
</div>
<script>
(() => {
// Standalone this is the document; mounted on html-in-canvas.dev it is the demo's shadow root.
const root = window.__demoRoot ?? document;
// Fonts declared inside a shadow root don't load, so a mounted demo adds its font link to the page.
if (root !== document) for (const href of ["https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600&display=swap"]) if (!document.querySelector(`link[href="${href}"]`)) { const l = document.createElement('link'); l.rel = 'stylesheet'; l.href = href; document.head.appendChild(l); }
const stage = root.getElementById('stage');
const card = root.getElementById('card');
const modeEl = root.getElementById('mode');
const msg = root.getElementById('msg');
const reduce = matchMedia('(prefers-reduced-motion: reduce)').matches;
const amp = reduce ? .3 : 1;
let W = 0, H = 0, dpr = 1;
let onWish = () => {};
// ---------- Sound: everything is synthesised, nothing is downloaded. Starts only when you press Sound on. ----------
const Sound = (() => {
let ac = null, master, amb, ambF, rainG, cricketG, lastPlink = 0, on = false;
const PENTA = [0, 2, 4, 7, 9, 12, 14, 16, 19, 21];
function noiseBuf(seconds, brown) {
const b = ac.createBuffer(1, ac.sampleRate * seconds, ac.sampleRate), d = b.getChannelData(0);
let last = 0;
for (let i = 0; i < d.length; i++) { const w = Math.random() * 2 - 1; if (brown) { last = (last + .02 * w) / 1.02; d[i] = last * 3.5; } else d[i] = w; }
return b;
}
function loop(buf) { const s = ac.createBufferSource(); s.buffer = buf; s.loop = true; s.start(); return s; }
function start() {
ac = new (window.AudioContext || window.webkitAudioContext)();
const comp = ac.createDynamicsCompressor();
master = ac.createGain(); master.gain.value = 0;
master.connect(comp); comp.connect(ac.destination);
// water lapping: brown noise through a slowly breathing low-pass
const brown = noiseBuf(4, true), white = noiseBuf(2, false);
ambF = ac.createBiquadFilter(); ambF.type = 'lowpass'; ambF.frequency.value = 420; ambF.Q.value = .7;
amb = ac.createGain(); amb.gain.value = .5;
loop(brown).connect(ambF); ambF.connect(amb); amb.connect(master);
const lfo = ac.createOscillator(), lfoG = ac.createGain();
lfo.frequency.value = .13; lfoG.gain.value = 160; lfo.connect(lfoG); lfoG.connect(ambF.frequency); lfo.start();
// rain: bright hiss, faded in by the weather
const rf = ac.createBiquadFilter(); rf.type = 'highpass'; rf.frequency.value = 1800;
rainG = ac.createGain(); rainG.gain.value = 0;
loop(white).connect(rf); rf.connect(rainG); rainG.connect(master);
cricketG = ac.createGain(); cricketG.gain.value = 0; cricketG.connect(master);
}
function env(g, t, peak, a, d) { g.gain.setValueAtTime(0, t); g.gain.linearRampToValueAtTime(peak, t + a); g.gain.exponentialRampToValueAtTime(.0001, t + a + d); }
function pan(x) { const p = ac.createStereoPanner ? ac.createStereoPanner() : null; if (p) { p.pan.value = Math.max(-1, Math.min(1, x * 2 - 1)) * .7; p.connect(master); return p; } return master; }
// a water drop: a sine that falls in pitch, the sound of a pebble
function plink(x, strength, note) {
if (!on) return;
const now = ac.currentTime;
if (now - lastPlink < .07 && note === undefined) return;
lastPlink = now;
const f = note !== undefined ? 523.25 * Math.pow(2, note / 12) : 500 + Math.random() * 900;
const o = ac.createOscillator(), g = ac.createGain();
o.type = 'sine';
o.frequency.setValueAtTime(f * 1.5, now); o.frequency.exponentialRampToValueAtTime(f * .55, now + .16);
env(g, now, .14 * strength, .004, .26);
o.connect(g); g.connect(pan(x)); o.start(now); o.stop(now + .35);
}
function key(k, x) { plink(x, .7, PENTA[(k.charCodeAt(0) || 0) % PENTA.length]); }
// a coin: a bright ting in the air, then a plop and bubbles as it sinks
function coin(x) {
if (!on) return;
const t = ac.currentTime;
[2637, 3951, 5274].forEach((f, i) => {
const o = ac.createOscillator(), g = ac.createGain();
o.frequency.value = f; env(g, t, [.09, .05, .03][i], .002, .5);
o.connect(g); g.connect(pan(x)); o.start(t); o.stop(t + .6);
});
const t2 = t + .38, o = ac.createOscillator(), g = ac.createGain();
o.frequency.setValueAtTime(360, t2); o.frequency.exponentialRampToValueAtTime(70, t2 + .14);
env(g, t2, .3, .005, .2); o.connect(g); g.connect(pan(x)); o.start(t2); o.stop(t2 + .3);
const nb = ac.createBufferSource(), nf = ac.createBiquadFilter(), ng = ac.createGain();
nb.buffer = noiseBuf(.3, false); nf.type = 'lowpass'; nf.frequency.value = 900;
env(ng, t2, .18, .003, .18); nb.connect(nf); nf.connect(ng); ng.connect(pan(x)); nb.start(t2);
for (let i = 0; i < 6; i++) {
const tb = t2 + .15 + i * .12 + Math.random() * .08, ob = ac.createOscillator(), gb = ac.createGain(), fb = 380 + Math.random() * 300;
ob.frequency.setValueAtTime(fb, tb); ob.frequency.exponentialRampToValueAtTime(fb * 2.2, tb + .06);
env(gb, tb, .05, .004, .07); ob.connect(gb); gb.connect(pan(x)); ob.start(tb); ob.stop(tb + .12);
}
}
// thunder: low rolling noise a moment after the flash
function thunder() {
if (!on) return;
const t = ac.currentTime + .35 + Math.random() * .7;
const nb = ac.createBufferSource(), nf = ac.createBiquadFilter(), g = ac.createGain();
nb.buffer = noiseBuf(3.5, true); nf.type = 'lowpass'; nf.frequency.setValueAtTime(420, t); nf.frequency.exponentialRampToValueAtTime(90, t + 3);
g.gain.setValueAtTime(0, t); g.gain.linearRampToValueAtTime(.9, t + .12); g.gain.exponentialRampToValueAtTime(.0001, t + 3.2);
nb.connect(nf); nf.connect(g); g.connect(master); nb.start(t);
}
// crickets at night: three quick high chirps
function chirp() {
const t = ac.currentTime;
for (let i = 0; i < 3; i++) {
const o = ac.createOscillator(), g = ac.createGain();
o.frequency.value = 4300 + Math.random() * 200;
env(g, t + i * .055, .05, .004, .03); o.connect(g); g.connect(cricketG); o.start(t + i * .055); o.stop(t + i * .055 + .06);
}
}
function update(dt, w) {
if (!on) return;
const t = ac.currentTime;
rainG.gain.setTargetAtTime(w.rain === 2 ? .09 : w.rain === 1 ? .045 : 0, t, .6);
amb.gain.setTargetAtTime(.5 + w.storm * .4, t, .8);
cricketG.gain.setTargetAtTime(Math.max(0, w.night - .5) * 2 * (1 - w.storm), t, 1);
if (w.night > .55 && w.storm < .3 && Math.random() < dt * 1.4) chirp();
if (w.rain && Math.random() < dt * (w.rain === 2 ? 9 : 4)) plink(Math.random(), .35);
}
async function toggle() {
if (!ac) start();
on = !on;
if (on) { ac.resume(); master.gain.setTargetAtTime(.8, ac.currentTime, .4); }
else master.gain.setTargetAtTime(0, ac.currentTime, .15);
return on;
}
return { toggle, plink, key, coin, thunder, update, get on() { return on; } };
})();
const soundBtn = root.getElementById('sound');
soundBtn.addEventListener('click', async () => {
const v = await Sound.toggle();
soundBtn.textContent = v ? 'Sound off' : 'Sound on';
soundBtn.setAttribute('aria-pressed', v ? 'true' : 'false');
});
card.addEventListener('submit', (e) => {
e.preventDefault();
const v = root.getElementById('wish').value.trim();
if (!v) { msg.textContent = 'Type a wish first.'; return; }
msg.textContent = 'Your wish sank to the bottom.';
onWish(v);
root.getElementById('wish').value = '';
weatherSeen.clear();
});
const proto = window.CanvasRenderingContext2D && CanvasRenderingContext2D.prototype;
// Without HTML-in-Canvas the pond still runs; the form just floats on top instead of sitting under the water.
const live = !!(proto && 'drawElementImage' in proto);
// ---------- Layer 1: pond floor + live card, via HTML-in-Canvas ----------
const c2 = document.createElement('canvas'); c2.id = 'page-canvas';
if (live) {
c2.setAttribute('content', 'drawable');
c2.setAttribute('layoutsubtree', '');
card.setAttribute('drawable', '');
stage.appendChild(c2);
c2.appendChild(card);
} else {
stage.insertBefore(c2, card);
card.classList.add('float');
}
const ctx = c2.getContext('2d');
let dirty = true, cardBox = { x: 0, y: 0, w: 0, h: 0 };
const floorCache = document.createElement('canvas');
function seeded(seed) { return () => { seed = (seed * 16807) % 2147483647; return (seed - 1) / 2147483646; }; }
function paintFloor() {
floorCache.width = c2.width; floorCache.height = c2.height;
const f = floorCache.getContext('2d');
const g = f.createRadialGradient(c2.width / 2, c2.height * .45, 0, c2.width / 2, c2.height * .45, Math.max(c2.width, c2.height) * .7);
g.addColorStop(0, '#1A4A42'); g.addColorStop(1, '#071C18');
f.fillStyle = g; f.fillRect(0, 0, c2.width, c2.height);
// Pebbles: soft dark and light stones scattered on the bed
const rnd = seeded(7);
const n = Math.round(c2.width * c2.height / (9000 * dpr * dpr));
for (let i = 0; i < n; i++) {
const x = rnd() * c2.width, y = rnd() * c2.height, r = (4 + rnd() * 16) * dpr;
f.fillStyle = rnd() < .5 ? 'rgba(4,18,15,.35)' : 'rgba(70,110,98,.18)';
f.beginPath(); f.ellipse(x, y, r, r * (.6 + rnd() * .4), rnd() * 3, 0, Math.PI * 2); f.fill();
}
}
c2.addEventListener('paint', () => {
try {
ctx.setTransform(1, 0, 0, 1, 0, 0);
ctx.drawImage(floorCache, 0, 0);
const cw = card.offsetWidth, ch = card.offsetHeight;
cardBox = { x: Math.round((W - cw) / 2), y: Math.round((H - ch) / 2), w: cw, h: ch };
ctx.drawElementImage(card, cardBox.x * dpr, cardBox.y * dpr, cw * dpr, ch * dpr);
dirty = true;
} catch (err) { console.warn('drawElementImage failed:', err); }
});
// ---------- Layer 2: koi + coins (plain 2D, updated every frame) ----------
const kc = document.createElement('canvas');
const kx = kc.getContext('2d');
const koi = [];
const coins = [];
function makeKoi(i) {
const rnd = seeded(31 + i * 97);
const k = {
x: rnd() * W, y: rnd() * H, a: rnd() * Math.PI * 2,
speed: 38 + rnd() * 18, len: 70 + rnd() * 30, turn: 0,
seed: rnd() * 10, white: i === 1, trail: [],
};
k.trail = Array.from({ length: 220 }, (_, j) => [k.x - Math.cos(k.a) * j * .6, k.y - Math.sin(k.a) * j * .6]);
return k;
}
// ---------- Layer 3: water (WebGL) ----------
const glc = document.createElement('canvas');
glc.id = 'gl';
stage.appendChild(glc);
const gl = glc.getContext('webgl', { antialias: false, premultipliedAlpha: false });
const fx = document.createElement('canvas');
fx.id = 'fx';
stage.appendChild(fx);
const fxc = fx.getContext('2d');
// ---------- World state ----------
const world = {
night: 0, nightTarget: 0, overrideUntil: 0,
rain: 0, storm: 0, stormTarget: 0, flash: 0,
typingUntil: 0, scatterUntil: 0, caret: { x: 0, y: 0 }, lastMove: 0,
};
const letters = [];
const wishSpots = [];
const flies = Array.from({ length: 28 }, (_, i) => ({ x: Math.random(), y: Math.random(), a: Math.random() * 6.28, ph: Math.random() * 6.28 }));
const VS = 'attribute vec2 p; varying vec2 uv; void main(){ uv = p * .5 + .5; gl_Position = vec4(p, 0., 1.); }';
const FS = `precision highp float;
varying vec2 uv;
uniform sampler2D floorTex, koiTex, hmap;
uniform vec2 texel;
uniform float t, aspect, night, storm, flash;
uniform vec4 pads[6];
uniform vec4 cardRect;
float caustic(vec2 p, float tm){
vec2 i = p; float c = 1.; float inten = .005;
for (int n = 0; n < 4; n++) {
float tt = tm * (1. - (3.5 / float(n + 1)));
i = p + vec2(cos(tt - i.x) + sin(tt + i.y), sin(tt - i.y) + cos(tt + i.x));
c += 1. / length(vec2(p.x / (sin(i.x + tt) / inten), p.y / (cos(i.y + tt) / inten)));
}
c /= 4.; c = 1.17 - pow(c, 1.4);
return pow(abs(c), 8.);
}
float pad(vec2 q, vec4 P){
vec2 d = q - P.xy;
float c = cos(P.w), s = sin(P.w);
d = vec2(c * d.x - s * d.y, s * d.x + c * d.y);
float r = length(d) - P.z;
float ang = atan(d.y, d.x);
float notch = step(abs(ang), .32) * step(length(d), P.z + .01);
return (1. - smoothstep(-.002, .002, r)) * (1. - notch);
}
void main(){
float hl = texture2D(hmap, uv - vec2(texel.x, 0.)).r;
float hr = texture2D(hmap, uv + vec2(texel.x, 0.)).r;
float hd = texture2D(hmap, uv - vec2(0., texel.y)).r;
float hu = texture2D(hmap, uv + vec2(0., texel.y)).r;
vec2 g = vec2(hr - hl, hu - hd);
vec2 off = g * .2;
vec2 ruv = uv + off;
vec3 col = vec3(
texture2D(floorTex, ruv + off * .2).r,
texture2D(floorTex, ruv).g,
texture2D(floorTex, ruv - off * .2).b);
float sh = texture2D(koiTex, ruv + vec2(-.006, .012)).a;
col *= 1. - sh * .38 * (1. - night * .7);
vec4 k = texture2D(koiTex, ruv + off * .4);
col = mix(col, k.rgb, k.a);
vec3 base = col;
vec2 cp = mod(vec2(ruv.x * aspect, ruv.y) * 6.2831 * 1.6, 6.2831) - 250.;
float cs = caustic(cp, t * .45 + 23.);
col += cs * .2 * vec3(.82, 1., .9) * (1. - night) * (1. - storm * .8);
// time of day: noon -> dusk -> moonlight
float dusk = smoothstep(0., .5, night) * smoothstep(1., .5, night);
vec3 tint = mix(vec3(.86, .97, .94), vec3(.16, .24, .42), night);
tint = mix(tint, vec3(1., .66, .48), dusk * .7);
tint *= 1. - storm * .3;
float vig = smoothstep(1.15, .25, length((uv - .5) * vec2(aspect, 1.)));
col *= tint * mix(.62, 1., vig);
// the form glows faintly underwater at night
vec2 cq = (ruv - cardRect.xy) / cardRect.zw;
vec2 cd = max(abs(cq - .5) - .5, 0.) * cardRect.zw * vec2(aspect, 1.);
float inside = step(length(cd), 0.0001);
float halo = exp(-length(cd) * 22.);
col += night * (inside * base * vec3(.42, .62, .6) + halo * .16 * vec3(.45, .95, .8));
vec2 q = vec2(uv.x * aspect, uv.y);
float pshadow = 0.;
float pm = 0.; vec3 padCol = vec3(0.);
for (int i = 0; i < 6; i++) {
pshadow = max(pshadow, pad(q + vec2(.012, -.018) + off * .5, pads[i]));
float m = pad(q + off * .15, pads[i]);
if (m > pm) {
vec2 d = q - pads[i].xy;
float rr = length(d) / pads[i].z;
float veins = .04 * sin(atan(d.y, d.x) * 14.);
padCol = mix(vec3(.30, .55, .27), vec3(.18, .40, .19), rr) + veins;
padCol += .12 * smoothstep(.85, 1., rr);
pm = m;
}
}
padCol *= mix(vec3(1.), vec3(.25, .32, .45), night);
padCol = mix(padCol, padCol * vec3(1.15, .85, .7), dusk * .6);
col *= 1. - pshadow * .45 * (1. - night * .6);
col = mix(col, padCol, pm);
vec3 n = normalize(vec3(-g * 10., 1.));
vec3 L = normalize(vec3(-.4, .55, 1.));
float spec = pow(max(dot(reflect(-L, n), vec3(0., 0., 1.)), 0.), 70.);
vec3 glint = mix(vec3(1.), vec3(.75, .85, 1.), night);
col += spec * .7 * (1. - pm) * glint * (1. - storm * .5);
col += flash * vec3(.75, .8, .95);
gl_FragColor = vec4(col, 1.);
}`;
let texFloor, texKoi, texH, U = {};
function fail(err) {
console.warn('Water off:', err);
glc.remove();
modeEl.textContent = 'HTML-in-Canvas is on, but WebGL failed, so the water is off.';
}
try {
if (!gl) throw new Error('no webgl');
const sh = (type, src) => { const s = gl.createShader(type); gl.shaderSource(s, src); gl.compileShader(s); if (!gl.getShaderParameter(s, gl.COMPILE_STATUS)) throw new Error(gl.getShaderInfoLog(s)); return s; };
const prog = gl.createProgram();
gl.attachShader(prog, sh(gl.VERTEX_SHADER, VS));
gl.attachShader(prog, sh(gl.FRAGMENT_SHADER, FS));
gl.linkProgram(prog);
if (!gl.getProgramParameter(prog, gl.LINK_STATUS)) throw new Error(gl.getProgramInfoLog(prog));
gl.useProgram(prog);
gl.bindBuffer(gl.ARRAY_BUFFER, gl.createBuffer());
gl.bufferData(gl.ARRAY_BUFFER, new Float32Array([-1, -1, 1, -1, -1, 1, 1, 1]), gl.STATIC_DRAW);
const loc = gl.getAttribLocation(prog, 'p');
gl.enableVertexAttribArray(loc);
gl.vertexAttribPointer(loc, 2, gl.FLOAT, false, 0, 0);
const mk = (unit) => {
const tx = gl.createTexture();
gl.activeTexture(gl.TEXTURE0 + unit); gl.bindTexture(gl.TEXTURE_2D, tx);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);
return tx;
};
texFloor = mk(0); texKoi = mk(1); texH = mk(2);
gl.uniform1i(gl.getUniformLocation(prog, 'floorTex'), 0);
gl.uniform1i(gl.getUniformLocation(prog, 'koiTex'), 1);
gl.uniform1i(gl.getUniformLocation(prog, 'hmap'), 2);
['texel', 't', 'aspect', 'pads', 'night', 'storm', 'flash', 'cardRect'].forEach(n => U[n] = gl.getUniformLocation(prog, n === 'pads' ? 'pads[0]' : n));
gl.pixelStorei(gl.UNPACK_FLIP_Y_WEBGL, true);
gl.pixelStorei(gl.UNPACK_ALIGNMENT, 1);
modeEl.textContent = live
? 'Running on HTML-in-Canvas. The form on the pond floor is real: type a wish, select the text, toss a coin. Drag through the water to make ripples.'
: 'Your browser can\'t put live HTML under water yet, so the form floats on top. In Chrome with HTML-in-Canvas it sinks into the pond.';
} catch (err) { fail(err); }
// ---------- Ripple sim ----------
let GW = 0, GH = 0, cur, prev, bytes;
function drop(cx, cy, radius, a) {
if (!cur) return;
const gx = cx / W * GW, gy = cy / H * GH, r = Math.ceil(radius);
for (let y = Math.max(1, Math.floor(gy - r)); y < Math.min(GH - 1, gy + r); y++)
for (let x = Math.max(1, Math.floor(gx - r)); x < Math.min(GW - 1, gx + r); x++) {
const d = Math.hypot(x - gx, y - gy) / radius;
if (d < 1) cur[y * GW + x] += a * (Math.cos(d * Math.PI) * .5 + .5);
}
}
function step() {
for (let y = 1; y < GH - 1; y++) {
const row = y * GW;
for (let x = 1; x < GW - 1; x++) {
const i = row + x;
prev[i] = ((cur[i - 1] + cur[i + 1] + cur[i - GW] + cur[i + GW]) * .5 - prev[i]) * .986;
}
}
const tmp = cur; cur = prev; prev = tmp;
}
function heightAt(x, y) {
if (!cur) return 0;
const gx = Math.max(1, Math.min(GW - 2, Math.round(x / W * GW)));
const gy = Math.max(1, Math.min(GH - 2, Math.round(y / H * GH)));
return { dx: cur[gy * GW + gx + 1] - cur[gy * GW + gx - 1], dy: cur[(gy + 1) * GW + gx] - cur[(gy - 1) * GW + gx] };
}
// ---------- Lily pads ----------
let pads = [];
function makePads() {
const rnd = seeded(5);
pads = [];
for (let i = 0; i < 6; i++) {
let x, y, tries = 0;
do { x = rnd() * W; y = rnd() * H; tries++; }
while (tries < 30 && Math.abs(x - W / 2) < 230 && Math.abs(y - H / 2) < 220);
pads.push({ x, y, r: 34 + rnd() * 34, rot: rnd() * 6.28, vx: (rnd() - .5) * 6, vy: (rnd() - .5) * 6, vr: (rnd() - .5) * .05 });
}
}
// ---------- Sizing ----------
function size() {
dpr = Math.min(window.devicePixelRatio || 1, 2);
W = stage.clientWidth; H = stage.clientHeight;
c2.width = Math.round(W * dpr); c2.height = Math.round(H * dpr);
glc.width = c2.width; glc.height = c2.height;
kc.width = W; kc.height = H;
fx.width = Math.round(W * dpr); fx.height = Math.round(H * dpr);
GW = 220; GH = Math.max(40, Math.round(GW * H / Math.max(W, 1)));
cur = new Float32Array(GW * GH); prev = new Float32Array(GW * GH); bytes = new Uint8Array(GW * GH);
if (gl) gl.viewport(0, 0, glc.width, glc.height);
paintFloor();
if (!koi.length) for (let i = 0; i < 4; i++) koi.push(makeKoi(i));
makePads();
dirty = true;
if (live && c2.requestPaint) c2.requestPaint();
if (!live) {
ctx.drawImage(floorCache, 0, 0);
const sr = stage.getBoundingClientRect(), cr = card.getBoundingClientRect();
cardBox = { x: cr.left - sr.left, y: cr.top - sr.top, w: cr.width, h: cr.height };
}
}
// ---------- Input ----------
let px = -1e4, py = -1e4, lx = null, ly = null;
window.addEventListener('pointermove', (e) => {
const r = stage.getBoundingClientRect();
const x = e.clientX - r.left, y = e.clientY - r.top;
px = x; py = y; world.lastMove = performance.now();
if (x < 0 || y < 0 || x > W || y > H) { lx = null; return; }
if (lx != null) {
const sp = Math.min(Math.hypot(x - lx, y - ly), 50);
if (sp > 1) drop(x, y, 3.4, sp * .8 * amp);
if (sp > 16) Sound.plink(x / W, Math.min(1, sp / 50) * .6);
}
lx = x; ly = y;
}, { passive: true });
window.addEventListener('pointerdown', (e) => {
const r = stage.getBoundingClientRect();
drop(e.clientX - r.left, e.clientY - r.top, 6, 55 * amp);
if (!(e.target.closest && e.target.closest('button'))) Sound.plink((e.clientX - r.left) / W, 1);
});
const wishEl = root.getElementById('wish');
const measure = document.createElement('canvas').getContext('2d');
function caretAt(idx) {
const r = stage.getBoundingClientRect(), b = wishEl.getBoundingClientRect();
const cs = getComputedStyle(wishEl);
measure.font = `${cs.fontWeight} ${cs.fontSize} ${cs.fontFamily}`;
const w = measure.measureText(wishEl.value.slice(0, idx)).width;
return { x: b.left - r.left + parseFloat(cs.paddingLeft) + w - wishEl.scrollLeft, y: b.top - r.top + b.height / 2 };
}
window.addEventListener('keydown', (e) => {
if (document.activeElement !== wishEl) return;
const now = performance.now();
if (e.key === 'Backspace' || e.key === 'Delete') world.scatterUntil = now + 1400;
else world.typingUntil = now + 2600;
if (e.key.length === 1) Sound.key(e.key, (wishEl.getBoundingClientRect().left + 40) / W);
requestAnimationFrame(() => {
world.caret = caretAt(wishEl.selectionStart || 0);
drop(world.caret.x, world.caret.y, 4.2, 50 * amp);
});
});
// Words change the weather
const seen = new Set(), weatherSeen = seen;
wishEl.addEventListener('input', () => {
const v = wishEl.value.toLowerCase();
const has = (w) => new RegExp(`\\b${w}\\b`).test(v);
const once = (key, fn) => { if (!seen.has(key)) { seen.add(key); fn(); } };
const now = performance.now();
if (has('night') || has('moon') || has('dark') || has('star') || has('stars')) once('night', () => { world.nightTarget = 1; world.overrideUntil = now + 40000; });
if (has('day') || has('sun') || has('sunny') || has('sunrise') || has('morning') || has('light') || has('bright')) once('day', () => { world.nightTarget = 0; world.overrideUntil = now + 40000; });
if (has('dusk') || has('sunset') || has('evening')) once('dusk', () => { world.nightTarget = .5; world.overrideUntil = now + 40000; });
if (has('rain') || has('rainy') || has('raining')) once('rain', () => setRain(Math.max(world.rain, 1)));
if (has('storm') || has('stormy') || has('storms') || has('thunder') || has('lightning')) once('storm', () => setRain(2));
if (has('calm') || has('clear') || has('peace')) once('calm', () => setRain(0));
if (!v) seen.clear();
});
function landingSpot(width) {
for (let tries = 0; tries < 60; tries++) {
const x = 40 + Math.random() * Math.max(10, W - width - 80);
const y = 50 + Math.random() * Math.max(10, H - 100);
const pad = 26;
const hitsCard = x + width > cardBox.x - pad && x < cardBox.x + cardBox.w + pad && y > cardBox.y - pad && y < cardBox.y + cardBox.h + pad;
const hitsWish = wishSpots.some(sp => Math.abs(sp.y - y) < 34 && x < sp.x + sp.w + 20 && x + width > sp.x - 20);
if (!hitsCard && !hitsWish) return { x, y };
}
return { x: 40 + Math.random() * (W - width - 80), y: H - 60 };
}
onWish = (text) => {
Sound.coin(.5);
const now = performance.now();
const cs = getComputedStyle(wishEl);
measure.font = `${cs.fontWeight} ${cs.fontSize} ${cs.fontFamily}`;
const chars = [...text];
const full = measure.measureText(text).width * .8 + 30;
const spot = landingSpot(full);
wishSpots.push({ x: spot.x, y: spot.y, w: full });
if (wishSpots.length > 14) wishSpots.shift();
let acc = 0;
chars.forEach((ch, i) => {
const from = caretAt(i);
const cw = measure.measureText(ch).width;
letters.push({
ch, fx: from.x + cw / 2, fy: from.y,
tx: spot.x + 26 + acc * .8 + (Math.random() - .5) * 3, ty: spot.y + (Math.random() - .5) * 6,
rot: (Math.random() - .5) * .35, born: now + i * 45, dur: 1500 + Math.random() * 500, seed: Math.random() * 6,
font: measure.font,
});
acc += cw;
});
while (letters.length > 400) letters.shift();
// the coin goes in first, right where the wish will land
const c = caretAt(chars.length);
drop(c.x, c.y, 8, 120 * amp);
coins.push({ x: spot.x + 8, y: spot.y, born: now, rot: Math.random() * 3, fx: c.x, fy: c.y });
if (coins.length > 40) coins.shift();
setTimeout(() => drop(spot.x + 8, spot.y, 7, -70 * amp), 500);
world.typingUntil = 0;
};
function setRain(level) {
world.rain = level;
world.stormTarget = level === 2 ? 1 : 0;
rainBtn.textContent = level ? 'Stop rain' : 'Rain';
rainBtn.setAttribute('aria-pressed', level ? 'true' : 'false');
}
const rainBtn = root.getElementById('rain');
rainBtn.addEventListener('click', () => setRain(world.rain ? 0 : 1));
// ---------- Koi + coins drawing ----------
function updateKoi(k, dt) {
const margin = 80;
// wander
k.turn += (Math.sin(performance.now() / 1300 + k.seed * 3) * .9 - k.turn) * dt;
let steer = k.turn * .9;
// stay in the pond
const cx = W / 2 - k.x, cy = H / 2 - k.y;
if (k.x < margin || k.x > W - margin || k.y < margin || k.y > H - margin) {
const want = Math.atan2(cy, cx);
let d = want - k.a; d = Math.atan2(Math.sin(d), Math.cos(d));
steer += d * 2.2;
}
let boost = 1;
const nowK = performance.now();
const cd = Math.hypot(world.caret.x - k.x, world.caret.y - k.y);
if (nowK < world.scatterUntil && cd < 260) {
const away = Math.atan2(k.y - world.caret.y, k.x - world.caret.x);
let d = away - k.a; d = Math.atan2(Math.sin(d), Math.cos(d));
steer += d * 4; boost = 3;
} else if (nowK < world.typingUntil) {
// swim up to the caret and nibble
const to = Math.atan2(world.caret.y - k.y, world.caret.x - k.x);
let d = to - k.a; d = Math.atan2(Math.sin(d), Math.cos(d));
steer += d * (cd > 60 ? 2.6 : 1.2);
boost = cd > 90 ? 1.8 : .55;
if (cd < 30 && Math.random() < .06) drop(k.x, k.y, 2.6, 22 * amp);
}
// shy of a moving cursor
const pd = Math.hypot(px - k.x, py - k.y);
if (pd < 140 && nowK - world.lastMove < 700) {
const away = Math.atan2(k.y - py, k.x - px);
let d = away - k.a; d = Math.atan2(Math.sin(d), Math.cos(d));
steer += d * 3.5; boost = 2.6 - pd / 140;
}
k.a += steer * dt;
const sp = k.speed * boost;
k.x += Math.cos(k.a) * sp * dt;
k.y += Math.sin(k.a) * sp * dt;
k.trail.unshift([k.x, k.y]);
if (k.trail.length > 220) k.trail.length = 220;
}
function drawKoi(k, now) {
if (k.trail.length < 4) return;
const segs = 18;
const pts = [];
let acc = 0, j = 0;
const step = k.len / segs;
pts.push(k.trail[0]);
for (let i = 1; i < k.trail.length && pts.length <= segs; i++) {
acc += Math.hypot(k.trail[i][0] - k.trail[i - 1][0], k.trail[i][1] - k.trail[i - 1][1]);
if (acc >= step * (pts.length)) pts.push(k.trail[i]);
}
if (pts.length < 6) return;
const n = pts.length;
const orange = '#EE6A21', white = '#F2EEE6', red = '#D2401C';
// tail fin
const tA = pts[n - 1], tB = pts[n - 3];
const ta = Math.atan2(tA[1] - tB[1], tA[0] - tB[0]);
const sway = Math.sin(now / 160 + k.seed) * .45;
kx.fillStyle = k.white ? 'rgba(242,238,230,.85)' : 'rgba(238,106,33,.8)';
kx.beginPath();
kx.moveTo(tA[0], tA[1]);
kx.lineTo(tA[0] + Math.cos(ta + .5 + sway) * k.len * .28, tA[1] + Math.sin(ta + .5 + sway) * k.len * .28);
kx.lineTo(tA[0] + Math.cos(ta + sway) * k.len * .16, tA[1] + Math.sin(ta + sway) * k.len * .16);
kx.lineTo(tA[0] + Math.cos(ta - .5 + sway) * k.len * .28, tA[1] + Math.sin(ta - .5 + sway) * k.len * .28);
kx.closePath(); kx.fill();
// pectoral fins
const h0 = pts[0], h1 = pts[3];
const ha = Math.atan2(h0[1] - h1[1], h0[0] - h1[0]);
const fin = Math.sin(now / 220 + k.seed) * .3;
kx.fillStyle = 'rgba(242,238,230,.75)';
[1, -1].forEach(sd => {
const bx = pts[3][0], by = pts[3][1];
kx.beginPath();
kx.ellipse(bx + Math.cos(ha + sd * 1.6) * k.len * .1, by + Math.sin(ha + sd * 1.6) * k.len * .1,
k.len * .11, k.len * .05, ha + sd * (1.1 + fin), 0, Math.PI * 2);
kx.fill();
});
// body
for (let i = n - 1; i >= 0; i--) {
const tt = i / (n - 1);
const r = k.len * .115 * Math.sin(Math.PI * (tt * .85 + .12)) + 1.5;
const patch = Math.sin(i * 1.15 + k.seed * 2.3) > .25;
kx.fillStyle = k.white ? (patch ? red : white) : (patch ? white : orange);
kx.beginPath(); kx.arc(pts[i][0], pts[i][1], r, 0, Math.PI * 2); kx.fill();
}
}
const ease = (x) => 1 - Math.pow(1 - x, 3);
function mixHex(a, b, t) {
const pa = [1, 3, 5].map(i => parseInt(a.slice(i, i + 2), 16)), pb = [1, 3, 5].map(i => parseInt(b.slice(i, i + 2), 16));
return `rgb(${pa.map((v, i) => Math.round(v + (pb[i] - v) * t)).join(',')})`;
}
function drawCoins(now) {
for (const c of coins) {
const age = Math.min((now - c.born) / 700, 1), e = ease(age);
const x = c.fx + (c.x - c.fx) * e, y = c.fy + (c.y - c.fy) * e - Math.sin(age * Math.PI) * 40;
const s = 1.9 - .9 * e;
const flip = Math.abs(Math.cos(age * 9 + c.rot));
kx.globalAlpha = .5 + .5 * e;
kx.fillStyle = '#B98A2B';
kx.beginPath(); kx.ellipse(x, y, 9 * s, 7 * s * (age < 1 ? flip + .15 : 1), c.rot, 0, Math.PI * 2); kx.fill();
kx.fillStyle = '#E8B341';
kx.beginPath(); kx.ellipse(x - 1, y - 1, 7 * s, 5.4 * s * (age < 1 ? flip + .1 : 1), c.rot, 0, Math.PI * 2); kx.fill();
}
kx.globalAlpha = 1;
}
function drawLetters(now) {
kx.textAlign = 'center'; kx.textBaseline = 'middle';
for (const l of letters) {
const age = (now - l.born) / l.dur;
if (age < 0) continue;
const p = Math.min(age, 1), e = ease(p);
const wob = Math.sin(p * Math.PI * 2.2 + l.seed) * 14 * (1 - p);
const x = l.fx + (l.tx - l.fx) * e + wob;
const y = l.fy + (l.ty - l.fy) * e;
const sc = 1.15 - .35 * e;
kx.save();
kx.translate(x, y); kx.rotate(l.rot * e + Math.sin(p * 6 + l.seed) * .4 * (1 - p)); kx.scale(sc, sc);
kx.font = l.font;
// a soft shadow once it nears the bed
kx.globalAlpha = .35 * e;
kx.fillStyle = '#031210';
kx.fillText(l.ch, 2, 3);
kx.globalAlpha = 1 - .2 * e;
kx.fillStyle = mixHex('#0F1F1C', '#D9EAE3', Math.min(1, p * 2.2));
kx.fillText(l.ch, 0, 0);
kx.restore();
}
kx.globalAlpha = 1;
}
function drawFlies(now, dt) {
fxc.setTransform(1, 0, 0, 1, 0, 0);
fxc.clearRect(0, 0, fx.width, fx.height);
if (world.night < .05) return;
fxc.setTransform(dpr, 0, 0, dpr, 0, 0);
for (const f of flies) {
f.a += (Math.sin(now / 900 + f.ph * 3) * 1.5) * dt;
f.x += Math.cos(f.a) * .02 * dt; f.y += Math.sin(f.a) * .02 * dt;
if (f.x < 0) f.x += 1; if (f.x > 1) f.x -= 1; if (f.y < 0) f.y += 1; if (f.y > 1) f.y -= 1;
const glow = Math.pow(Math.max(0, Math.sin(now / 700 + f.ph)), 3) * world.night * (1 - world.storm);
if (glow < .02) continue;
const x = f.x * W, y = f.y * H;
const g = fxc.createRadialGradient(x, y, 0, x, y, 16);
g.addColorStop(0, `rgba(236,248,150,${glow})`);
g.addColorStop(.25, `rgba(210,240,110,${glow * .45})`);
g.addColorStop(1, 'rgba(210,240,110,0)');
fxc.fillStyle = g;
fxc.fillRect(x - 16, y - 16, 32, 32);
}
}
// ---------- Frame loop ----------
let last = performance.now();
const padData = new Float32Array(24);
function frame(now) {
const dt = Math.min((now - last) / 1000, .05); last = now;
if (glc.isConnected && cur) {
try {
step(); step();
for (let i = 0; i < cur.length; i++) { const v = cur[i] * 1.5 + 128; bytes[i] = v < 0 ? 0 : v > 255 ? 255 : v; }
// weather + time of day
if (now > world.overrideUntil) {
const ph = (now / 1000 % 70) / 70; // noon, dusk, moon, dawn
world.nightTarget = ph < .3 ? 0 : ph < .45 ? (ph - .3) / .15 : ph < .85 ? 1 : 1 - (ph - .85) / .15;
}
world.night += (world.nightTarget - world.night) * Math.min(1, dt * .8);
world.storm += (world.stormTarget - world.storm) * Math.min(1, dt * 1.2);
const drops = world.rain === 2 ? 4 : world.rain === 1 ? 1 : 0;
for (let i = 0; i < drops; i++) drop(Math.random() * W, Math.random() * H, world.rain === 2 ? 3 : 2.4, (world.rain === 2 ? 45 : 28) * (.6 + Math.random() * .6) * amp);
if (world.rain === 2 && !reduce && Math.random() < .006) {
world.flash = 1;
Sound.thunder();
setTimeout(() => { world.flash = .7; }, 140);
}
world.flash *= .86;
Sound.update(dt, world);
// koi + coins + sunk wishes layer
kx.clearRect(0, 0, W, H);
drawLetters(now);
drawCoins(now);
for (const k of koi) { if (!reduce) updateKoi(k, dt); drawKoi(k, now); }
// pads drift and get pushed by ripples
pads.forEach((p, i) => {
const g = heightAt(p.x, p.y);
p.vx -= g.dx * .25; p.vy -= g.dy * .25;
p.vx *= .985; p.vy *= .985;
p.x += p.vx * dt; p.y += p.vy * dt; p.rot += p.vr * dt;
if (p.x < -p.r) p.x = W + p.r; if (p.x > W + p.r) p.x = -p.r;
if (p.y < -p.r) p.y = H + p.r; if (p.y > H + p.r) p.y = -p.r;
padData.set([p.x / H, 1 - p.y / H, p.r / H, p.rot], i * 4);
});
gl.activeTexture(gl.TEXTURE2); gl.bindTexture(gl.TEXTURE_2D, texH);
gl.texImage2D(gl.TEXTURE_2D, 0, gl.LUMINANCE, GW, GH, 0, gl.LUMINANCE, gl.UNSIGNED_BYTE, bytes);
gl.activeTexture(gl.TEXTURE1); gl.bindTexture(gl.TEXTURE_2D, texKoi);
gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, kc);
if (dirty) {
gl.activeTexture(gl.TEXTURE0); gl.bindTexture(gl.TEXTURE_2D, texFloor);
gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, c2);
dirty = false;
}
gl.uniform2f(U.texel, 1 / GW, 1 / GH);
gl.uniform1f(U.t, now / 1000);
gl.uniform1f(U.aspect, W / H);
gl.uniform4fv(U.pads, padData);
gl.uniform1f(U.night, world.night);
gl.uniform1f(U.storm, world.storm);
gl.uniform1f(U.flash, world.flash);
gl.uniform4f(U.cardRect, cardBox.x / W, 1 - (cardBox.y + cardBox.h) / H, cardBox.w / W, cardBox.h / H);
drawFlies(now, dt);
gl.drawArrays(gl.TRIANGLE_STRIP, 0, 4);
} catch (err) { fail(err); }
}
requestAnimationFrame(frame);
}
new ResizeObserver(size).observe(stage);
size();
requestAnimationFrame(frame);
if (!reduce) setTimeout(() => drop(W * .5, H * .5 - 200, 7, 55), 600);
})();
</script>
</body>
</html>