Custom Code Experiment •
Not a Musical Instrument
Source Code View HTML / CSS / JavaScript
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
<title>Organic Cluster Tiles · click to activate</title>
<style>
@import url('https://fonts.googleapis.com/css2?family=Syne:wght@400;700&display=swap');
* { box-sizing: border-box; margin: 0; padding: 0; }
:root { --bg: #0a0a0f; --tile-base: #1e3a5f; --text: #4a7ab5; }
body {
background: var(--bg);
width: 100vw; height: 100vh;
overflow: hidden;
display: flex; align-items: center; justify-content: center;
font-family: 'Syne', sans-serif;
-webkit-tap-highlight-color: transparent;
}
body::before {
content: '';
position: fixed; inset: 0;
background-image: url("data:image/svg+xml,%3Csvg viewBox='0 0 256 256' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='noise'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.9' numOctaves='4' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23noise)' opacity='0.04'/%3E%3C/svg%3E");
pointer-events: none; z-index: 100; opacity: 0.35;
}
p.label {
position: absolute; top: 1.5em; left: 1.5em;
color: var(--text); font-size: clamp(0.75em, 2vw, 0.95em);
letter-spacing: 0.2em; text-transform: uppercase; z-index: 10;
}
#scene {
perspective: 1000px; width: 100%; height: 100%;
display: flex; align-items: center; justify-content: center;
}
#cluster {
position: relative; transform-style: preserve-3d;
transform: rotateX(58deg) rotateZ(45deg);
width: 620px; height: 620px;
}
.tile { position: absolute; transform-style: preserve-3d; cursor: pointer; }
.tile svg { width: 100%; height: 100%; overflow: visible; display: block; }
.tile .face {
transition: transform 0.45s cubic-bezier(0.22, 0.61, 0.36, 1), filter 0.45s ease;
transform: translateZ(0px);
filter: drop-shadow(6px 8px 10px rgba(0,0,0,0.55));
will-change: transform, filter;
}
.tile .face polygon { transition: fill 0.4s ease; fill: var(--tile-base); }
.tile.hovered .face { transform: translateZ(48px); filter: drop-shadow(18px 22px 18px rgba(0,0,0,0.8)); }
.tile.active .face { transform: translateZ(32px); filter: drop-shadow(12px 16px 14px rgba(0,0,0,0.72)); }
/* ——— minimal text prompt: no box, just words ——— */
.audio-prompt {
position: absolute;
bottom: 5rem; /* keep same placement */
left: 50%;
transform: translateX(-50%);
color: #8bb3dc; /* soft blue, fits palette */
font-size: clamp(0.9rem, 3.5vw, 1.2rem);
font-weight: 400;
letter-spacing: 0.1em;
text-transform: uppercase;
z-index: 50;
pointer-events: none; /* clicks pass through to tiles */
white-space: nowrap;
background: transparent;
border: none;
padding: 0;
box-shadow: none;
text-shadow: 0 2px 5px rgba(0,0,0,0.5);
opacity: 0.9;
}
.instructions {
position: absolute; bottom: 1.5em; right: 1.5em;
color: var(--text); font-size: clamp(0.6em, 1.8vw, 0.78em);
letter-spacing: 0.12em; text-transform: uppercase;
text-align: right; z-index: 10; opacity: 0.6;
}
@media (max-width: 560px) {
.audio-prompt { font-size: 0.8rem; bottom: 4.5rem; }
}
</style>
</head>
<body>
<div id="scene"><div id="cluster"></div></div>
<!-- plain text message – exactly as requested: only the text, no button styling -->
<div class="audio-prompt">Click to Activate Audio</div>
<script>
// ─────────────────────────────────────────────
// AUDIO ENGINE
// ─────────────────────────────────────────────
let audioCtx = null;
let audioReady = false;
function getCtx() {
if (!audioCtx) audioCtx = new (window.AudioContext || window.webkitAudioContext)();
return audioCtx;
}
function unlockAudio() {
if (audioReady) return;
const ctx = getCtx();
if (ctx.state === 'suspended') ctx.resume();
audioReady = true;
}
['pointerdown', 'touchstart', 'keydown'].forEach(evt =>
document.addEventListener(evt, unlockAudio, { once: false, passive: true })
);
function getMaster() {
const ctx = getCtx();
if (!getMaster._node) {
const lim = ctx.createDynamicsCompressor();
lim.threshold.value = -6;
lim.knee.value = 3;
lim.ratio.value = 12;
lim.attack.value = 0.001;
lim.release.value = 0.1;
lim.connect(ctx.destination);
getMaster._node = lim;
}
return getMaster._node;
}
const soundDefs = {
'#c0392b': (ctx, out) => {
const osc = ctx.createOscillator();
const env = ctx.createGain();
osc.frequency.setValueAtTime(120, ctx.currentTime);
osc.frequency.exponentialRampToValueAtTime(30, ctx.currentTime + 0.18);
env.gain.setValueAtTime(0.7, ctx.currentTime);
env.gain.exponentialRampToValueAtTime(0.001, ctx.currentTime + 0.25);
osc.connect(env); env.connect(out);
osc.start(); osc.stop(ctx.currentTime + 0.3);
},
'#e74c3c': (ctx, out) => {
const osc = ctx.createOscillator();
const env = ctx.createGain();
osc.type = 'sawtooth';
osc.frequency.setValueAtTime(200, ctx.currentTime);
osc.frequency.exponentialRampToValueAtTime(50, ctx.currentTime + 0.12);
env.gain.setValueAtTime(0.5, ctx.currentTime);
env.gain.exponentialRampToValueAtTime(0.001, ctx.currentTime + 0.18);
osc.connect(env); env.connect(out);
osc.start(); osc.stop(ctx.currentTime + 0.2);
},
'#e67e22': (ctx, out) => {
const osc = ctx.createOscillator();
const env = ctx.createGain();
osc.type = 'triangle';
osc.frequency.value = 392;
env.gain.setValueAtTime(0.5, ctx.currentTime);
env.gain.exponentialRampToValueAtTime(0.001, ctx.currentTime + 0.5);
osc.connect(env); env.connect(out);
osc.start(); osc.stop(ctx.currentTime + 0.55);
},
'#f39c12': (ctx, out) => {
const osc = ctx.createOscillator();
const env = ctx.createGain();
osc.type = 'triangle';
osc.frequency.value = 440;
env.gain.setValueAtTime(0.45, ctx.currentTime);
env.gain.exponentialRampToValueAtTime(0.001, ctx.currentTime + 0.45);
osc.connect(env); env.connect(out);
osc.start(); osc.stop(ctx.currentTime + 0.5);
},
'#f1c40f': (ctx, out) => {
[523.25, 1046.5].forEach((freq, i) => {
const osc = ctx.createOscillator();
const env = ctx.createGain();
osc.type = 'sine';
osc.frequency.value = freq;
env.gain.setValueAtTime(i === 0 ? 0.4 : 0.15, ctx.currentTime);
env.gain.exponentialRampToValueAtTime(0.001, ctx.currentTime + 0.8);
osc.connect(env); env.connect(out);
osc.start(); osc.stop(ctx.currentTime + 0.85);
});
},
'#2ecc71': (ctx, out) => {
const buf = ctx.createBuffer(1, ctx.sampleRate * 0.4, ctx.sampleRate);
const d = buf.getChannelData(0);
for (let i = 0; i < d.length; i++) d[i] = (Math.random() * 2 - 1) * (1 - i / d.length);
const src = ctx.createBufferSource();
src.buffer = buf;
const filter = ctx.createBiquadFilter();
filter.type = 'bandpass';
filter.frequency.setValueAtTime(400, ctx.currentTime);
filter.frequency.exponentialRampToValueAtTime(2000, ctx.currentTime + 0.4);
filter.Q.value = 3;
const env = ctx.createGain();
env.gain.setValueAtTime(0.3, ctx.currentTime);
env.gain.exponentialRampToValueAtTime(0.001, ctx.currentTime + 0.4);
src.connect(filter); filter.connect(env); env.connect(out);
src.start(); src.stop(ctx.currentTime + 0.45);
},
'#27ae60': (ctx, out) => {
const osc = ctx.createOscillator();
const env = ctx.createGain();
osc.type = 'sine';
osc.frequency.setValueAtTime(220, ctx.currentTime);
osc.frequency.exponentialRampToValueAtTime(660, ctx.currentTime + 0.3);
env.gain.setValueAtTime(0.35, ctx.currentTime);
env.gain.exponentialRampToValueAtTime(0.001, ctx.currentTime + 0.35);
osc.connect(env); env.connect(out);
osc.start(); osc.stop(ctx.currentTime + 0.4);
},
'#1abc9c': (ctx, out) => {
[523.25, 659.25, 783.99].forEach((freq, i) => {
const osc = ctx.createOscillator();
const env = ctx.createGain();
osc.type = 'sine';
osc.frequency.value = freq;
env.gain.setValueAtTime(0.18, ctx.currentTime + i * 0.04);
env.gain.exponentialRampToValueAtTime(0.001, ctx.currentTime + 0.7);
osc.connect(env); env.connect(out);
osc.start(ctx.currentTime + i * 0.04);
osc.stop(ctx.currentTime + 0.75);
});
},
'#16a085': (ctx, out) => {
const osc = ctx.createOscillator();
const env = ctx.createGain();
osc.type = 'sine';
osc.frequency.value = 349.23;
env.gain.setValueAtTime(0.4, ctx.currentTime);
env.gain.exponentialRampToValueAtTime(0.001, ctx.currentTime + 0.6);
osc.connect(env); env.connect(out);
osc.start(); osc.stop(ctx.currentTime + 0.65);
},
'#3498db': (ctx, out) => {
const osc = ctx.createOscillator();
const env = ctx.createGain();
const filter = ctx.createBiquadFilter();
filter.type = 'lowpass'; filter.frequency.value = 800; filter.Q.value = 8;
osc.type = 'sawtooth';
osc.frequency.value = 110;
env.gain.setValueAtTime(0.5, ctx.currentTime);
env.gain.exponentialRampToValueAtTime(0.001, ctx.currentTime + 0.4);
osc.connect(filter); filter.connect(env); env.connect(out);
osc.start(); osc.stop(ctx.currentTime + 0.45);
},
'#2980b9': (ctx, out) => {
const osc = ctx.createOscillator();
const env = ctx.createGain();
osc.type = 'square';
osc.frequency.value = 130.81;
env.gain.setValueAtTime(0.25, ctx.currentTime);
env.gain.exponentialRampToValueAtTime(0.001, ctx.currentTime + 0.35);
osc.connect(env); env.connect(out);
osc.start(); osc.stop(ctx.currentTime + 0.4);
},
'#9b59b6': (ctx, out) => {
const osc = ctx.createOscillator();
const env = ctx.createGain();
osc.type = 'sine';
osc.frequency.setValueAtTime(880, ctx.currentTime);
osc.frequency.exponentialRampToValueAtTime(110, ctx.currentTime + 0.5);
env.gain.setValueAtTime(0.35, ctx.currentTime);
env.gain.exponentialRampToValueAtTime(0.001, ctx.currentTime + 0.55);
osc.connect(env); env.connect(out);
osc.start(); osc.stop(ctx.currentTime + 0.6);
},
'#8e44ad': (ctx, out) => {
const carrier = ctx.createOscillator();
const lfo = ctx.createOscillator();
const lfoGain = ctx.createGain();
const env = ctx.createGain();
lfo.frequency.value = 8;
lfoGain.gain.value = 60;
carrier.type = 'sine';
carrier.frequency.value = 440;
lfo.connect(lfoGain); lfoGain.connect(carrier.frequency);
env.gain.setValueAtTime(0.3, ctx.currentTime);
env.gain.exponentialRampToValueAtTime(0.001, ctx.currentTime + 0.5);
carrier.connect(env); env.connect(out);
lfo.start(); carrier.start(); lfo.stop(ctx.currentTime + 0.55); carrier.stop(ctx.currentTime + 0.55);
},
'#e91e8c': (ctx, out) => {
const buf = ctx.createBuffer(1, ctx.sampleRate * 0.08, ctx.sampleRate);
const d = buf.getChannelData(0);
for (let i = 0; i < d.length; i++) d[i] = (Math.random() * 2 - 1) * (1 - i / d.length);
const src = ctx.createBufferSource();
src.buffer = buf;
const filter = ctx.createBiquadFilter();
filter.type = 'highpass'; filter.frequency.value = 6000;
const env = ctx.createGain();
env.gain.setValueAtTime(0.5, ctx.currentTime);
env.gain.exponentialRampToValueAtTime(0.001, ctx.currentTime + 0.08);
src.connect(filter); filter.connect(env); env.connect(out);
src.start(); src.stop(ctx.currentTime + 0.1);
},
'#c2185b': (ctx, out) => {
const buf = ctx.createBuffer(1, ctx.sampleRate * 0.05, ctx.sampleRate);
const d = buf.getChannelData(0);
for (let i = 0; i < d.length; i++) d[i] = (Math.random() * 2 - 1) * Math.exp(-i / 200);
const src = ctx.createBufferSource(); src.buffer = buf;
const env = ctx.createGain();
env.gain.setValueAtTime(0.6, ctx.currentTime);
env.gain.exponentialRampToValueAtTime(0.001, ctx.currentTime + 0.06);
src.connect(env); env.connect(out);
src.start(); src.stop(ctx.currentTime + 0.08);
},
'#00bcd4': (ctx, out) => {
[523.25, 659.25, 783.99, 1046.5].forEach((freq, i) => {
const osc = ctx.createOscillator();
const env = ctx.createGain();
osc.type = 'triangle';
osc.frequency.value = freq;
const t = ctx.currentTime + i * 0.06;
env.gain.setValueAtTime(0.22, t);
env.gain.exponentialRampToValueAtTime(0.001, t + 0.15);
osc.connect(env); env.connect(out);
osc.start(t); osc.stop(t + 0.2);
});
},
'#0097a7': (ctx, out) => {
const osc = ctx.createOscillator();
const env = ctx.createGain();
osc.type = 'triangle';
osc.frequency.setValueAtTime(261.63, ctx.currentTime);
osc.frequency.exponentialRampToValueAtTime(523.25, ctx.currentTime + 0.2);
env.gain.setValueAtTime(0.35, ctx.currentTime);
env.gain.exponentialRampToValueAtTime(0.001, ctx.currentTime + 0.3);
osc.connect(env); env.connect(out);
osc.start(); osc.stop(ctx.currentTime + 0.35);
},
'#ff5722': (ctx, out) => {
const osc = ctx.createOscillator();
const wave = ctx.createWaveShaper();
const env = ctx.createGain();
const curve = new Float32Array(256);
for (let i = 0; i < 256; i++) {
const x = (i * 2) / 256 - 1;
curve[i] = (Math.PI + 200) * x / (Math.PI + 200 * Math.abs(x));
}
wave.curve = curve;
osc.type = 'sawtooth'; osc.frequency.value = 80;
env.gain.setValueAtTime(0.6, ctx.currentTime);
env.gain.exponentialRampToValueAtTime(0.001, ctx.currentTime + 0.15);
osc.connect(wave); wave.connect(env); env.connect(out);
osc.start(); osc.stop(ctx.currentTime + 0.2);
},
'#bf360c': (ctx, out) => {
const osc = ctx.createOscillator();
const env = ctx.createGain();
osc.type = 'sawtooth';
osc.frequency.setValueAtTime(160, ctx.currentTime);
osc.frequency.exponentialRampToValueAtTime(40, ctx.currentTime + 0.2);
env.gain.setValueAtTime(0.55, ctx.currentTime);
env.gain.exponentialRampToValueAtTime(0.001, ctx.currentTime + 0.22);
osc.connect(env); env.connect(out);
osc.start(); osc.stop(ctx.currentTime + 0.25);
},
'#76c442': (ctx, out) => {
const osc = ctx.createOscillator();
const env = ctx.createGain();
osc.type = 'sine'; osc.frequency.value = 1318.51;
env.gain.setValueAtTime(0.3, ctx.currentTime);
env.gain.exponentialRampToValueAtTime(0.001, ctx.currentTime + 0.6);
osc.connect(env); env.connect(out);
osc.start(); osc.stop(ctx.currentTime + 0.65);
},
'#4caf50': (ctx, out) => {
[659.25, 830.61].forEach((freq, i) => {
const osc = ctx.createOscillator();
const env = ctx.createGain();
osc.type = 'sine'; osc.frequency.value = freq;
env.gain.setValueAtTime(0.2, ctx.currentTime + i * 0.05);
env.gain.exponentialRampToValueAtTime(0.001, ctx.currentTime + 0.55);
osc.connect(env); env.connect(out);
osc.start(ctx.currentTime + i * 0.05);
osc.stop(ctx.currentTime + 0.6);
});
},
};
function playSound(colorKey) {
if (!audioReady) return;
const fn = soundDefs[colorKey];
if (!fn) return;
try {
const ctx = getCtx();
if (ctx.state === 'suspended') ctx.resume();
fn(ctx, getMaster());
} catch(e) { /* ignore */ }
}
// ─────────────────────────────────────────────
// TILE DATA & RENDERING (identical to before)
// ─────────────────────────────────────────────
const palette = [
'#c0392b','#e74c3c','#e67e22','#f39c12','#f1c40f',
'#2ecc71','#27ae60','#1abc9c','#16a085','#3498db',
'#2980b9','#9b59b6','#8e44ad','#e91e8c','#c2185b',
'#00bcd4','#0097a7','#ff5722','#bf360c','#76c442','#4caf50',
];
const tiles = [
{ x:225, y:210, w:115, h:105, pts:[[0.1,0.05],[0.9,0],[1,0.2],[0.95,0.85],[0.8,1],[0.05,0.95],[0,0.5]] },
{ x:330, y:195, w:100, h:115, pts:[[0.05,0],[0.95,0.1],[1,0.4],[0.9,1],[0.1,0.95],[0,0.6],[0.05,0.2]] },
{ x:248, y:308, w:110, h:100, pts:[[0.15,0],[0.9,0.05],[1,0.5],[0.85,1],[0.1,0.95],[0,0.4]] },
{ x:348, y:298, w: 95, h:110, pts:[[0,0.1],[0.8,0],[1,0.3],[1,0.8],[0.7,1],[0.05,0.9]] },
{ x:235, y:115, w: 95, h: 98, pts:[[0.2,0],[0.85,0.05],[1,0.5],[0.8,1],[0.1,0.9],[0,0.3]] },
{ x:322, y: 98, w:110, h: 98, pts:[[0.05,0.1],[0.9,0],[1,0.35],[0.95,0.9],[0.5,1],[0,0.7]] },
{ x:145, y:130, w: 95, h: 88, pts:[[0.1,0.05],[0.9,0],[1,0.45],[0.8,1],[0.05,0.9],[0,0.4]] },
{ x:425, y:112, w: 82, h: 90, pts:[[0.05,0],[0.9,0.1],[1,0.55],[0.8,1],[0.1,0.95],[0,0.4]] },
{ x: 90, y:215, w:100, h: 95, pts:[[0.1,0],[0.95,0.05],[1,0.55],[0.85,1],[0.05,0.9],[0,0.4]] },
{ x: 82, y:305, w: 92, h:108, pts:[[0,0.05],[0.8,0],[1,0.4],[0.9,1],[0.15,0.95],[0.05,0.5]] },
{ x:155, y:220, w: 80, h: 92, pts:[[0.1,0.05],[0.85,0],[1,0.5],[0.8,1],[0.05,0.9],[0,0.4]] },
{ x:440, y:210, w: 95, h:100, pts:[[0.1,0],[0.9,0.1],[1,0.5],[0.85,1],[0,0.9],[0.05,0.2]] },
{ x:432, y:305, w: 88, h: 95, pts:[[0.05,0],[0.95,0.05],[1,0.6],[0.7,1],[0.05,0.95],[0,0.35]] },
{ x:358, y:398, w: 92, h: 88, pts:[[0.1,0.05],[0.9,0],[1,0.5],[0.8,1],[0.05,0.9],[0,0.4]] },
{ x:230, y:402, w:110, h: 90, pts:[[0.1,0.05],[0.9,0],[1,0.5],[0.8,1],[0.05,0.9],[0,0.4]] },
{ x:332, y:392, w:100, h: 95, pts:[[0,0.1],[0.85,0],[1,0.45],[0.9,0.95],[0.2,1],[0.05,0.6]] },
{ x:135, y:392, w: 98, h: 88, pts:[[0.1,0],[0.9,0.05],[1,0.5],[0.75,1],[0,0.88],[0.05,0.3]] },
{ x:170, y:115, w: 72, h: 78, pts:[[0.15,0],[0.9,0.1],[1,0.6],[0.75,1],[0,0.85],[0.05,0.2]] },
{ x:430, y: 98, w: 68, h: 72, pts:[[0.1,0],[0.9,0.05],[1,0.5],[0.8,1],[0.05,0.9],[0,0.4]] },
{ x:108, y:118, w: 65, h: 72, pts:[[0,0.15],[0.8,0],[1,0.4],[0.85,0.9],[0.2,1],[0.05,0.55]] },
{ x: 58, y:195, w: 68, h: 75, pts:[[0.1,0],[0.9,0.05],[1,0.55],[0.8,1],[0,0.9],[0.05,0.3]] },
{ x: 55, y:390, w: 78, h: 72, pts:[[0.05,0.1],[0.88,0],[1,0.5],[0.8,1],[0.1,0.95],[0,0.4]] },
{ x:450, y:390, w: 72, h: 75, pts:[[0,0.1],[0.85,0],[1,0.5],[0.8,1],[0.05,0.9],[0.05,0.3]] },
{ x:148, y:395, w: 88, h: 72, pts:[[0.1,0.05],[0.9,0],[1,0.45],[0.8,0.98],[0.05,0.9],[0,0.4]] },
{ x:160, y:308, w: 78, h: 88, pts:[[0.05,0],[0.9,0.1],[1,0.55],[0.8,1],[0.05,0.9],[0,0.35]] },
{ x:445, y:305, w: 68, h: 88, pts:[[0.1,0],[0.9,0.05],[1,0.5],[0.78,1],[0,0.88],[0.05,0.3]] },
{ x: 58, y:278, w: 62, h: 80, pts:[[0.05,0],[0.9,0.1],[1,0.5],[0.8,1],[0.05,0.9],[0,0.3]] },
{ x:505, y:250, w: 65, h: 78, pts:[[0.1,0],[0.9,0.05],[1,0.5],[0.8,1],[0,0.88],[0.05,0.25]] },
{ x:290, y: 42, w: 80, h: 68, pts:[[0.1,0.05],[0.9,0],[1,0.5],[0.8,1],[0.05,0.9],[0,0.4]] },
{ x:200, y: 42, w: 72, h: 70, pts:[[0.05,0],[0.9,0.1],[1,0.5],[0.78,1],[0.05,0.9],[0,0.35]] },
{ x:382, y: 42, w: 70, h: 68, pts:[[0.1,0.05],[0.88,0],[1,0.45],[0.8,1],[0.05,0.9],[0,0.4]] },
{ x:260, y:488, w: 88, h: 65, pts:[[0.1,0],[0.9,0.05],[1,0.5],[0.8,1],[0.05,0.9],[0,0.4]] },
{ x:348, y:482, w: 80, h: 68, pts:[[0.05,0.1],[0.88,0],[1,0.5],[0.8,1],[0.1,0.9],[0,0.4]] },
{ x:168, y:482, w: 78, h: 65, pts:[[0.1,0],[0.9,0.05],[1,0.5],[0.78,0.98],[0.05,0.9],[0,0.35]] },
];
const cluster = document.getElementById('cluster');
const tileColors = tiles.map((_, i) => palette[i % palette.length]);
for (let i = tileColors.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[tileColors[i], tileColors[j]] = [tileColors[j], tileColors[i]];
}
const leaveTimers = new Map();
const LEAVE_DELAY = 80;
const soundCooldowns = new Map();
const SOUND_COOLDOWN = 180;
function tryPlaySound(div, color) {
const now = Date.now();
const last = soundCooldowns.get(div) || 0;
if (now - last < SOUND_COOLDOWN) return;
soundCooldowns.set(div, now);
playSound(color);
}
tiles.forEach((t, i) => {
const div = document.createElement('div');
div.className = 'tile';
div.style.left = t.x + 'px';
div.style.top = t.y + 'px';
div.style.width = t.w + 'px';
div.style.height = t.h + 'px';
div.setAttribute('tabindex', '0');
const points = t.pts.map(([px, py]) => `${px * t.w},${py * t.h}`).join(' ');
const hoverCol = tileColors[i];
div.innerHTML = `
<div class="face">
<svg viewBox="0 0 ${t.w} ${t.h}" xmlns="http://www.w3.org/2000/svg">
<polygon points="${points}" />
</svg>
</div>`;
const poly = div.querySelector('polygon');
const face = div.querySelector('.face');
div.addEventListener('pointerenter', () => {
if (leaveTimers.has(div)) { clearTimeout(leaveTimers.get(div)); leaveTimers.delete(div); }
if (!div.classList.contains('hovered')) {
div.classList.add('hovered');
poly.style.fill = hoverCol;
tryPlaySound(div, hoverCol);
}
});
div.addEventListener('pointerleave', () => {
const timer = setTimeout(() => {
div.classList.remove('hovered');
if (!div.classList.contains('active')) poly.style.fill = '';
leaveTimers.delete(div);
}, LEAVE_DELAY);
leaveTimers.set(div, timer);
});
let isTouching = false;
div.addEventListener('touchstart', e => {
e.preventDefault();
isTouching = true;
div.classList.add('active');
poly.style.fill = hoverCol;
tryPlaySound(div, hoverCol);
}, { passive: false });
div.addEventListener('touchend', e => {
e.preventDefault();
isTouching = false;
div.classList.remove('active');
setTimeout(() => { poly.style.fill = ''; }, 420);
}, { passive: false });
div.addEventListener('touchcancel', () => {
isTouching = false;
div.classList.remove('active');
poly.style.fill = '';
});
div.addEventListener('keydown', e => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
poly.style.fill = hoverCol;
face.style.transform = 'translateZ(48px)';
tryPlaySound(div, hoverCol);
setTimeout(() => { poly.style.fill = ''; face.style.transform = ''; }, 440);
}
});
cluster.appendChild(div);
});
let lastTouch = 0;
document.addEventListener('touchend', e => {
const now = Date.now();
if (now - lastTouch < 300) e.preventDefault();
lastTouch = now;
}, false);
</script>
</body>
</html>