From 3c4b56a6ea2d3d38b5e7cc10c774f5d053450809 Mon Sep 17 00:00:00 2001 From: Lewis Date: Wed, 15 Jul 2026 06:05:29 +0000 Subject: [PATCH] appview/pages/profile-fx: more profile-fx additions appview/pages/profile-fx/danschmidt.js: danschmidt appview/pages/profile-fx/mihaizaurus.at.js: Mihai <73397939+mihaizaurus@users.noreply.github.com> appview/state/profile.go: Lewis Lewis: May this revision serve well! --- appview/state/profile.go | 2 ++ appview/pages/profile-fx/danschmidt.js | 551 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ appview/pages/profile-fx/mihaizaurus.at.js | 674 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ 3 file(s) changed, 1227 insertion(s)(+), 0 deletion(s)(-) diff --git a/appview/state/profile.go b/appview/state/profile.go --- a/appview/state/profile.go +++ b/appview/state/profile.go @@ -64,6 +64,8 @@ "did:plc:doe7nkqeodssh6uc5jcq5iyw": "/static/profile-fx/luisstd.js", "did:plc:f2ablw5m3ashhpydt6u7h2rx": "/static/profile-fx/chancey.dev.js", "did:plc:4aah6pidgmlp36cvtispnwhu": "/static/profile-fx/matthewlipski.tngl.sh.js", + "did:plc:7qubw2z53qzfturlblaozz4a": "/static/profile-fx/mihaizaurus.at.js", + "did:plc:xmyx5qvyzd4fm77oehni7pvh": "/static/profile-fx/danschmidt.js", } func (s *State) profile(r *http.Request) (*pages.ProfileCard, error) { diff --git a/appview/pages/profile-fx/danschmidt.js b/appview/pages/profile-fx/danschmidt.js new file mode 100644 --- /dev/null +++ b/appview/pages/profile-fx/danschmidt.js @@ -0,0 +1,551 @@ +// A lane-crossing dodge game on the Tangled punchcard. +// Guide the chicken from one edge of the grid to the other without getting +// clipped by the moving hazards in the "traffic" lanes. Arrow keys / WASD +// once the grid is focused, or tap in the direction you want to move. +// Reduced motion gets a turn-based variant: hazards only move when you do. +// +// Levels alternate direction: odd levels climb to the top, even levels +// descend to the bottom, and so on forever, getting faster each time. +// Losing all your lives turns the whole grid into a plate of drumsticks. +// +// Tune the constants below to change difficulty and pacing. + +const COLS_WIDE = 14; +const COLS_NARROW = 28; +const WIDE_QUERY = "(min-width: 768px)"; +const REDUCED_MOTION_QUERY = "(prefers-reduced-motion: reduce)"; + +const LIVES_START = 3; +const BASE_SPEED = 0.7; // cells/sec for the easiest danger lane +const SPEED_RAMP = 0.06; // extra cells/sec per lane closer to the goal +const SAFE_ROW_INTERVAL = 3; // every Nth interior row is a resting lane +const CAR_DENSITY_DIVISOR = 11; // larger = fewer cars per lane at level 1 +const CARS_PER_LEVEL = 0.5; // extra cars per lane added every ~2 levels +const MAX_CARS_PER_LANE = 6; // cap so late levels don't get absurdly crowded +const WIN_PAUSE_MS = 1600; +const HIT_FLASH_MS = 180; + +const PLAYER_COLOR = "hsl(45 100% 55%)"; +const PLAYER_GLYPH = "๐Ÿ”"; +const PLAYER_GLYPH_SIZE = "16px"; // tune relative to your actual dot size +const HIT_COLOR = "hsl(355 85% 55%)"; +const HAZARD_GLYPH = "๐Ÿš—"; +const HAZARD_GLYPH_SIZE = "16px"; // tune relative to your actual dot size +const HAZARD_BG = "hsla(330, 85%, 62%, 0.55)"; +const HAZARD_BG_REVERSE = "hsla(280, 80%, 62%, 0.55)"; +const GAME_OVER_GLYPH = "๐Ÿ—"; +const GAME_OVER_GLYPH_SIZE = "16px"; +const GRASS_HUE = 100; +const GRASS_HUE_JITTER = 18; +const GRASS_LIGHTNESS_MIN = 36; +const GRASS_LIGHTNESS_MAX = 58; +const GRASS_SCALE_MIN = 0.65; +const GRASS_SCALE_MAX = 1.3; + +function getCols() { + return matchMedia(WIDE_QUERY).matches ? COLS_WIDE : COLS_NARROW; +} + +function setupGame(grid) { + const cells = Array.from(grid.children, (c) => c.firstElementChild).filter(Boolean); + const total = cells.length; + if (total === 0) return () => {}; + + const reducedMotion = matchMedia(REDUCED_MOTION_QUERY).matches; + const wideQuery = matchMedia(WIDE_QUERY); + + let cols = getCols(); + let rows = Math.ceil(total / cols); + let homeRow = total % cols === 0 ? rows - 1 : rows - 2; + let prevKey = new Array(total).fill(""); + let lanes = []; + let player = { row: 0, col: 0 }; + let level = 1; + let bestLevel = 1; + let lives = LIVES_START; + let gameOver = false; + let justWon = false; + let resolving = false; + let raf = 0; + let lastTs = 0; + let visible = true; + let flashTimeoutId = 0; + let winTimeoutId = 0; + let playerElIdx = -1; + let hazardIdxs = new Set(); + let audioCtx = null; + + cells.forEach((el) => { + el.style.transformOrigin = "center"; + if (!reducedMotion) { + el.style.transition = "background-color 0.12s ease, transform 0.12s ease"; + } + }); + + grid.tabIndex = 0; + grid.setAttribute("aria-label", "Dodge game: use arrow keys or tap to cross"); + + const status = document.createElement("div"); + status.style.fontSize = "11px"; + status.style.lineHeight = "1.4"; + status.style.marginTop = "6px"; + status.style.opacity = "0.75"; + status.style.fontFamily = "inherit"; + status.setAttribute("aria-live", "polite"); + grid.insertAdjacentElement("afterend", status); + + let grassColor = new Array(total); + let grassScale = new Array(total); + function buildGrassField() { + for (let i = 0; i < total; i++) { + const hue = GRASS_HUE + (Math.random() * 2 - 1) * GRASS_HUE_JITTER; + const light = GRASS_LIGHTNESS_MIN + Math.random() * (GRASS_LIGHTNESS_MAX - GRASS_LIGHTNESS_MIN); + grassColor[i] = `hsl(${hue.toFixed(0)} 55% ${light.toFixed(0)}%)`; + grassScale[i] = GRASS_SCALE_MIN + Math.random() * (GRASS_SCALE_MAX - GRASS_SCALE_MIN); + } + } + buildGrassField(); + + function colsInRow(row) { + return row === rows - 1 ? total - cols * (rows - 1) : cols; + } + + // Odd levels climb from the bottom (homeRow) to the top (0). + // Even levels descend from the top (0) back to the bottom (homeRow). + function goalRow() { + return level % 2 === 1 ? 0 : homeRow; + } + function startRow() { + return level % 2 === 1 ? homeRow : 0; + } + + function laneTypeFor(row) { + if (row === goalRow()) return "goal"; + if (row === startRow()) return "home"; + if (row % SAFE_ROW_INTERVAL === 0) return "safe"; + return "danger"; + } + + function buildLanes() { + lanes = new Array(rows); + for (let r = 0; r <= homeRow; r++) { + const type = laneTypeFor(r); + if (type !== "danger") { + lanes[r] = { type }; + continue; + } + const distFromGoal = Math.abs(r - goalRow()); + const baseCars = Math.max(1, Math.floor(cols / CAR_DENSITY_DIVISOR)); + const bonusCars = Math.floor((level - 1) * CARS_PER_LEVEL); + lanes[r] = { + type, + dir: r % 2 === 0 ? 1 : -1, + speed: BASE_SPEED + (homeRow - distFromGoal) * SPEED_RAMP + (level - 1) * 0.08, + carCount: Math.min(MAX_CARS_PER_LANE, baseCars + bonusCars), + offset: Math.random() * cols, + }; + } + } + + function carColumnsFor(lane) { + const spacing = cols / lane.carCount; + const out = []; + for (let k = 0; k < lane.carCount; k++) { + const pos = ((lane.offset + k * spacing) % cols + cols) % cols; + out.push(Math.round(pos) % cols); + } + return out; + } + + function resetPlayer() { + const row = startRow(); + const maxCol = colsInRow(row) - 1; + player = { row, col: Math.floor(maxCol / 2) }; + } + + function applyStyle(i, bg, scale) { + const key = bg + "|" + scale; + if (prevKey[i] === key) return; + prevKey[i] = key; + const el = cells[i]; + el.style.backgroundColor = bg; + el.style.transform = scale === 1 ? "" : `scale(${scale})`; + } + + function clearCell(el) { + el.style.backgroundColor = ""; + el.style.transform = ""; + el.textContent = ""; + el.style.fontSize = ""; + el.style.display = ""; + el.style.alignItems = ""; + el.style.justifyContent = ""; + } + + function paintPlayer(idx) { + if (playerElIdx !== -1 && playerElIdx !== idx) { + clearCell(cells[playerElIdx]); + prevKey[playerElIdx] = ""; + } + const el = cells[idx]; + el.style.backgroundColor = PLAYER_COLOR; + el.style.display = "flex"; + el.style.alignItems = "center"; + el.style.justifyContent = "center"; + el.style.fontSize = PLAYER_GLYPH_SIZE; + el.textContent = PLAYER_GLYPH; + prevKey[idx] = "player"; + playerElIdx = idx; + } + + function paintHazard(i, dir) { + const el = cells[i]; + el.style.backgroundColor = dir === 1 ? HAZARD_BG_REVERSE : HAZARD_BG; + el.style.display = "flex"; + el.style.alignItems = "center"; + el.style.justifyContent = "center"; + el.style.fontSize = HAZARD_GLYPH_SIZE; + el.style.transform = dir === 1 ? "scaleX(-1)" : ""; + el.textContent = HAZARD_GLYPH; + prevKey[i] = "hazard|" + dir; + } + + function clearHazard(i) { + clearCell(cells[i]); + prevKey[i] = ""; + } + + function renderGameOver() { + for (let i = 0; i < total; i++) { + const el = cells[i]; + el.style.backgroundColor = ""; + el.style.transform = ""; + el.style.display = "flex"; + el.style.alignItems = "center"; + el.style.justifyContent = "center"; + el.style.fontSize = GAME_OVER_GLYPH_SIZE; + el.textContent = GAME_OVER_GLYPH; + prevKey[i] = "gameover"; + } + playerElIdx = -1; + hazardIdxs = new Set(); + } + + function render() { + if (gameOver) { + renderGameOver(); + return; + } + const newHazards = new Map(); + for (let r = 0; r <= homeRow; r++) { + const lane = lanes[r]; + const rowCols = colsInRow(r); + const carCols = lane.type === "danger" ? carColumnsFor(lane) : null; + for (let c = 0; c < rowCols; c++) { + const i = r * cols + c; + if (carCols && carCols.includes(c)) { + newHazards.set(i, lane.dir); + } else { + applyStyle(i, grassColor[i], grassScale[i]); + } + } + } + for (const i of hazardIdxs) { + if (!newHazards.has(i)) clearHazard(i); + } + for (const [i, dir] of newHazards) { + paintHazard(i, dir); + } + hazardIdxs = new Set(newHazards.keys()); + + const pIdx = player.row * cols + player.col; + paintPlayer(pIdx); + } + + function updateStatus() { + if (gameOver) { + status.textContent = `Game over โ€” reached level ${level} (best ${bestLevel}). Click the grid to try again.`; + return; + } + if (justWon) { + status.textContent = `Level ${level} complete! Best ${bestLevel}`; + return; + } + const filled = "โ—".repeat(lives); + const empty = "โ—‹".repeat(Math.max(0, LIVES_START - lives)); + const dirLabel = level % 2 === 1 ? "climb up" : "climb down"; + const hint = reducedMotion ? "tap/arrows (turn-based)" : "arrows or tap"; + status.textContent = `Level ${level}: ${dirLabel} ยท ${filled}${empty} ยท Best ${bestLevel} โ€” ${hint}`; + } + + function ensureAudio() { + const AC = window.AudioContext || window.webkitAudioContext; + if (!AC) return null; + if (!audioCtx) audioCtx = new AC(); + if (audioCtx.state === "suspended") audioCtx.resume(); + return audioCtx; + } + + function playTone(freq, duration, type, gainLevel) { + const ctx = ensureAudio(); + if (!ctx) return; + const osc = ctx.createOscillator(); + const gain = ctx.createGain(); + osc.type = type; + osc.frequency.value = freq; + gain.gain.value = gainLevel; + gain.gain.exponentialRampToValueAtTime(0.0001, ctx.currentTime + duration); + osc.connect(gain).connect(ctx.destination); + osc.start(); + osc.stop(ctx.currentTime + duration); + } + + function sfxHop() { + playTone(520, 0.07, "square", 0.07); + } + + function sfxHit() { + playTone(120, 0.25, "sawtooth", 0.12); + } + + function sfxWin() { + [523, 659, 784, 1047].forEach((freq, i) => { + setTimeout(() => playTone(freq, 0.15, "square", 0.09), i * 90); + }); + } + + function sfxGameOver() { + playTone(200, 0.35, "sawtooth", 0.1); + setTimeout(() => playTone(140, 0.4, "sawtooth", 0.1), 150); + } + + function checkCollision() { + if (gameOver || resolving) return false; + const lane = lanes[player.row]; + if (lane.type !== "danger") return false; + if (!carColumnsFor(lane).includes(player.col)) return false; + + resolving = true; + lives--; + sfxHit(); + const hitIdx = player.row * cols + player.col; + clearCell(cells[hitIdx]); + cells[hitIdx].style.backgroundColor = HIT_COLOR; + cells[hitIdx].style.transform = "scale(1.5)"; + prevKey[hitIdx] = "hit"; + + const finalize = () => { + resolving = false; + if (lives <= 0) { + gameOver = true; + cancelAnimationFrame(raf); + raf = 0; + sfxGameOver(); + } else { + resetPlayer(); + } + updateStatus(); + render(); + }; + + if (reducedMotion) { + finalize(); + } else { + flashTimeoutId = setTimeout(finalize, HIT_FLASH_MS); + } + return true; + } + + function win() { + clearTimeout(winTimeoutId); + resolving = true; + justWon = true; + sfxWin(); + updateStatus(); + render(); + winTimeoutId = setTimeout(advanceLevel, WIN_PAUSE_MS); + } + + function advanceLevel() { + level++; + bestLevel = Math.max(bestLevel, level); + justWon = false; + resolving = false; + buildLanes(); + resetPlayer(); + prevKey.fill(""); + updateStatus(); + render(); + if (!reducedMotion) { + lastTs = 0; + if (raf === 0 && visible) raf = requestAnimationFrame(frame); + } + } + + function advanceLanesOneStep() { + for (let r = 0; r <= homeRow; r++) { + const lane = lanes[r]; + if (lane.type !== "danger") continue; + const steps = Math.max(1, Math.round(lane.speed)); + lane.offset = ((lane.offset + lane.dir * steps) % cols + cols) % cols; + } + } + + function advanceLanesContinuous(dt) { + for (let r = 0; r <= homeRow; r++) { + const lane = lanes[r]; + if (lane.type !== "danger") continue; + lane.offset = ((lane.offset + lane.dir * lane.speed * dt) % cols + cols) % cols; + } + } + + function tryMove(dr, dc) { + if (gameOver) { + newGame(); + return; + } + if (resolving) return; + const nr = player.row + dr; + const nc = player.col + dc; + if (nr < 0 || nr > homeRow) return; + const maxCol = colsInRow(nr) - 1; + if (nc < 0 || nc > maxCol) return; + + player.row = nr; + player.col = nc; + sfxHop(); + if (nr === goalRow()) { + win(); + return; + } + let hit = checkCollision(); + if (!hit && reducedMotion) { + advanceLanesOneStep(); + hit = checkCollision(); + } + render(); + updateStatus(); + } + + function frame(ts) { + const dt = lastTs ? (ts - lastTs) / 1000 : 0; + lastTs = ts; + if (!gameOver && !resolving) { + advanceLanesContinuous(dt); + checkCollision(); + } + render(); + if (visible && !gameOver) { + raf = requestAnimationFrame(frame); + } else { + raf = 0; + } + } + + function newGame() { + level = 1; + lives = LIVES_START; + gameOver = false; + justWon = false; + resolving = false; + playerElIdx = -1; + hazardIdxs = new Set(); + cells.forEach(clearCell); + prevKey.fill(""); + buildLanes(); + resetPlayer(); + updateStatus(); + render(); + if (!reducedMotion) { + lastTs = 0; + if (raf === 0 && visible) raf = requestAnimationFrame(frame); + } + } + + function onKeyDown(e) { + const moves = { + ArrowUp: [-1, 0], ArrowDown: [1, 0], ArrowLeft: [0, -1], ArrowRight: [0, 1], + w: [-1, 0], s: [1, 0], a: [0, -1], d: [0, 1], + W: [-1, 0], S: [1, 0], A: [0, -1], D: [0, 1], + }; + const mv = moves[e.key]; + if (!mv) return; + e.preventDefault(); + tryMove(mv[0], mv[1]); + } + + function onClick(e) { + grid.focus({ preventScroll: true }); + if (gameOver) { + newGame(); + return; + } + const rect = grid.getBoundingClientRect(); + const colF = ((e.clientX - rect.left) / rect.width) * cols; + const rowF = ((e.clientY - rect.top) / rect.height) * rows; + const dc = colF - (player.col + 0.5); + const dr = rowF - (player.row + 0.5); + if (Math.abs(dc) > Math.abs(dr)) tryMove(0, dc > 0 ? 1 : -1); + else tryMove(dr > 0 ? 1 : -1, 0); + } + + grid.addEventListener("keydown", onKeyDown); + grid.addEventListener("click", onClick); + + function rebuild() { + cancelAnimationFrame(raf); + raf = 0; + cols = getCols(); + rows = Math.ceil(total / cols); + homeRow = total % cols === 0 ? rows - 1 : rows - 2; + prevKey = new Array(total).fill(""); + newGame(); + } + wideQuery.addEventListener("change", rebuild); + + let io = null; + if (!reducedMotion) { + io = new IntersectionObserver((entries) => { + const nowVisible = entries.some((e) => e.isIntersecting); + if (nowVisible === visible) return; + visible = nowVisible; + if (visible && !gameOver && raf === 0) { + lastTs = 0; + raf = requestAnimationFrame(frame); + } else if (!visible) { + cancelAnimationFrame(raf); + raf = 0; + } + }); + io.observe(grid); + } + + newGame(); + + return () => { + cancelAnimationFrame(raf); + clearTimeout(flashTimeoutId); + clearTimeout(winTimeoutId); + grid.removeEventListener("keydown", onKeyDown); + grid.removeEventListener("click", onClick); + wideQuery.removeEventListener("change", rebuild); + if (io) io.disconnect(); + grid.removeAttribute("tabindex"); + grid.removeAttribute("aria-label"); + cells.forEach(clearCell); + if (audioCtx) audioCtx.close(); + status.remove(); + }; +} + +let currentGrid = null; +let cleanup = null; +function init() { + const grid = document.querySelector("[data-punchcard]"); + if (grid === currentGrid) return; + if (cleanup) cleanup(); + currentGrid = grid; + cleanup = grid ? setupGame(grid) : null; +} +init(); +document.addEventListener("htmx:load", init); diff --git a/appview/pages/profile-fx/mihaizaurus.at.js b/appview/pages/profile-fx/mihaizaurus.at.js new file mode 100644 --- /dev/null +++ b/appview/pages/profile-fx/mihaizaurus.at.js @@ -0,0 +1,674 @@ +// Intersex-Inclusive Progress Pride palette, arranged as left-to-right bands. +const prideColors = [ + "#E22016", "#F28917", "#F5E524", "#7BB82A", "#2C5B84", "#6D2380", + "#000000", "#945516", "#7BCCE5", "#F4AEC8", "#FFFFFF", "#FFD817", +]; +const particleShapes = ["triangle", "diamond", "dot", "hexagon"]; +const shapePaths = { + triangle: "polygon(50% 0, 100% 100%, 0 100%)", + diamond: "polygon(50% 0, 100% 50%, 50% 100%, 0 50%)", + hexagon: "polygon(25% 6.7%, 75% 6.7%, 100% 50%, 75% 93.3%, 25% 93.3%, 0 50%)", +}; + +const wideScreen = matchMedia("(min-width: 768px)"); +const reducedMotion = matchMedia("(prefers-reduced-motion: reduce)"); +let currentGrid = null; +let stop = null; + +function animate(grid) { + const dots = Array.from(grid.children, (cell) => cell.firstElementChild).filter(Boolean); + if (!dots.length) return () => {}; + + const originalStyles = dots.map((dot) => dot.getAttribute("style")); + const originalGridStyle = grid.getAttribute("style"); + const maxRadius = 5; + const accelerationX = new Float32Array(dots.length); + const accelerationY = new Float32Array(dots.length); + const effects = new Map(); + const fireworkTimers = new Set(); + const devourTimers = new Set(); + let audioContext = null; + const collisionNotes = [587, 659, 440, 659, 740, 880, 784, 740, 587, 659, 440, 440, 440, 494, 587, 587, + 587, 659, 440, 659, 740, 880, 784, 740, 587, 659, 440, 440, 440, 494, 587, 587, + 0, 494, 554, 587, 587, 659, 554, 494, 440, 0, 0, 494, 494, 554, 587, 494, + 440, 880, 0, 880, 659, 0, 494, 494, 554, 587, 494, 587, 659, 0, 0, 554, + 494, 440, 0, 0, 494, 494, 554, 587, 494, 440, 659, 659, 659, 740, 659, 0, + 587, 659, 740, 587, 659, 659, 659, 740, 659, 440, 0, 494, 554, 587, 494, 0, + 659, 740, 659, 440, 494, 587, 494, 740, 740, 659, 440, 494, 587, 494, 659, 659, + 587, 554, 494, 440, 494, 587, 494, 587, 659, 554, 494, 440, 440, 440, 659, 587, + 440, 494, 587, 494, 740, 740, 659, 440, 494, 587, 494, 880, 554, 587, 554, 494, + 440, 494, 587, 494, 587, 659, 554, 494, 440, 440, 659, 587, 0, 0, 494, 587, + 494, 587, 659, 0, 0, 554, 494, 440, 0, 0, 494, 494, 554, 587, 494, 440, + 0, 880, 880, 659, 740, 659, 587, 0, 440, 494, 554, 587, 494, 0, 554, 494, + 440, 0, 494, 494, 554, 587, 494, 440, 0, 0, 659, 659, 740, 659, 587, 587, + 659, 740, 659, 659, 659, 740, 659, 440, 440, 0, 440, 494, 554, 587, 494, 0, + 659, 740, 659, 440, 494, 587, 494, 740, 740, 659, 440, 494, 587, 494, 659, 659, + 587, 554, 494, 440, 494, 587, 494, 587, 659, 554, 494, 440, 440, 659, 587, 440, + 494, 587, 494, 740, 740, 659, 440, 494, 587, 494, 880, 554, 587, 554, 494, 440, + 494, 587, 494, 587, 659, 554, 494, 440, 440, 659, 587, 440, 494, 587, 494, 740, + 740, 659, 440, 494, 587, 494, 880, 554, 587, 554, 494, 440, 494, 587, 494, 587, + 659, 554, 494, 440, 440, 659, 587, 440, 494, 587, 494, 740, 740, 659, 440, 494, + 587, 494, 880, 554, 587, 554, 494, 440, 494, 587, 494, 587, 659, 554, 494, 440, + 440, 659, 587, 0]; + let collisionNoteIndex = 0; + let particles = []; + let aliveCount = dots.length; + let fireworksShown = false; + let cols = 0; + let halfWidth = 1; + let halfHeight = 1; + let frame = 0; + let resizeFrame = 0; + let lastTime = 0; + let visible = true; + let mouse = { x: 0, y: 0, active: false }; + let singularity = null; + + Object.assign(grid.style, { + backgroundColor: "rgba(7, 8, 24, 0.2)", + backgroundImage: "radial-gradient(ellipse at center, rgba(109, 35, 128, 0.34), transparent 68%)", + backgroundRepeat: "no-repeat", + boxShadow: "inset 0 0 20px rgba(109, 35, 128, 0.25)", + cursor: "none", + }); + + const blackHole = document.createElement("div"); + const makeEye = () => { + const eye = document.createElement("div"); + const pupil = document.createElement("div"); + Object.assign(eye.style, { + position: "absolute", + top: "7px", + width: "10px", + height: "10px", + borderRadius: "50%", + background: "white", + overflow: "hidden", + }); + Object.assign(pupil.style, { + position: "absolute", + left: "3px", + top: "3px", + width: "5px", + height: "5px", + borderRadius: "50%", + background: "#111", + }); + eye.append(pupil); + return { eye, pupil }; + }; + const leftEye = makeEye(); + const rightEye = makeEye(); + const smile = document.createElement("div"); + leftEye.eye.style.left = "5px"; + rightEye.eye.style.right = "5px"; + Object.assign(smile.style, { + position: "absolute", + left: "8px", + top: "19px", + width: "16px", + height: "6px", + borderBottom: "2px solid white", + borderRadius: "0 0 60% 60%", + transform: "rotate(9deg)", + }); + Object.assign(blackHole.style, { + position: "fixed", + width: "32px", + height: "32px", + borderRadius: "50%", + background: "radial-gradient(circle at 42% 38%, #242424 0 9%, #050505 34%, #000 70%)", + boxShadow: "0 0 10px rgba(109, 35, 128, 0.55)", + pointerEvents: "none", + display: "none", + zIndex: "10002", + }); + blackHole.dataset.profileFx = "black-hole"; + blackHole.append(leftEye.eye, rightEye.eye, smile); + document.body.append(blackHole); + + const paintBlackHole = (time) => { + const location = singularity || mouse; + if (!singularity && !mouse.active) { + blackHole.style.display = "none"; + return; + } + const wobbleX = Math.sin(time / 90) * 1.4; + const wobbleY = Math.cos(time / 120) * 1.2; + blackHole.style.display = "block"; + blackHole.style.left = `${location.screenX}px`; + blackHole.style.top = `${location.screenY}px`; + blackHole.style.transform = `translate(-50%, -50%) rotate(${(Math.sin(time / 280) * 4).toFixed(1)}deg)`; + leftEye.pupil.style.transform = `translate(${wobbleX.toFixed(1)}px, ${wobbleY.toFixed(1)}px)`; + rightEye.pupil.style.transform = `translate(${(-wobbleX * 0.7).toFixed(1)}px, ${(wobbleY * 0.8).toFixed(1)}px)`; + }; + + const getAudioContext = () => (audioContext?.state === "running" ? audioContext : null); + + const playTone = (frequency, duration, delay, volume) => { + const context = getAudioContext(); + if (!context) return; + const start = context.currentTime + delay; + const oscillator = context.createOscillator(); + const gain = context.createGain(); + oscillator.type = "sine"; + oscillator.frequency.setValueAtTime(frequency, start); + gain.gain.setValueAtTime(0.0001, start); + gain.gain.exponentialRampToValueAtTime(volume, start + 0.015); + gain.gain.exponentialRampToValueAtTime(0.0001, start + duration); + oscillator.connect(gain).connect(context.destination); + oscillator.start(start); + oscillator.stop(start + duration + 0.02); + }; + + const playPop = () => { + const frequency = collisionNotes[collisionNoteIndex]; + collisionNoteIndex = (collisionNoteIndex + 1) % collisionNotes.length; + // Zero or a negative value is a rest; advance the sequence without a tone. + if (!Number.isFinite(frequency) || frequency <= 0) return; + const context = getAudioContext(); + if (!context) return; + const start = context.currentTime; + const oscillator = context.createOscillator(); + const gain = context.createGain(); + oscillator.type = "sine"; + oscillator.frequency.setValueAtTime(frequency, start); + oscillator.frequency.exponentialRampToValueAtTime(frequency * 0.82, start + 0.12); + gain.gain.setValueAtTime(0.0001, start); + gain.gain.exponentialRampToValueAtTime(0.05, start + 0.01); + gain.gain.exponentialRampToValueAtTime(0.0001, start + 0.14); + oscillator.connect(gain).connect(context.destination); + oscillator.start(start); + oscillator.stop(start + 0.16); + }; + + const playTada = () => { + playTone(523.25, 0.24, 0, 0.045); + playTone(659.25, 0.24, 0.13, 0.045); + playTone(783.99, 0.55, 0.26, 0.06); + }; + + const unlockAudio = () => { + if (!audioContext) { + const AudioContext = window.AudioContext || window.webkitAudioContext; + if (!AudioContext) return; + audioContext = new AudioContext(); + } + if (audioContext.state === "suspended") audioContext.resume().catch(() => {}); + }; + + const moveMouse = (event) => { + if (singularity) return; + const gridRect = grid.getBoundingClientRect(); + mouse = { + x: event.clientX - gridRect.left - halfWidth, + y: event.clientY - gridRect.top - halfHeight, + screenX: event.clientX, + screenY: event.clientY, + active: true, + }; + paintBlackHole(performance.now()); + }; + + const leaveMouse = () => { + if (!singularity) mouse = { ...mouse, active: false }; + paintBlackHole(performance.now()); + }; + + const initialRadius = (dot) => { + if (dot.classList.contains("size-[4px]")) return 1; + if (dot.classList.contains("size-[7px]")) return 3.5; + if (dot.classList.contains("size-[6px]")) return 3; + return 2.5; + }; + + const paintParticle = (particle) => { + const dot = dots[particle.index]; + const diameter = (particle.baseRadius * 2).toFixed(2); + const path = shapePaths[particle.shape]; + const color = dot.style.backgroundColor || prideColors[particle.colorIndex]; + const glow = (1.5 + Math.min(particle.radius, maxRadius) * 0.8).toFixed(1); + dot.style.width = `${diameter}px`; + dot.style.height = `${diameter}px`; + dot.style.minWidth = `${diameter}px`; + dot.style.minHeight = `${diameter}px`; + dot.style.maxWidth = "none"; + dot.style.maxHeight = "none"; + dot.style.aspectRatio = "1 / 1"; + dot.style.borderRadius = particle.shape === "dot" ? "50%" : "0"; + dot.style.clipPath = path || "none"; + dot.style.webkitClipPath = path || "none"; + dot.style.filter = `saturate(1.45) brightness(1.18) drop-shadow(0 0 ${glow}px ${color})`; + dot.style.flexShrink = "0"; + dot.style.opacity = particle.radius < 1.5 ? "0.45" : "1"; + dot.style.visibility = particle.alive ? "visible" : "hidden"; + }; + + const showBlast = (particle) => { + const gridRect = grid.getBoundingClientRect(); + const color = dots[particle.index].style.backgroundColor; + const blast = document.createElement("div"); + const size = Math.max(8, particle.radius * 4); + + Object.assign(blast.style, { + position: "fixed", + left: `${gridRect.left + gridRect.width / 2 + particle.x}px`, + top: `${gridRect.top + gridRect.height / 2 + particle.y}px`, + width: `${size}px`, + height: `${size}px`, + border: `1px solid ${color}`, + borderRadius: "9999px", + boxShadow: `0 0 8px ${color}`, + pointerEvents: "none", + transform: "translate(-50%, -50%) scale(0.3)", + transition: "transform 280ms ease-out, opacity 280ms ease-out", + opacity: "1", + zIndex: "9999", + }); + + document.body.append(blast); + requestAnimationFrame(() => { + blast.style.transform = "translate(-50%, -50%) scale(2.8)"; + blast.style.opacity = "0"; + }); + const timer = setTimeout(() => { + effects.delete(blast); + blast.remove(); + }, 300); + effects.set(blast, timer); + }; + + const showCongrats = (left, top) => { + const message = document.createElement("div"); + message.dataset.profileFx = "congrats"; + message.textContent = "Congrats!"; + Object.assign(message.style, { + position: "fixed", + left: `${left}px`, + top: `${top - 28}px`, + color: "white", + fontFamily: "ui-rounded, system-ui, sans-serif", + fontSize: "clamp(22px, 4vw, 38px)", + fontWeight: "800", + letterSpacing: "0.04em", + pointerEvents: "none", + textShadow: "0 0 8px #7BCCE5, 0 0 18px #6D2380", + transform: "translate(-50%, -50%) scale(0.5)", + transition: "transform 500ms cubic-bezier(.15,.8,.25,1), opacity 600ms ease-out", + opacity: "0", + zIndex: "10000", + }); + document.body.append(message); + requestAnimationFrame(() => { + message.style.transform = "translate(-50%, -50%) scale(1)"; + message.style.opacity = "1"; + }); + effects.set(message, []); + }; + + const devourPage = (target) => { + const letters = []; + const textNodes = []; + const walker = document.createTreeWalker(document.body, NodeFilter.SHOW_TEXT, { + acceptNode(node) { + const parent = node.parentElement; + if (!parent || !node.nodeValue.trim()) return NodeFilter.FILTER_REJECT; + if (parent.closest("[data-punchcard], [data-profile-fx], script, style, noscript, textarea, select, option")) { + return NodeFilter.FILTER_REJECT; + } + return NodeFilter.FILTER_ACCEPT; + }, + }); + + while (walker.nextNode()) { + const node = walker.currentNode; + const parent = node.parentElement; + const text = node.nodeValue; + const style = getComputedStyle(parent); + const start = letters.length; + + for (let index = 0; index < text.length; index += 1) { + if (/\s/.test(text[index])) continue; + const range = document.createRange(); + range.setStart(node, index); + range.setEnd(node, index + 1); + const rect = range.getBoundingClientRect(); + if (!rect.width && !rect.height) continue; + letters.push({ + character: text[index], + left: rect.left, + top: rect.top, + width: rect.width, + height: rect.height, + font: style.font, + color: style.color, + order: letters.length, + }); + } + if (letters.length > start) textNodes.push({ node, delay: start * 18 }); + } + + textNodes.forEach(({ node, delay }) => { + const timer = setTimeout(() => { + devourTimers.delete(timer); + node.nodeValue = ""; + }, delay); + devourTimers.add(timer); + }); + + letters.forEach((letter) => { + const glyph = document.createElement("span"); + const delay = letter.order * 18; + glyph.dataset.profileFx = "devoured-letter"; + glyph.textContent = letter.character; + Object.assign(glyph.style, { + position: "fixed", + left: `${letter.left}px`, + top: `${letter.top}px`, + width: `${Math.max(1, letter.width)}px`, + height: `${Math.max(1, letter.height)}px`, + color: letter.color, + font: letter.font, + lineHeight: `${Math.max(1, letter.height)}px`, + pointerEvents: "none", + transformOrigin: "center", + transition: "transform 1.5s cubic-bezier(.1,.8,.2,1), opacity 1.5s ease-in", + zIndex: "10001", + }); + document.body.append(glyph); + + const startTimer = setTimeout(() => { + devourTimers.delete(startTimer); + const dx = target.screenX - (letter.left + letter.width / 2); + const dy = target.screenY - (letter.top + letter.height / 2); + glyph.style.transform = `translate(${dx.toFixed(1)}px, ${dy.toFixed(1)}px) scale(0.08) rotate(${(Math.random() * 720 - 360).toFixed(0)}deg)`; + glyph.style.opacity = "0"; + }, delay + 80); + const removeTimer = setTimeout(() => { + devourTimers.delete(removeTimer); + effects.delete(glyph); + glyph.remove(); + }, delay + 1650); + devourTimers.add(startTimer); + devourTimers.add(removeTimer); + effects.set(glyph, [startTimer, removeTimer]); + }); + }; + + const becomeSingularity = (particle) => { + const gridRect = grid.getBoundingClientRect(); + singularity = { + screenX: gridRect.left + gridRect.width / 2 + particle.x, + screenY: gridRect.top + gridRect.height / 2 + particle.y, + }; + mouse = { ...mouse, active: false }; + particle.vx = 0; + particle.vy = 0; + dots[particle.index].style.visibility = "hidden"; + paintBlackHole(performance.now()); + devourPage(singularity); + }; + + const showFireworks = (particle) => { + const gridRect = grid.getBoundingClientRect(); + const left = gridRect.left + gridRect.width / 2 + particle.x; + const top = gridRect.top + gridRect.height / 2 + particle.y; + + const launchWave = (wave) => { + const sparks = wave === 0 ? 28 : 18; + for (let index = 0; index < sparks; index += 1) { + const spark = document.createElement("div"); + const angle = (Math.PI * 2 * index) / sparks + (Math.random() - 0.5) * 0.25; + const distance = 38 + Math.random() * 85; + const color = prideColors[(index + wave * 3) % prideColors.length]; + Object.assign(spark.style, { + position: "fixed", + left: `${left}px`, + top: `${top}px`, + width: "4px", + height: "4px", + backgroundColor: color, + borderRadius: "50%", + boxShadow: `0 0 6px ${color}`, + pointerEvents: "none", + transform: "translate(-50%, -50%)", + transition: "transform 1.1s cubic-bezier(.15,.8,.25,1)", + zIndex: "9999", + }); + document.body.append(spark); + requestAnimationFrame(() => { + spark.style.transform = `translate(-50%, -50%) translate(${(Math.cos(angle) * distance).toFixed(1)}px, ${(Math.sin(angle) * distance).toFixed(1)}px)`; + }); + effects.set(spark, []); + } + }; + + playTada(); + showCongrats(gridRect.left + gridRect.width / 2, gridRect.top + gridRect.height / 2); + for (let wave = 0; wave < 7; wave += 1) { + const timer = setTimeout(() => { + fireworkTimers.delete(timer); + launchWave(wave); + }, wave * 800); + fireworkTimers.add(timer); + } + }; + + const reset = () => { + if (singularity) return; + // Set every base size before measuring. Absorption only uses transform scale, + // so changing a shape later cannot shift the grid beneath the physics mesh. + const seeds = dots.map((dot, index) => { + const radius = initialRadius(dot); + return { + index, + baseRadius: radius, + radius, + mass: (radius * radius) / 3, + colorIndex: Math.floor(((index % cols) * prideColors.length) / cols), + shape: particleShapes[Math.floor(Math.random() * particleShapes.length)], + alive: true, + }; + }); + seeds.forEach(paintParticle); + + const gridRect = grid.getBoundingClientRect(); + halfWidth = Math.max(1, gridRect.width / 2); + halfHeight = Math.max(1, gridRect.height / 2); + particles = seeds.map((particle) => { + const cellRect = dots[particle.index].parentElement.getBoundingClientRect(); + const originX = cellRect.left - gridRect.left + cellRect.width / 2 - halfWidth; + const originY = cellRect.top - gridRect.top + cellRect.height / 2 - halfHeight; + const spin = 0.25 + (Math.random() - 0.5) * 0.08; + return { + ...particle, + originX, + originY, + x: originX, + y: originY, + vx: -originY * spin, + vy: originX * spin, + }; + }); + aliveCount = particles.length; + fireworksShown = false; + lastTime = 0; + }; + + const layout = () => { + cols = wideScreen.matches ? 14 : 28; + dots.forEach((dot, index) => { + const column = index % cols; + dot.style.backgroundColor = prideColors[Math.floor((column * prideColors.length) / cols)]; + dot.style.transition = "none"; + dot.style.transformOrigin = "center"; + dot.style.willChange = "transform"; + }); + reset(); + }; + + const absorb = (winner, loser) => { + const totalMass = winner.mass + loser.mass; + winner.x = (winner.x * winner.mass + loser.x * loser.mass) / totalMass; + winner.y = (winner.y * winner.mass + loser.y * loser.mass) / totalMass; + winner.vx = (winner.vx * winner.mass + loser.vx * loser.mass) / totalMass; + winner.vy = (winner.vy * winner.mass + loser.vy * loser.mass) / totalMass; + winner.radius = Math.min(maxRadius, Math.hypot(winner.radius, loser.radius)); + winner.mass = (winner.radius * winner.radius) / 3; + winner.colorIndex = (winner.colorIndex + loser.colorIndex + 1 + Math.floor(Math.random() * 3)) % prideColors.length; + winner.shape = particleShapes[Math.floor(Math.random() * particleShapes.length)]; + dots[winner.index].style.backgroundColor = prideColors[winner.colorIndex]; + loser.alive = false; + aliveCount -= 1; + paintParticle(winner); + paintParticle(loser); + showBlast(winner); + playPop(); + if (aliveCount === 1 && !fireworksShown) { + fireworksShown = true; + becomeSingularity(winner); + showFireworks(winner); + } + }; + + const tick = (time) => { + frame = 0; + if (!visible) return; + + const seconds = lastTime ? Math.min((time - lastTime) / 1000, 1 / 30) : 0; + lastTime = time; + paintBlackHole(time); + accelerationX.fill(0); + accelerationY.fill(0); + const attractionBoost = aliveCount < 5 ? 1 + ((5 - aliveCount) / 4) * 3 : 1; + + for (let i = 0; i < particles.length; i += 1) { + const a = particles[i]; + if (!a.alive) continue; + + if (mouse.active) { + const dx = mouse.x - a.x; + const dy = mouse.y - a.y; + const distanceSquared = dx * dx + dy * dy + 576; + const pull = 200000 / (distanceSquared * Math.sqrt(distanceSquared)); + accelerationX[i] += dx * pull; + accelerationY[i] += dy * pull; + } + + for (let j = i + 1; j < particles.length; j += 1) { + const b = particles[j]; + if (!b.alive) continue; + + const dx = b.x - a.x; + const dy = b.y - a.y; + const collisionDistance = a.radius + b.radius; + if (dx * dx + dy * dy < collisionDistance * collisionDistance) { + const winner = a.radius >= b.radius ? a : b; + const loser = winner === a ? b : a; + absorb(winner, loser); + if (loser === a) break; + continue; + } + + // Every dot pulls on every other dot; larger combined dots have more mass. + const distanceSquared = dx * dx + dy * dy + 324; + const pull = (560 * attractionBoost) / (distanceSquared * Math.sqrt(distanceSquared)); + accelerationX[i] += dx * pull * b.mass; + accelerationY[i] += dy * pull * b.mass; + accelerationX[j] -= dx * pull * a.mass; + accelerationY[j] -= dy * pull * a.mass; + } + } + + particles.forEach((particle, index) => { + if (!particle.alive) return; + + const overflow = Math.max( + 0, + (Math.abs(particle.x) - halfWidth) / halfWidth, + (Math.abs(particle.y) - halfHeight) / halfHeight, + ); + if (overflow > 0) { + // The farther it escapes the punchcard, the stronger its pull back to centre. + const pullBack = 0.12 * overflow + 0.9 * overflow * overflow; + particle.vx -= particle.x * pullBack * seconds; + particle.vy -= particle.y * pullBack * seconds; + } + + particle.vx = (particle.vx + accelerationX[index] * seconds) * 0.995; + particle.vy = (particle.vy + accelerationY[index] * seconds) * 0.995; + particle.x += particle.vx * seconds; + particle.y += particle.vy * seconds; + const scale = particle.radius / particle.baseRadius; + dots[index].style.transform = `translate(${(particle.x - particle.originX).toFixed(2)}px, ${(particle.y - particle.originY).toFixed(2)}px) scale(${scale.toFixed(3)})`; + }); + + frame = requestAnimationFrame(tick); + }; + + const resize = () => { + if (resizeFrame) return; + resizeFrame = requestAnimationFrame(() => { + resizeFrame = 0; + reset(); + }); + }; + + layout(); + wideScreen.addEventListener("change", layout); + window.addEventListener("resize", resize); + grid.addEventListener("pointerdown", unlockAudio); + grid.addEventListener("pointermove", moveMouse, { passive: true }); + grid.addEventListener("pointerleave", leaveMouse); + + const observer = new IntersectionObserver((entries) => { + visible = entries.some((entry) => entry.isIntersecting); + if (visible && !frame) frame = requestAnimationFrame(tick); + else if (!visible && frame) { + cancelAnimationFrame(frame); + frame = 0; + } + }); + observer.observe(grid); + frame = requestAnimationFrame(tick); + + return () => { + cancelAnimationFrame(frame); + observer.disconnect(); + cancelAnimationFrame(resizeFrame); + wideScreen.removeEventListener("change", layout); + window.removeEventListener("resize", resize); + grid.removeEventListener("pointerdown", unlockAudio); + grid.removeEventListener("pointermove", moveMouse); + grid.removeEventListener("pointerleave", leaveMouse); + blackHole.remove(); + fireworkTimers.forEach((timer) => clearTimeout(timer)); + devourTimers.forEach((timer) => clearTimeout(timer)); + effects.forEach((timers, effect) => { + (Array.isArray(timers) ? timers : [timers]).forEach((timer) => clearTimeout(timer)); + effect.remove(); + }); + if (originalGridStyle === null) grid.removeAttribute("style"); + else grid.setAttribute("style", originalGridStyle); + dots.forEach((dot, index) => { + if (originalStyles[index] === null) dot.removeAttribute("style"); + else dot.setAttribute("style", originalStyles[index]); + }); + }; +} + +function mount() { + const grid = document.querySelector("[data-punchcard]"); + if (grid === currentGrid && stop && !reducedMotion.matches) return; + + if (stop) stop(); + stop = null; + currentGrid = grid; + + if (!grid || reducedMotion.matches) return; + stop = animate(grid); +} + +mount(); +document.addEventListener("htmx:load", mount); +reducedMotion.addEventListener("change", mount); -- tangled.sh