Add split-flap animation engine with staggered character stepping and sound

This commit is contained in:
Jared Miller 2026-03-01 11:59:54 -05:00
parent 93d7f53ab4
commit a2b6ab0546
Signed by: shmup
GPG key ID: 22B5C6D66A38B06C

View file

@ -196,8 +196,8 @@ body {
(function() {
'use strict';
// ─── Config ─────────────────────────────────────────────────────────
// Column definitions - customize per use case
// ─── Constants ──────────────────────────────────────────────────────
const CHARSET = ' ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789:.-/,&()+\u2713\u2717';
const COLS = [
{ key: 'time', width: 7, header: 'TIME' },
{ key: 'type', width: 10, header: 'TYPE' },
@ -210,14 +210,19 @@ body {
];
const TOTAL_CHARS = COLS.reduce((s, c) => s + c.width, 0);
const MAX_ROWS = 20;
const FLIP_STEP_MS = 200;
const STAGGER_MS = 20;
const CHAR_WIDTH = 18;
// ─── State ──────────────────────────────────────────────────────────
const flapChars = new Map();
// ─── Flap Element Creation ─────────────────────────────────────────
function createFlap(ch) {
ch = ch || ' ';
const el = document.createElement('div');
el.className = 'flap';
el.dataset.char = ch;
flapChars.set(el, ch);
const top = document.createElement('div');
top.className = 'flap-top';
@ -246,12 +251,115 @@ body {
else if (ch === '\u2717') flapEl.classList.add('flap-red');
}
// ─── Set flap character (no animation yet) ─────────────────────────
function setFlapChar(flapEl, ch) {
flapEl.dataset.char = ch;
flapEl.querySelector('.flap-top .flap-char').textContent = ch;
flapEl.querySelector('.flap-bottom .flap-char').textContent = ch;
applyCharColor(flapEl, ch);
// ─── Sound ─────────────────────────────────────────────────────────
const flipAudio = new Audio('/split.mp3');
flipAudio.loop = true;
flipAudio.volume = 0;
let fadeInterval = null;
function startFlipSound() {
clearInterval(fadeInterval);
flipAudio.currentTime = 3;
flipAudio.volume = 0;
flipAudio.play().catch(() => {});
const target = 0.5;
const step = target / 8;
fadeInterval = setInterval(() => {
flipAudio.volume = Math.min(flipAudio.volume + step, target);
if (flipAudio.volume >= target) clearInterval(fadeInterval);
}, 10);
}
function stopFlipSound() {
clearInterval(fadeInterval);
const step = flipAudio.volume / 12;
fadeInterval = setInterval(() => {
flipAudio.volume = Math.max(flipAudio.volume - step, 0);
if (flipAudio.volume <= 0) {
clearInterval(fadeInterval);
flipAudio.pause();
}
}, 10);
}
// ─── Batch Flip Animation Engine ────────────────────────────────────
const activeFlips = [];
let animRunning = false;
function buildSteps(oldChar, newChar) {
const oldIdx = CHARSET.indexOf(oldChar);
const newIdx = CHARSET.indexOf(newChar);
if (oldIdx === -1 || newIdx === -1) return [newChar];
const steps = [];
if (newIdx >= oldIdx) {
for (let i = oldIdx + 1; i <= newIdx; i++) steps.push(CHARSET[i]);
} else {
for (let i = oldIdx + 1; i < CHARSET.length; i++) steps.push(CHARSET[i]);
for (let i = 0; i <= newIdx; i++) steps.push(CHARSET[i]);
}
if (steps.length === 0) return [newChar];
const maxSteps = 12;
if (steps.length <= maxSteps) return steps;
const sampled = [];
for (let i = 0; i < maxSteps - 1; i++) {
sampled.push(steps[Math.floor(i * (steps.length - 1) / (maxSteps - 1))]);
}
sampled.push(steps[steps.length - 1]);
return sampled;
}
function scheduleFlip(flapEl, newChar, delay) {
const oldChar = flapChars.get(flapEl) || ' ';
if (oldChar === newChar) return;
const topSpan = flapEl.querySelector('.flap-top .flap-char');
const bottomSpan = flapEl.querySelector('.flap-bottom .flap-char');
const steps = buildSteps(oldChar, newChar);
const startTime = performance.now() + delay;
activeFlips.push({
flapEl, topSpan, bottomSpan, steps,
stepIdx: 0,
nextTime: startTime
});
if (!animRunning) {
animRunning = true;
startFlipSound();
requestAnimationFrame(animLoop);
}
}
function animLoop(timestamp) {
let stillActive = false;
for (let i = 0; i < activeFlips.length; i++) {
const flip = activeFlips[i];
if (flip.stepIdx >= flip.steps.length) continue;
stillActive = true;
if (timestamp >= flip.nextTime) {
const ch = flip.steps[flip.stepIdx];
flip.topSpan.textContent = ch;
flip.bottomSpan.textContent = ch;
flapChars.set(flip.flapEl, ch);
flip.stepIdx++;
flip.nextTime = timestamp + FLIP_STEP_MS;
if (flip.stepIdx >= flip.steps.length) {
applyCharColor(flip.flapEl, ch);
}
}
}
if (stillActive) {
requestAnimationFrame(animLoop);
} else {
activeFlips.length = 0;
animRunning = false;
stopFlipSound();
}
}
// ─── Row helpers ───────────────────────────────────────────────────
@ -269,7 +377,7 @@ body {
const flaps = rowEl.children;
const padded = text.padEnd(flaps.length);
for (let i = 0; i < flaps.length; i++) {
setFlapChar(flaps[i], padded[i]);
scheduleFlip(flaps[i], padded[i], i * STAGGER_MS);
}
}
@ -296,12 +404,12 @@ body {
if (container.children.length !== upper.length) {
container.innerHTML = '';
for (let i = 0; i < upper.length; i++) {
container.appendChild(createFlap(upper[i]));
container.appendChild(createFlap(' '));
}
} else {
}
for (let i = 0; i < upper.length; i++) {
setFlapChar(container.children[i], upper[i]);
}
scheduleFlip(container.children[i], upper[i], i * STAGGER_MS);
}
}