Wave Tank
by ArcadeLab793 lines24.7 KB
<!--ARCADELAB
title: Wave Tank
description: A top-down water tank — tap for ripples, dial the frequency and amplitude, drop a wall with a slit to watch waves bend through it, and see the wave speed.
emoji: 🌊
color: teal
-->
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no">
<title>Wave Tank</title>
<style>
:root {
--bg: #03161a;
--panel: rgba(8, 42, 49, 0.86);
--accent: #4fe3d6;
--accent2: #38c8e0;
--text: #e3fbf8;
--muted: #9fd6d2;
}
* { box-sizing: border-box; }
html, body {
margin: 0;
height: 100%;
overflow: hidden;
background: var(--bg);
font-family: system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
color: var(--text);
-webkit-tap-highlight-color: transparent;
}
#stage { position: fixed; inset: 0; }
canvas {
display: block;
width: 100%;
height: 100%;
touch-action: none;
}
#hud {
position: fixed;
top: 0; left: 0; right: 0;
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 8px 10px;
padding: clamp(10px, 2.4vw, 18px);
pointer-events: none;
z-index: 5;
}
.title {
display: flex;
align-items: center;
gap: 8px;
font-weight: 700;
font-size: clamp(17px, 4.6vw, 22px);
letter-spacing: 0.2px;
background: var(--panel);
border: 1px solid rgba(79, 227, 214, 0.30);
border-radius: 14px;
padding: 8px 13px;
backdrop-filter: blur(10px);
-webkit-backdrop-filter: blur(10px);
}
.title .em { font-size: 1.15em; }
.spacer { flex: 1 1 auto; }
.controls {
display: flex;
flex-wrap: wrap;
gap: 8px;
pointer-events: auto;
}
button {
pointer-events: auto;
font: inherit;
font-size: clamp(14px, 3.6vw, 16px);
font-weight: 600;
color: var(--text);
background: var(--panel);
border: 1px solid rgba(79, 227, 214, 0.34);
border-radius: 13px;
padding: 11px 16px;
min-height: 46px;
cursor: pointer;
backdrop-filter: blur(10px);
-webkit-backdrop-filter: blur(10px);
transition: transform .12s ease, background .18s ease, border-color .18s ease;
}
button:hover { background: rgba(12, 60, 70, 0.95); border-color: var(--accent); }
button:active { transform: scale(0.94); }
button.on {
background: linear-gradient(180deg, #4fe3d6, #2bb6cf);
color: #02232a;
border-color: #9af2e9;
}
#info {
position: fixed;
left: 0; right: 0; bottom: 0;
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
padding: clamp(10px, 2.4vw, 18px);
pointer-events: none;
z-index: 5;
}
#hint {
background: var(--panel);
border: 1px solid rgba(79, 227, 214, 0.24);
border-radius: 13px;
padding: 9px 15px;
font-size: clamp(13px, 3.4vw, 15px);
color: var(--muted);
text-align: center;
backdrop-filter: blur(10px);
-webkit-backdrop-filter: blur(10px);
max-width: 92vw;
}
/* slider panel in the bottom info bar */
#sliders {
pointer-events: auto;
display: flex;
flex-wrap: wrap;
justify-content: center;
gap: 8px 16px;
background: var(--panel);
border: 1px solid rgba(79, 227, 214, 0.30);
border-radius: 14px;
padding: 9px 15px;
backdrop-filter: blur(10px);
-webkit-backdrop-filter: blur(10px);
max-width: 94vw;
}
.slider-row {
display: flex;
align-items: center;
gap: 9px;
}
.slider-row .lbl {
font-size: clamp(12px, 3.2vw, 14px);
font-weight: 600;
color: var(--muted);
white-space: nowrap;
min-width: 62px;
}
input[type="range"].wave {
-webkit-appearance: none;
appearance: none;
width: clamp(96px, 30vw, 170px);
height: 14px;
border-radius: 9px;
background: linear-gradient(90deg, #0a4048, #4fe3d6);
outline: none;
margin: 0;
}
input[type="range"].wave::-webkit-slider-thumb {
-webkit-appearance: none;
appearance: none;
width: 30px; height: 30px;
border-radius: 50%;
background: radial-gradient(circle at 35% 30%, #e3fbf8, #2bb6cf);
border: 2px solid #e3fbf8;
cursor: pointer;
box-shadow: 0 1px 5px rgba(0,0,0,0.45);
}
input[type="range"].wave::-moz-range-thumb {
width: 30px; height: 30px;
border-radius: 50%;
background: radial-gradient(circle at 35% 30%, #e3fbf8, #2bb6cf);
border: 2px solid #e3fbf8;
cursor: pointer;
box-shadow: 0 1px 5px rgba(0,0,0,0.45);
}
#howBtn { pointer-events: auto; }
#howPanel {
pointer-events: auto;
display: none;
background: var(--panel);
border: 1px solid rgba(79, 227, 214, 0.30);
border-radius: 14px;
padding: 14px 16px;
max-width: min(92vw, 420px);
font-size: clamp(13px, 3.4vw, 15px);
line-height: 1.5;
color: var(--text);
backdrop-filter: blur(10px);
-webkit-backdrop-filter: blur(10px);
}
#howPanel.show { display: block; }
#howPanel b { color: var(--accent); }
#howPanel p { margin: 0 0 8px; }
#howPanel p:last-child { margin-bottom: 0; }
#stage { opacity: 0; transition: opacity .8s ease; }
#stage.ready { opacity: 1; }
</style>
</head>
<body>
<div id="stage">
<canvas id="c"></canvas>
</div>
<div id="hud">
<div class="title"><span class="em">🌊</span><span>Wave Tank</span></div>
<div class="spacer"></div>
<div class="controls">
<button id="srcBtn" type="button">2nd Source: Off</button>
<button id="barrierBtn" type="button">Wall: Off</button>
<button id="calmBtn" type="button">Calm</button>
</div>
</div>
<div id="info">
<div id="howPanel">
<p><b>Tap the water</b> to make a ripple. It spreads out in a growing circle, just like a stone dropped in a pond.</p>
<p>Turn on the <b>2nd source</b> and two sets of ripples will cross each other.</p>
<p>Where two crests meet, the wave gets <b>extra big and bright</b> — that is constructive interference.</p>
<p>Where a crest meets a dip, they <b>cancel out</b> and the water goes flat and dark.</p>
<p>The <b>Frequency</b> slider sets how close together the ripples are. The <b>Amplitude</b> slider sets how tall and strong they are.</p>
<p>Turn on the <b>Wall</b> — it has a small gap. Waves squeeze through the slit and <b>bend out in a fan</b> on the far side. That bending is called diffraction.</p>
<p>The readout shows the <b>wave speed</b> — how fast each crest races across the tank.</p>
</div>
<div id="hint">Tap the water to make a ripple</div>
<div id="sliders">
<div class="slider-row">
<span class="lbl">Frequency</span>
<input id="freq" class="wave" type="range" min="0" max="100" value="50" aria-label="Frequency">
</div>
<div class="slider-row">
<span class="lbl">Amplitude</span>
<input id="amp" class="wave" type="range" min="0" max="100" value="60" aria-label="Amplitude">
</div>
</div>
<button id="howBtn" type="button">How it works</button>
</div>
<script>
(function () {
"use strict";
var stage = document.getElementById("stage");
var canvas = document.getElementById("c");
var ctx = canvas.getContext("2d");
if (!ctx) return;
var srcBtn = document.getElementById("srcBtn");
var barrierBtn = document.getElementById("barrierBtn");
var calmBtn = document.getElementById("calmBtn");
var hint = document.getElementById("hint");
var howBtn = document.getElementById("howBtn");
var howPanel = document.getElementById("howPanel");
var freqSlider = document.getElementById("freq");
var ampSlider = document.getElementById("amp");
var W = 0, H = 0, DPR = 1;
// --- wave model ---
// We render the surface height on a coarse grid into an ImageData buffer,
// then let the browser scale it up. Each "source" emits radial waves.
// Height at a point = sum over sources of A * sin(phase) * envelope.
var CELL = 7; // device-independent pixels per grid cell
var cols = 0, rows = 0;
var grid = null; // Float32Array of heights, length cols*rows
var img = null; // ImageData for the coarse field
var buf = null; // backing canvas for the coarse field
var bufCtx = null;
var WAVELENGTH = 58; // px between crests (changed by the Frequency slider)
var SPEED = 150; // px per second the wavefront travels (fixed)
var DAMP = 360; // px distance over which a ripple fades
// user controls (0..1 fractions from the sliders)
var freqLevel = 0.5; // 0 = long lazy waves, 1 = short busy waves
var ampLevel = 0.6; // 0 = barely a ripple, 1 = big strong waves
// barrier: a horizontal wall with a centered gap. When on, continuous-source
// waves above the wall are blocked, and pass through the slit, fanning out
// from the gap (Huygens-style) so kids see diffraction.
var barrierOn = false;
var barrierY = 0; // y of the wall (set on resize)
var gapHalf = 0; // half-width of the slit
var gapX = 0; // x center of the slit
// continuous sources (the optional 2nd source, and 1st when toggled on)
// each: {x,y, on}
var contSources = [];
// transient ripples from taps: {x,y, t0} -- t0 is start time (seconds)
var ripples = [];
var twoSources = false;
var startTime = 0;
var nowSec = 0;
function resize() {
var rect = stage.getBoundingClientRect();
W = Math.max(1, rect.width);
H = Math.max(1, rect.height);
DPR = Math.min(window.devicePixelRatio || 1, 2);
canvas.width = Math.round(W * DPR);
canvas.height = Math.round(H * DPR);
canvas.style.width = W + "px";
canvas.style.height = H + "px";
ctx.setTransform(DPR, 0, 0, DPR, 0, 0);
ctx.imageSmoothingEnabled = true;
// coarse grid sized in CSS pixels
cols = Math.max(2, Math.ceil(W / CELL) + 1);
rows = Math.max(2, Math.ceil(H / CELL) + 1);
grid = new Float32Array(cols * rows);
buf = document.createElement("canvas");
buf.width = cols;
buf.height = rows;
bufCtx = buf.getContext("2d");
if (bufCtx) {
img = bufCtx.createImageData(cols, rows);
}
placeSources();
}
// Position the continuous sources. Source 0 lives left-of-center, source 1
// right-of-center; both off until enabled. They sit above the barrier line
// so that, with the wall on, their waves must pass through the slit.
function placeSources() {
var sy = H * 0.40;
contSources = [
{ x: W * 0.36, y: sy, on: false },
{ x: W * 0.64, y: sy, on: false }
];
if (twoSources) {
contSources[0].on = true;
contSources[1].on = true;
}
// barrier geometry: a horizontal wall low in the tank with a centered slit
barrierY = H * 0.64;
gapX = W * 0.5;
gapHalf = Math.max(22, Math.min(46, W * 0.07));
}
function rippleAt(x, y) {
ripples.push({ x: x, y: y, t0: nowSec });
// keep the list bounded
if (ripples.length > 24) ripples.splice(0, ripples.length - 24);
}
// --- height field ---
var TWO_PI = Math.PI * 2;
var K = 0; // angular wavenumber, set on resize-independent constant
var OMEGA = 0;
function recomputeConstants() {
// Frequency slider sets the wavelength: low slider = long lazy waves
// (90px crests), high slider = short busy waves (34px crests).
WAVELENGTH = 90 - freqLevel * 56;
K = TWO_PI / WAVELENGTH;
OMEGA = K * SPEED; // so phase = k*r - omega*t keeps crest speed = SPEED
}
// overall wave height multiplier from the Amplitude slider (0.15 .. 1.6)
function ampScale() {
return 0.15 + ampLevel * 1.45;
}
// Effective propagation distance from a source to a point, accounting for
// the barrier. If the wall is on and the point is on the far side of it,
// the wave must travel to the slit first, then fan out from the slit.
// Returns -1 when the point is blocked by solid wall (no path).
function pathLen(sx, sy, px, py) {
if (!barrierOn) {
var dx = px - sx, dy = py - sy;
return Math.sqrt(dx * dx + dy * dy);
}
var srcAbove = sy < barrierY;
var ptAbove = py < barrierY;
if (srcAbove === ptAbove) {
// same side of the wall: straight-line travel
var dx2 = px - sx, dy2 = py - sy;
return Math.sqrt(dx2 * dx2 + dy2 * dy2);
}
// opposite sides: the wave goes through the slit (a Huygens secondary
// source at the gap). Total path = source->slit + slit->point.
var d1x = gapX - sx, d1y = barrierY - sy;
var d2x = px - gapX, d2y = py - barrierY;
var l1 = Math.sqrt(d1x * d1x + d1y * d1y);
var l2 = Math.sqrt(d2x * d2x + d2y * d2y);
return l1 + l2;
}
// height contribution of a continuous source at effective distance r
function contHeight(r, t) {
if (r < 0) return 0;
// wavefront has travelled SPEED*t; only affect points it has reached
var front = SPEED * t;
if (r > front) return 0;
var phase = K * r - OMEGA * t;
// amplitude falls with distance (gentle) and fades far out
var amp = 1 / (1 + r / 130);
// soft fade-in at the very front so it doesn't pop
var edge = front - r;
var fadeIn = edge < 40 ? edge / 40 : 1;
return Math.sin(phase) * amp * fadeIn;
}
// height contribution of a transient tap ripple
function rippleHeight(rp, t) {
var age = t - rp.t0;
if (age <= 0) return null;
// a finite ring that expands and decays
return age;
}
function computeField() {
var t = nowSec;
var n = grid.length;
var i;
for (i = 0; i < n; i++) grid[i] = 0;
var amp = ampScale();
var bOn = barrierOn;
var bY = barrierY;
// continuous sources
var cs;
for (cs = 0; cs < contSources.length; cs++) {
var src = contSources[cs];
if (!src.on) continue;
var sx = src.x, sy = src.y;
var srcAbove = sy < bY;
var idx = 0;
for (var ry = 0; ry < rows; ry++) {
var py = ry * CELL;
var dy = py - sy;
var ptAbove = py < bY;
var crossing = bOn && (srcAbove !== ptAbove);
for (var rx = 0; rx < cols; rx++) {
var px = rx * CELL;
var r;
if (crossing) {
r = pathLen(sx, sy, px, py);
} else {
var dx = px - sx;
r = Math.sqrt(dx * dx + dy * dy);
}
grid[idx] += contHeight(r, t) * amp;
idx++;
}
}
}
// transient tap ripples
for (var rpI = 0; rpI < ripples.length; rpI++) {
var rp = ripples[rpI];
var age = t - rp.t0;
if (age <= 0) continue;
var front = SPEED * age;
if (front - DAMP > Math.max(W, H) * 1.5) continue; // fully gone
var rsx = rp.x, rsy = rp.y;
var rpAbove = rsy < bY;
var idx2 = 0;
for (var ry2 = 0; ry2 < rows; ry2++) {
var py2 = ry2 * CELL;
var dy2 = py2 - rsy;
var pt2Above = py2 < bY;
var crossing2 = bOn && (rpAbove !== pt2Above);
for (var rx2 = 0; rx2 < cols; rx2++) {
var px2 = rx2 * CELL;
var r2;
if (crossing2) {
r2 = pathLen(rsx, rsy, px2, py2);
} else {
var dx2 = px2 - rsx;
r2 = Math.sqrt(dx2 * dx2 + dy2 * dy2);
}
if (r2 >= 0 && r2 <= front) {
var phase2 = K * r2 - OMEGA * age;
// ring envelope: bump near the wavefront, decays with age & distance
var behind = front - r2;
var ringEnv = Math.exp(-behind * behind / (2 * 90 * 90));
var distFade = 1 / (1 + r2 / 150);
var ageFade = Math.exp(-age * 0.55);
grid[idx2] += Math.sin(phase2) * ringEnv * distFade * ageFade * 1.5 * amp;
}
idx2++;
}
}
}
// drop fully-faded ripples
for (var d = ripples.length - 1; d >= 0; d--) {
if (nowSec - ripples[d].t0 > 7) ripples.splice(d, 1);
}
}
// --- rendering ---
function render() {
if (!img) return;
var data = img.data;
var idx = 0;
var px4 = 0;
for (var ry = 0; ry < rows; ry++) {
for (var rx = 0; rx < cols; rx++) {
var h = grid[idx];
// map height (~ -2..2) to a teal water color.
// crests -> bright cyan, troughs -> deep dark blue.
var v = h;
if (v > 1.6) v = 1.6;
if (v < -1.6) v = -1.6;
var nrm = v / 1.6; // -1..1
var rCol, gCol, bCol;
if (nrm >= 0) {
// base water -> bright crest
rCol = 10 + nrm * 150;
gCol = 70 + nrm * 175;
bCol = 95 + nrm * 130;
} else {
// base water -> dark trough
var d2 = -nrm;
rCol = 10 - d2 * 8;
gCol = 70 - d2 * 58;
bCol = 95 - d2 * 60;
}
if (rCol < 0) rCol = 0; if (rCol > 255) rCol = 255;
if (gCol < 0) gCol = 0; if (gCol > 255) gCol = 255;
if (bCol < 0) bCol = 0; if (bCol > 255) bCol = 255;
data[px4] = rCol;
data[px4 + 1] = gCol;
data[px4 + 2] = bCol;
data[px4 + 3] = 255;
idx++;
px4 += 4;
}
}
bufCtx.putImageData(img, 0, 0);
// scale the coarse field up smoothly to fill the canvas
ctx.imageSmoothingEnabled = true;
ctx.drawImage(buf, 0, 0, cols, rows, 0, 0, (cols) * CELL, (rows) * CELL);
// vignette for depth
var vg = ctx.createRadialGradient(W / 2, H / 2, Math.min(W, H) * 0.3,
W / 2, H / 2, Math.max(W, H) * 0.75);
vg.addColorStop(0, "rgba(0,0,0,0)");
vg.addColorStop(1, "rgba(0,8,12,0.55)");
ctx.fillStyle = vg;
ctx.fillRect(0, 0, W, H);
// the barrier wall (drawn over the water so the slit reads clearly)
if (barrierOn) drawBarrier();
// draw the source markers
for (var s = 0; s < contSources.length; s++) {
var src = contSources[s];
if (!src.on) continue;
drawSourceMarker(src.x, src.y, true);
}
// tank rim
ctx.strokeStyle = "rgba(79,227,214,0.35)";
ctx.lineWidth = 4;
roundRectStroke(6, 6, W - 12, H - 12, 18);
// wave-speed readout
drawSpeedReadout();
}
// a solid wall across the tank with a centered gap (the slit)
function drawBarrier() {
var th = Math.max(12, Math.min(20, H * 0.025)); // wall thickness
var y0 = barrierY - th / 2;
var leftEnd = gapX - gapHalf;
var rightEnd = gapX + gapHalf;
ctx.fillStyle = "#0a2c33";
ctx.strokeStyle = "rgba(79,227,214,0.55)";
ctx.lineWidth = 2;
// left wall segment (from tank edge to slit)
if (leftEnd > 8) {
roundRectFill(8, y0, leftEnd - 8, th, 6);
}
// right wall segment (from slit to tank edge)
if (rightEnd < W - 8) {
roundRectFill(rightEnd, y0, (W - 8) - rightEnd, th, 6);
}
// glow the slit edges so kids see where the gap is
var gp = 0.5 + 0.4 * Math.sin(nowSec * 4);
ctx.fillStyle = "rgba(150,242,233," + (0.25 + 0.25 * gp) + ")";
ctx.beginPath();
ctx.arc(leftEnd, barrierY, th * 0.55, 0, Math.PI * 2);
ctx.arc(rightEnd, barrierY, th * 0.55, 0, Math.PI * 2);
ctx.fill();
}
function roundRectFill(x, y, w, h, r) {
if (w <= 0 || h <= 0) return;
r = Math.min(r, w / 2, h / 2);
ctx.beginPath();
ctx.moveTo(x + r, y);
ctx.arcTo(x + w, y, x + w, y + h, r);
ctx.arcTo(x + w, y + h, x, y + h, r);
ctx.arcTo(x, y + h, x, y, r);
ctx.arcTo(x, y, x + w, y, r);
ctx.closePath();
ctx.fill();
ctx.stroke();
}
// wave-speed readout chip in the top-left of the tank
function drawSpeedReadout() {
var pad = 14;
var fs = Math.max(12, Math.min(16, W * 0.032));
// crests cross the tank at SPEED px/s; show a kid-friendly speed plus the
// current wavelength so the Frequency slider visibly changes the number.
var speedTxt = "Wave speed: " + Math.round(SPEED / 10) + " cm/s";
var waveTxt = "Crest gap: " + Math.round(WAVELENGTH / 5) + " cm";
ctx.font = "600 " + Math.round(fs) + "px system-ui, sans-serif";
ctx.textAlign = "left";
ctx.textBaseline = "middle";
var w1 = ctx.measureText(speedTxt).width;
var w2 = ctx.measureText(waveTxt).width;
var boxW = Math.max(w1, w2) + 22;
var lineH = fs * 1.5;
var boxH = lineH * 2 + 10;
var bx = pad, by = pad + 4;
// keep clear of the HUD title which also sits top-left
var hudEl = document.getElementById("hud");
if (hudEl) {
var hb = hudEl.getBoundingClientRect();
by = Math.max(by, hb.height + 8);
}
if (by + boxH > H - 110) by = Math.max(pad, H - 110 - boxH);
ctx.fillStyle = "rgba(8,42,49,0.86)";
roundRectFillNoStroke(bx, by, boxW, boxH, 12);
ctx.strokeStyle = "rgba(79,227,214,0.30)";
ctx.lineWidth = 1.5;
roundRectStroke(bx, by, boxW, boxH, 12);
ctx.fillStyle = "#9af2e9";
ctx.fillText(speedTxt, bx + 11, by + 5 + lineH / 2);
ctx.fillStyle = "#9fd6d2";
ctx.fillText(waveTxt, bx + 11, by + 5 + lineH * 1.5);
}
function roundRectFillNoStroke(x, y, w, h, r) {
r = Math.min(r, w / 2, h / 2);
ctx.beginPath();
ctx.moveTo(x + r, y);
ctx.arcTo(x + w, y, x + w, y + h, r);
ctx.arcTo(x + w, y + h, x, y + h, r);
ctx.arcTo(x, y + h, x, y, r);
ctx.arcTo(x, y, x + w, y, r);
ctx.closePath();
ctx.fill();
}
function drawSourceMarker(x, y, pulsing) {
var pr = pulsing ? 11 + 3 * Math.sin(nowSec * 6) : 11;
var g = ctx.createRadialGradient(x, y, 0, x, y, pr * 2.2);
g.addColorStop(0, "rgba(255,255,255,0.9)");
g.addColorStop(0.4, "rgba(150,242,233,0.7)");
g.addColorStop(1, "rgba(150,242,233,0)");
ctx.fillStyle = g;
ctx.beginPath();
ctx.arc(x, y, pr * 2.2, 0, Math.PI * 2);
ctx.fill();
ctx.fillStyle = "#ffffff";
ctx.beginPath();
ctx.arc(x, y, pr * 0.5, 0, Math.PI * 2);
ctx.fill();
}
function roundRectStroke(x, y, w, h, r) {
r = Math.min(r, w / 2, h / 2);
ctx.beginPath();
ctx.moveTo(x + r, y);
ctx.arcTo(x + w, y, x + w, y + h, r);
ctx.arcTo(x + w, y + h, x, y + h, r);
ctx.arcTo(x, y + h, x, y, r);
ctx.arcTo(x, y, x + w, y, r);
ctx.closePath();
ctx.stroke();
}
// --- main loop ---
function frame(ts) {
if (!startTime) startTime = ts;
nowSec = (ts - startTime) / 1000;
computeField();
render();
requestAnimationFrame(frame);
}
// --- input ---
function pointFromEvent(e) {
var rect = canvas.getBoundingClientRect();
return { x: e.clientX - rect.left, y: e.clientY - rect.top };
}
canvas.addEventListener("pointerdown", function (e) {
e.preventDefault();
var pt = pointFromEvent(e);
if (pt.x < 0 || pt.y < 0 || pt.x > W || pt.y > H) return;
rippleAt(pt.x, pt.y);
hint.textContent = "Ripple! Tap more spots to see them cross";
});
srcBtn.addEventListener("click", function () {
twoSources = !twoSources;
srcBtn.textContent = twoSources ? "2nd Source: On" : "2nd Source: Off";
srcBtn.classList.toggle("on", twoSources);
contSources[0].on = twoSources;
contSources[1].on = twoSources;
if (twoSources) {
hint.textContent = "Two sources — watch the bright and dark bands appear";
} else {
hint.textContent = "Tap the water to make a ripple";
}
});
barrierBtn.addEventListener("click", function () {
barrierOn = !barrierOn;
barrierBtn.textContent = barrierOn ? "Wall: On" : "Wall: Off";
barrierBtn.classList.toggle("on", barrierOn);
if (barrierOn) {
// turn a source on so there is something to diffract, if all are off
if (!contSources[0].on && !contSources[1].on && ripples.length === 0) {
contSources[0].on = true;
hint.textContent = "Waves squeeze through the slit and fan out — that is diffraction";
} else {
hint.textContent = "Watch the waves bend as they pass through the slit";
}
} else {
hint.textContent = "Tap the water to make a ripple";
}
});
calmBtn.addEventListener("click", function () {
ripples = [];
twoSources = false;
srcBtn.textContent = "2nd Source: Off";
srcBtn.classList.remove("on");
contSources[0].on = false;
contSources[1].on = false;
barrierOn = false;
barrierBtn.textContent = "Wall: Off";
barrierBtn.classList.remove("on");
// reset the clock so old continuous phase doesn't linger
startTime = 0;
hint.textContent = "Tap the water to make a ripple";
});
freqSlider.addEventListener("input", function () {
var v = parseInt(freqSlider.value, 10);
if (isNaN(v)) v = 50;
freqLevel = v / 100;
recomputeConstants();
});
ampSlider.addEventListener("input", function () {
var v = parseInt(ampSlider.value, 10);
if (isNaN(v)) v = 60;
ampLevel = v / 100;
});
howBtn.addEventListener("click", function () {
var showing = howPanel.classList.toggle("show");
howBtn.textContent = showing ? "Hide" : "How it works";
});
window.addEventListener("resize", resize);
// --- boot ---
(function initSliders() {
var f = parseInt(freqSlider.value, 10);
var a = parseInt(ampSlider.value, 10);
freqLevel = isNaN(f) ? 0.5 : f / 100;
ampLevel = isNaN(a) ? 0.6 : a / 100;
})();
recomputeConstants();
resize();
requestAnimationFrame(frame);
requestAnimationFrame(function () { stage.classList.add("ready"); });
})();
</script>
</body>
</html>
Game Source: Wave Tank
Creator: ArcadeLab
Libraries: none
Complexity: complex (793 lines, 24.7 KB)
The full source code is displayed above on this page.
Remix Instructions
To remix this game, copy the source code above and modify it. Add a ARCADELAB header at the top with "remix_of: wave-tank-arcadelab" to link back to the original. Then publish at arcadelab.ai/publish.