From 19ead6a1f970b7a920ffef15e8a0ab0e47a38bac Mon Sep 17 00:00:00 2001 From: Anirudh Oppiliappan Date: Tue, 14 Jul 2026 20:07:32 +0300 Subject: [PATCH] appview/pages/profile-fx: several profile-fx additions appview/pages/profile-fx/oppili.js: oppi.li appview/pages/profile-fx/wilb.me.js: Wilhelm Berggren appview/pages/profile-fx/kandake.js: ashex appview/pages/profile-fx/gdorsi.bsky.social.js: Gustavo Dorsi appview/pages/profile-fx/punchcard-td.js: J.H. Roemer appview/pages/profile-fx/willow.sh.js: Willow appview/pages/profile-fx/luisstd.js: Luis Steidle appview/pages/profile-fx/chancey.dev.js: Jonathan Chancey appview/pages/profile-fx/matthewlipski.tngl.sh.js: Matthew Lipski appview/state/profile.go: Lewis Signed-off-by: Anirudh Oppiliappan --- appview/pages/profile-fx/chancey.dev.js | 565 ++++++++++++++++++ .../pages/profile-fx/gdorsi.bsky.social.js | 313 ++++++++++ appview/pages/profile-fx/kandake.js | 236 ++++++++ appview/pages/profile-fx/luisstd.js | 201 +++++++ .../pages/profile-fx/matthewlipski.tngl.sh.js | 349 +++++++++++ appview/pages/profile-fx/oppili.js | 326 ++++++++++ appview/pages/profile-fx/punchcard-td.js | 481 +++++++++++++++ appview/pages/profile-fx/wilb.me.js | 347 +++++++++++ appview/pages/profile-fx/willow.sh.js | 60 ++ appview/state/profile.go | 9 + 10 files changed, 2887 insertions(+) create mode 100644 appview/pages/profile-fx/chancey.dev.js create mode 100644 appview/pages/profile-fx/gdorsi.bsky.social.js create mode 100644 appview/pages/profile-fx/kandake.js create mode 100644 appview/pages/profile-fx/luisstd.js create mode 100644 appview/pages/profile-fx/matthewlipski.tngl.sh.js create mode 100644 appview/pages/profile-fx/oppili.js create mode 100644 appview/pages/profile-fx/punchcard-td.js create mode 100644 appview/pages/profile-fx/wilb.me.js create mode 100644 appview/pages/profile-fx/willow.sh.js diff --git a/appview/pages/profile-fx/chancey.dev.js b/appview/pages/profile-fx/chancey.dev.js new file mode 100644 index 00000000..bfebb046 --- /dev/null +++ b/appview/pages/profile-fx/chancey.dev.js @@ -0,0 +1,565 @@ +const punchcard = document.querySelector("[data-punchcard]"); + +if (punchcard) { + const reduceMotion = matchMedia("(prefers-reduced-motion: reduce)"); + const wideScreen = matchMedia("(min-width: 768px)"); + + const purple = "#b57edc"; + const white = "#fff"; + const idleBackground = `linear-gradient(90deg, ${purple} 0 50%, ${white} 50% 100%)`; + + const activityCache = new WeakMap(); + + let dots = []; + let frame = 0; + let hovered = false; + let pointerX = 0.5; + let pointerY = 0.5; + let coinX = NaN; + let coinY = NaN; + let velocityX = 0; + let velocityY = 0; + let coinMix = 0; + let evaporateStart = null; + let evaporateFrom = 0; + let clickSpinStart = -Infinity; + let clickSpinDirection = 1; + let coalesceStart = -Infinity; + let explosionStart = -Infinity; + let explosionPower = 1; + let explosionX = 0; + let explosionY = 0; + let joltStart = -Infinity; + let joltX = 0; + let joltY = 0; + let joltDirection = 1; + let suppressGatherUntil = 0; + let waitForReenterAfterExplosion = false; + let lastTime = 0; + let layout = { cols: 28, rows: 1 }; + + function columnCount() { + return wideScreen.matches ? 14 : 28; + } + + function clamp(value, min, max) { + return Math.min(max, Math.max(min, value)); + } + + function lerp(a, b, t) { + return a + (b - a) * t; + } + + function ease(t) { + return t * t * (3 - 2 * t); + } + + function smoothstep(edge0, edge1, value) { + const t = clamp((value - edge0) / (edge1 - edge0), 0, 1); + return t * t * (3 - 2 * t); + } + + function ring(distance, front, width, energy = 1) { + return (1 - smoothstep(0, width, Math.abs(distance - front))) * energy; + } + + function heldSpin(raw, hold) { + return raw - Math.sin(raw * 2) * hold * 0.5; + } + + function occasionalSpin(now, activity, phase) { + const active = 0.16; + const period = lerp(9800, 5600, activity); + const cycle = ((now + phase * 1400) % period) / period; + + if (cycle >= active) return Math.PI * 2; + + return ease(cycle / active) * Math.PI * 2; + } + + function numberFromLabel(text) { + const match = text?.match( + /(\d+(?:\.\d+)?)\s+(?:commit|commits|contribution|contributions|change|changes)/i, + ); + + return match ? Number(match[1]) : null; + } + + function explicitActivity(dot, wrapper) { + for (const element of [dot, wrapper]) { + for (const key of ["count", "commits", "contributions", "value", "level", "intensity"]) { + const value = element.dataset?.[key]; + if (value !== undefined && value !== "" && !Number.isNaN(Number(value))) { + return Number(value); + } + } + + const label = + `${element.getAttribute("aria-label") || ""} ${element.getAttribute("title") || ""}`; + const labelValue = numberFromLabel(label); + if (labelValue !== null) return labelValue; + + const className = typeof element.className === "string" ? element.className : ""; + const classValue = className.match(/(?:level|count|activity|intensity)-?(\d+)/i); + if (classValue) return Number(classValue[1]); + } + + return null; + } + + function colorActivity(color) { + const match = color.match(/rgba?\(([\d.]+)[,\s]+([\d.]+)[,\s]+([\d.]+)(?:[,\s/]+([\d.]+))?\)/i); + if (!match) return 0; + + const r = Number(match[1]); + const g = Number(match[2]); + const b = Number(match[3]); + const a = match[4] === undefined ? 1 : Number(match[4]); + if (a <= 0) return 0; + + const max = Math.max(r, g, b); + const min = Math.min(r, g, b); + const saturation = max === 0 ? 0 : (max - min) / max; + const luminance = (0.2126 * r + 0.7152 * g + 0.0722 * b) / 255; + const greenBias = clamp((g - Math.max(r, b)) / 160, 0, 1); + + return clamp((saturation * 0.45 + greenBias * 0.55) * (0.55 + (1 - luminance) * 0.65), 0, 1); + } + + function readActivity(dot, wrapper) { + if (activityCache.has(dot)) return activityCache.get(dot); + + const signal = { + explicit: explicitActivity(dot, wrapper), + color: colorActivity(getComputedStyle(dot).backgroundColor), + }; + + activityCache.set(dot, signal); + return signal; + } + + function refreshLayout() { + layout.cols = columnCount(); + layout.rows = Math.ceil(dots.length / layout.cols) || 1; + } + + function setup() { + const cols = columnCount(); + + const items = Array.from(punchcard.children) + .map((wrapper, index) => { + const dot = wrapper.firstElementChild; + if (!dot) return null; + + return { + dot, + wrapper, + signal: readActivity(dot, wrapper), + col: index % cols, + row: Math.floor(index / cols), + phase: index * 0.43, + }; + }) + .filter(Boolean); + + const maxExplicit = Math.max(0, ...items.map((item) => item.signal.explicit || 0)); + const maxColor = Math.max(0.001, ...items.map((item) => item.signal.color || 0)); + + dots = items.map((item) => { + const activity = + item.signal.explicit !== null + ? maxExplicit > 0 + ? Math.log1p(item.signal.explicit) / Math.log1p(maxExplicit) + : 0 + : item.signal.color > 0.015 + ? item.signal.color / maxColor + : 0; + + item.wrapper.style.perspective = "90px"; + + item.dot.style.transition = "none"; + item.dot.style.borderRadius = "50%"; + item.dot.style.transformOrigin = "50% 50%"; + item.dot.style.backfaceVisibility = "visible"; + item.dot.style.willChange = "transform, opacity, background, box-shadow, filter"; + item.dot.style.background = idleBackground; + + return { + ...item, + activity: clamp(activity, 0, 1), + }; + }); + + refreshLayout(); + + if (!Number.isFinite(coinX)) coinX = (layout.cols - 1) / 2; + if (!Number.isFinite(coinY)) coinY = (layout.rows - 1) / 2; + } + + function radius() { + return Math.max(2.8, Math.min(layout.cols * 0.3, layout.rows * 0.24)); + } + + function bounds() { + return { + minX: 0, + maxX: layout.cols - 1, + minY: 0, + maxY: layout.rows - 1, + }; + } + + function setPointer(event) { + const rect = punchcard.getBoundingClientRect(); + + pointerX = clamp((event.clientX - rect.left) / rect.width, 0, 1); + pointerY = clamp((event.clientY - rect.top) / rect.height, 0, 1); + } + + function evaporateCoin() { + evaporateStart = performance.now(); + evaporateFrom = Math.max(coinMix, 0.28); + + const speed = Math.hypot(velocityX, velocityY); + if (speed < 2.8) { + const angle = speed > 0.2 ? Math.atan2(velocityY, velocityX) : -0.72; + velocityX = Math.cos(angle) * 3.5; + velocityY = Math.sin(angle) * 3.5; + } + } + + function triggerCoalesce(now = performance.now()) { + coalesceStart = now - 90; + evaporateStart = null; + coinMix = Math.max(coinMix, 0.78); + } + + function triggerSpin(now = performance.now()) { + clickSpinStart = now; + clickSpinDirection *= -1; + evaporateStart = null; + coinMix = Math.max(coinMix, 0.62); + } + + function triggerExplosion(now = performance.now(), power = 1) { + const { minX, maxX, minY, maxY } = bounds(); + + explosionStart = now; + explosionPower = power; + explosionX = Number.isFinite(coinX) ? coinX : clamp(pointerX * (layout.cols - 1), minX, maxX); + explosionY = Number.isFinite(coinY) ? coinY : clamp(pointerY * (layout.rows - 1), minY, maxY); + suppressGatherUntil = now + 1050 + power * 260; + waitForReenterAfterExplosion = hovered; + if (hovered) coalesceStart = Infinity; + evaporateStart = null; + evaporateFrom = Math.max(coinMix, 0.95); + coinMix = evaporateFrom; + + const angle = Math.atan2(velocityY || -0.45, velocityX || 0.9); + velocityX = Math.cos(angle) * (4.8 + power * 0.7); + velocityY = Math.sin(angle) * (4.8 + power * 0.7); + } + + function triggerJolt(now = performance.now()) { + const { minX, maxX, minY, maxY } = bounds(); + + joltStart = now; + joltX = clamp(pointerX * (layout.cols - 1), minX, maxX); + joltY = clamp(pointerY * (layout.rows - 1), minY, maxY); + joltDirection *= -1; + } + + function resumeCoalesceAfterReenter(now = performance.now()) { + if (!waitForReenterAfterExplosion) return false; + + waitForReenterAfterExplosion = false; + coalesceStart = Math.max(now, suppressGatherUntil); + if (now >= suppressGatherUntil) coinMix = Math.max(coinMix, 0.78); + return true; + } + + function paintStill() { + for (const { dot, activity } of dots) { + dot.style.background = idleBackground; + dot.style.opacity = `${0.45 + activity * 0.55}`; + dot.style.transform = `scale(${0.72 + activity * 0.42})`; + dot.style.boxShadow = "none"; + dot.style.filter = "none"; + } + } + + function animate(now) { + const dt = lastTime ? clamp((now - lastTime) / 1000, 0.001, 0.04) : 0.016; + lastTime = now; + + const r = radius(); + const { minX, maxX, minY, maxY } = bounds(); + const targetX = clamp(pointerX * (layout.cols - 1), minX, maxX); + const targetY = clamp(pointerY * (layout.rows - 1), minY, maxY); + const explosionDuration = 1050 + explosionPower * 250; + const explosionT = clamp((now - explosionStart) / explosionDuration, 0, 1); + const exploding = now - explosionStart >= 0 && explosionT < 1; + const joltDuration = 620; + const joltT = clamp((now - joltStart) / joltDuration, 0, 1); + const jolting = now - joltStart >= 0 && joltT < 1; + const evaporateDuration = 1650; + const evaporateT = + evaporateStart !== null ? clamp((now - evaporateStart) / evaporateDuration, 0, 1) : 1; + const evaporating = evaporateStart !== null && evaporateT < 1; + const effectiveHovered = + hovered && !exploding && !waitForReenterAfterExplosion && now >= suppressGatherUntil; + + const previousX = coinX; + const previousY = coinY; + + if (effectiveHovered) { + evaporateStart = null; + + const follow = 1 - Math.exp(-9.5 * dt); + coinX += (targetX - coinX) * follow; + coinY += (targetY - coinY) * follow; + + velocityX = (coinX - previousX) / dt; + velocityY = (coinY - previousY) / dt; + + coinMix += (1 - coinMix) * (1 - Math.exp(-18 * dt)); + } else { + if (exploding) { + coinMix = evaporateFrom * (1 - smoothstep(0.06, 0.74, explosionT)); + } else if (waitForReenterAfterExplosion) { + coinMix = 0; + evaporateStart = null; + } else if (evaporating) { + const dissolve = smoothstep(0.04, 0.96, evaporateT); + coinMix = evaporateFrom * (1 - dissolve); + if (evaporateT >= 1) evaporateStart = null; + } else if (evaporateStart !== null) { + coinMix = 0; + evaporateStart = null; + } else { + coinMix += (0 - coinMix) * (1 - Math.exp(-3.5 * dt)); + } + + if (coinMix > 0.01) { + coinX += velocityX * dt; + coinY += velocityY * dt; + + if (coinX < minX || coinX > maxX) { + coinX = clamp(coinX, minX, maxX); + velocityX *= -0.9; + } + + if (coinY < minY || coinY > maxY) { + coinY = clamp(coinY, minY, maxY); + velocityY *= -0.9; + } + + const driftDamping = Math.exp(-0.22 * dt); + velocityX *= driftDamping; + velocityY *= driftDamping; + } + } + + coinX = clamp(coinX, minX, maxX); + coinY = clamp(coinY, minY, maxY); + + const mix = ease(coinMix); + const rippleEnergy = Math.sin(mix * Math.PI); + const gridReach = Math.hypot(layout.cols, layout.rows) + r; + const coalesceDuration = 520; + const coalesceT = clamp((now - coalesceStart) / coalesceDuration, 0, 1); + const coalescing = effectiveHovered && now - coalesceStart >= 0 && coalesceT < 1; + const coalesceEnergy = coalescing ? Math.pow(1 - coalesceT, 0.38) : 0; + + const hopCycle = (now / 1580) % 1; + const hopArc = Math.sin(hopCycle * Math.PI); + const hopLift = Math.pow(hopArc, 0.86) * Math.min(1.15, r * 0.24) * mix; + + const centerX = coinX; + const centerY = clamp(coinY - hopLift, minY, maxY); + + const clickSpinT = clamp((now - clickSpinStart) / 820, 0, 1); + const clickSpinActive = now - clickSpinStart >= 0 && clickSpinT < 1; + const clickSpinEase = 1 - Math.pow(1 - clickSpinT, 3); + const clickSpinPop = clickSpinActive ? Math.sin(clickSpinT * Math.PI) : 0; + const clickSpin = clickSpinActive ? clickSpinDirection * Math.PI * 6 * clickSpinEase : 0; + + const rawSpin = hopCycle * Math.PI * 2 + clickSpin; + const spin = heldSpin(rawSpin, lerp(0.64, 0.18, clickSpinPop)); + const face = Math.abs(Math.cos(spin)); + const faceHold = Math.pow(face, 0.38); + const edgeFlash = 1 - face; + const widthScale = 0.2 + faceHold * 0.8; + const heightScale = 1 + edgeFlash * 0.06; + const flipped = Math.cos(spin) < 0; + const coinBrightness = 0.94 + faceHold * 0.13 + edgeFlash * 0.12 + clickSpinPop * 0.16; + const explosionReach = (gridReach + r) * (0.92 + explosionPower * 0.12); + const explosionFront = explosionT * explosionReach - r * 0.35; + const explosionEnergy = exploding ? Math.pow(1 - explosionT, 0.55) * explosionPower : 0; + const joltReach = layout.cols + layout.rows; + const joltFront = joltT * joltReach - 1; + const joltEnergy = jolting ? Math.pow(1 - joltT, 0.65) : 0; + const evaporateFront = evaporateT * gridReach - r * 0.2; + const evaporateEnergy = evaporating ? Math.pow(1 - evaporateT, 0.42) : 0; + const coalesceFront = (1 - coalesceT) * gridReach; + + for (const item of dots) { + const { dot, col, row, phase, activity } = item; + + const idleSpin = heldSpin(occasionalSpin(now, activity, phase), 0.72); + const idleFace = Math.pow(Math.abs(Math.cos(idleSpin)), 0.42); + const idleScale = 0.66 + activity * 0.48 + idleFace * (0.04 + activity * 0.05); + const idleOpacity = 0.42 + activity * 0.58; + const idleGlow = (1 - idleFace) * (0.07 + activity * 0.22); + + const localX = (col - centerX) / widthScale; + const localY = (row - centerY) / heightScale; + const distance = Math.hypot(localX, localY); + const coinShape = 1 - smoothstep(r - 0.65, r + 0.35, distance); + const coinMass = mix * coinShape; + + const fieldDistance = Math.hypot(col - centerX, row - centerY); + const ripple = Math.sin(fieldDistance * 1.15 - now * 0.0065) * rippleEnergy; + const transferFront = mix * gridReach; + const coalesceRing = coalescing ? ring(fieldDistance, coalesceFront, 2.5, coalesceEnergy) : 0; + const coalesceAbsorb = + coalescing + ? smoothstep(coalesceFront - 1.8, coalesceFront + 1.8, fieldDistance) + : 0; + const fieldAbsorb = + coalescing + ? coalesceAbsorb + : mix > 0.94 + ? 1 + : 1 - smoothstep(transferFront - 2.2, transferFront + 2.2, fieldDistance); + const transfer = clamp(mix * (coinShape + (1 - coinShape) * fieldAbsorb), 0, 1); + const idleMass = clamp(1 - transfer, 0, 1); + const totalMass = idleMass + coinMass; + + const explosionDistance = Math.hypot(col - explosionX, row - explosionY); + const explosionRing = exploding ? ring(explosionDistance, explosionFront, 2.4, explosionEnergy) : 0; + const joltDistance = + joltDirection > 0 + ? col - joltX + (row - joltY) * 0.45 + : joltX - col + (row - joltY) * 0.45; + const joltRing = jolting ? ring(joltDistance, joltFront, 1.1, joltEnergy) : 0; + const explosionAfterglow = + exploding + ? (1 - smoothstep(explosionFront - 3.4, explosionFront + 0.2, explosionDistance)) * + explosionEnergy + : 0; + const evaporateRing = + evaporating ? ring(fieldDistance, evaporateFront, 2.1, evaporateEnergy * coinShape) : 0; + const evaporateSpark = + evaporating + ? Math.max(0, Math.sin(phase * 11.3 + evaporateT * 34)) * evaporateEnergy * coinShape + : 0; + + const edge = distance / r; + const rim = edge > 0.78; + const leftHalf = flipped ? localX > 0 : localX < 0; + const onSeam = Math.abs(localX) < 0.38 && edge < 0.88; + + const coinBackground = onSeam + ? `linear-gradient(90deg, ${purple}, ${white})` + : leftHalf + ? purple + : white; + + const explosionBackground = Math.sin(phase + explosionT * 22) > 0 ? purple : white; + const coinScale = (rim ? 1.5 : 1.28) + clickSpinPop * (rim ? 0.16 : 0.1); + const idleWeightedScale = idleScale + ripple * idleMass * 0.035; + const scale = + totalMass > 0.001 + ? (idleWeightedScale * idleMass + coinScale * coinMass) / totalMass + : idleWeightedScale; + const spinAmount = idleSpin; + const burstRing = Math.max(explosionRing, coalesceRing, evaporateRing, joltRing); + const burstScale = + explosionRing * (0.42 + activity * 0.22 + clickSpinPop * 0.12) + + joltRing * 0.14 + + coalesceRing * (0.3 + activity * 0.18) + + evaporateRing * 0.38 + + evaporateSpark * 0.22; + const burstOpacity = + explosionRing * 0.95 + + explosionAfterglow * 0.2 + + joltRing * 0.36 + + coalesceRing * 0.75 + + evaporateRing * 0.7 + + evaporateSpark * 0.38; + + dot.style.background = + burstRing > Math.max(coinMass, idleMass) * 0.28 + ? explosionBackground + : coinMass > idleMass * 0.72 + ? coinBackground + : idleBackground; + dot.style.opacity = `${clamp(idleOpacity * idleMass + coinMass + burstOpacity, 0, 1)}`; + dot.style.transform = `rotateY(${spinAmount}rad) scale(${scale + burstScale})`; + dot.style.filter = `brightness(${lerp(0.9 + idleFace * 0.13 + activity * 0.08, coinBrightness, coinMass) + burstRing * 0.45 + evaporateSpark * 0.25}) saturate(${lerp(1, 1.1, coinMass) + burstRing * 0.18})`; + dot.style.boxShadow = + burstRing > 0.16 + ? `0 0 ${8 + burstRing * 14}px rgba(181, 126, 220, ${0.22 + burstRing * 0.32})` + : coinMass > 0.2 + ? rim + ? "0 0 8px rgba(181, 126, 220, 0.3)" + : "0 0 4px rgba(181, 126, 220, 0.18)" + : `0 0 ${idleGlow * 8}px rgba(181, 126, 220, ${idleGlow})`; + } + + frame = requestAnimationFrame(animate); + } + + function start() { + if (frame) cancelAnimationFrame(frame); + + frame = 0; + lastTime = 0; + setup(); + + if (reduceMotion.matches) { + paintStill(); + } else { + frame = requestAnimationFrame(animate); + } + } + + punchcard.addEventListener("pointerenter", (event) => { + const wasHovered = hovered; + hovered = true; + setPointer(event); + const resumed = !wasHovered && resumeCoalesceAfterReenter(); + if (!wasHovered && !resumed) triggerCoalesce(); + }); + + punchcard.addEventListener("pointermove", setPointer); + + punchcard.addEventListener("pointerleave", () => { + hovered = false; + evaporateCoin(); + }); + + punchcard.addEventListener("click", (event) => { + const now = performance.now(); + + setPointer(event); + if (waitForReenterAfterExplosion) { + triggerJolt(now); + return; + } + + if (event.detail % 3 === 0) { + triggerSpin(now); + triggerExplosion(now, 1.75); + } else { + triggerExplosion(now, 1); + } + }); + + start(); + + wideScreen.addEventListener("change", start); + reduceMotion.addEventListener("change", start); + addEventListener("resize", refreshLayout); +} diff --git a/appview/pages/profile-fx/gdorsi.bsky.social.js b/appview/pages/profile-fx/gdorsi.bsky.social.js new file mode 100644 index 00000000..98460927 --- /dev/null +++ b/appview/pages/profile-fx/gdorsi.bsky.social.js @@ -0,0 +1,313 @@ +// Super Mario Bros. World 1-1, rendered onto the punchcard. +// +// The punchcard is a grid of small dots (one per day of the year). We treat it +// as a low-res dot-matrix display and side-scroll World 1-1 across it: ground, +// bricks, ? blocks, pipes, Goombas, hills, bushes, clouds, a flagpole and a +// castle, with Mario auto-running and hopping over the obstacles. It loops. +// +// Textures are base64: every sprite is an array of strings, and each character +// is one pixel whose value is its index in the base64 alphabet (A=0, B=1, ...). +// That index selects an entry from PAL below. Index 0 (`A`) is transparent. + +const B64 = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"; + +// Palette. Index lines up with the base64 alphabet so a texture char maps +// straight to a colour: A->sky, D->ground, M->mario-red, and so on. +const PAL = [ + null, // 0 A transparent + "#5c94fc", // 1 B sky + "#ffffff", // 2 C white (clouds, flag) + "#c84c0c", // 3 D ground + "#8a3b08", // 4 E ground / seam dark + "#e39d5b", // 5 F ground top light / block edge + "#b8560f", // 6 G brick + "#fac000", // 7 H ? block yellow + "#7a3b08", // 8 I shadow / mortar + "#00a800", // 9 J pipe green + "#5fdd5f", // 10 K pipe light + "#006000", // 11 L pipe dark + "#e21b0c", // 12 M mario red + "#ffa060", // 13 N mario skin + "#2038ec", // 14 O mario overalls blue + "#6a2a00", // 15 P brown (mario hair, goomba feet) + "#b06a3c", // 16 Q goomba tan + "#000000", // 17 R black + "#3ca03c", // 18 S hill / bush green + "#78d060", // 19 T hill light + "#b0b0b0", // 20 U castle grey + "#707070", // 21 V castle dark +]; +const SKY = PAL[1]; + +// Decode a base64 texture (array of rows) into a {w,h,d} sprite of palette ids. +const spr = (rows) => ({ + w: rows[0].length, + h: rows.length, + d: rows.map((r) => Array.prototype.map.call(r, (c) => B64.indexOf(c))), +}); + +const HILL = spr(["AASAA", "ASTSA", "SSSSS"]); +const BUSH = spr(["ASSSA", "SSSSS"]); +const CLOUD = spr(["ACCCA", "CCCCC"]); + +// Mario's three fixed top rows (hat, face, body); the fourth row is his legs, +// swapped per animation frame below. +const MTOP = ["AMM", "PNN", "MMM"]; +const LEGS = ["OAO", "AOO", "OAO", "OOA"]; // running cycle +const LEGS_AIR = "OOA"; // tucked while jumping + +// --- Level layout (one looping period, measured in dot-columns) -------------- +const P = 72; // period width +const SCROLL = 5.5; // dot-columns per second + +const CLOUDS = [ + { x: 8, f: 0.18 }, + { x: 26, f: 0.1 }, + { x: 42, f: 0.24 }, + { x: 62, f: 0.14 }, +]; +const HILLS = [3, 46]; +const BUSHES = [14, 58]; +const QSINGLE = [{ x: 10, u: 4 }]; // lone ? block +const HIGHQ = { x: 21, u: 8 }; // high ? block +const RUN = [ + // the classic brick / ? / brick / ? / brick row, 4 tiles up + { x: 18, t: "b" }, + { x: 19, t: "q" }, + { x: 20, t: "b" }, + { x: 21, t: "q" }, + { x: 22, t: "b" }, +]; +const PIPES = [ + { x: 28, h: 2 }, + { x: 34, h: 3 }, + { x: 48, h: 4 }, + { x: 56, h: 2 }, +]; +const PITS = [[44, 45]]; // inclusive column ranges with no ground +const GOOMBAS = [24, 40, 52]; // spawn columns; they walk left +const STAIRS = [ + { x: 61, h: 1 }, + { x: 62, h: 2 }, + { x: 63, h: 3 }, + { x: 64, h: 4 }, +]; +const FLAGX = 68; +const CASTLEX = 70; + +// Scheduled jumps (column ranges + peak height) that carry Mario over each +// obstacle. Between them he runs along the ground. +const JUMPS = [ + { a: 25, b: 31, p: 4 }, // pipe @28 + { a: 31.5, b: 38, p: 5 }, // pipe @34 + { a: 42.5, b: 52, p: 7 }, // pit @44-45 + pipe @48 + { a: 53.5, b: 59, p: 4 }, // pipe @56 + { a: 60, b: 65, p: 5 }, // staircase +]; + +const isPit = (local) => PITS.some(([a, b]) => local >= a && local <= b); +const jumpY = (local) => { + let y = 0; + for (const j of JUMPS) { + if (local > j.a && local < j.b) { + const u = (local - j.a) / (j.b - j.a); + const h = 4 * j.p * u * (1 - u); // parabola, peak at the middle + if (h > y) y = h; + } + } + return y; +}; + +// --- Renderer ---------------------------------------------------------------- +const run = (card) => { + const dots = Array.from(card.children, (c) => c.firstElementChild).filter(Boolean); + const count = dots.length; + if (!count) return () => {}; + + const cols = matchMedia("(min-width: 768px)").matches ? 14 : 28; + const rows = Math.max(1, Math.ceil(count / cols)); + const W = cols; + const G = Math.max(2, Math.round(rows * 0.16)); // ground thickness + const horizon = rows - G; // first ground row; sky is above + const MSC = Math.max(2, Math.round(W * 0.32)); // Mario's fixed screen column + const reduce = matchMedia("(prefers-reduced-motion: reduce)").matches; + + // Fill the cells so the picture reads as a solid dot-matrix screen. + for (const d of dots) { + d.style.transition = "none"; + d.style.transform = "scale(2)"; + d.style.border = "none"; + d.style.willChange = "background-color"; + } + + const buf = new Uint8Array(W * rows); + const last = new Array(count).fill(null); + + const setPx = (x, y, idx) => { + if (idx > 0 && x >= 0 && x < W && y >= 0 && y < rows) buf[y * W + x] = idx; + }; + const blit = (s, x, y) => { + for (let ry = 0; ry < s.h; ry++) { + const row = s.d[ry]; + for (let cx = 0; cx < s.w; cx++) setPx(x + cx, y + ry, row[cx]); + } + }; + const blitStr = (str, x, y) => { + for (let k = 0; k < str.length; k++) setPx(x + k, y, B64.indexOf(str[k])); + }; + + // Draw one instance of a level feature per visible period. + let camCol = 0; + const eachBase = (cb) => { + const start = Math.floor((camCol - 8) / P) * P; + for (let b = start; b <= camCol + W + 8; b += P) cb(b); + }; + const at = (lx, draw) => + eachBase((b) => { + const sx = b + lx - camCol; + if (sx > -8 && sx < W + 2) draw(Math.round(sx)); + }); + + const drawPipe = (sx, h) => { + const top = horizon - h; + for (let y = top; y <= horizon - 1; y++) { + setPx(sx, y, 10); + setPx(sx + 1, y, 9); + setPx(sx + 2, y, 11); + } + setPx(sx + 1, top, 10); // brighten the cap lip + }; + const drawStep = (sx, h) => { + for (let y = horizon - h; y <= horizon - 1; y++) setPx(sx, y, y === horizon - h ? 5 : 3); + }; + const drawFlag = (sx) => { + const top = horizon - 9; + for (let y = top; y <= horizon - 1; y++) setPx(sx, y, 20); + setPx(sx, top - 1, 2); // ball + setPx(sx - 1, top, 18); + setPx(sx - 2, top + 1, 18); + setPx(sx - 1, top + 1, 18); + setPx(sx - 1, top + 2, 18); + }; + const drawCastle = (sx) => { + const top = horizon - 4; + for (let ry = 0; ry < 4; ry++) { + for (let cx = 0; cx < 5; cx++) { + if (ry === 0 && cx % 2 === 1) continue; // crenellations + setPx(sx + cx, top + ry, 20); + } + } + setPx(sx + 2, horizon - 1, 17); // door + setPx(sx + 2, horizon - 2, 17); + setPx(sx + 1, top + 1, 17); // windows + setPx(sx + 3, top + 1, 17); + }; + + let marioY = 0; // set each frame, read by the Goomba stomp check + const drawGoomba = (sx, t) => { + if ((sx === MSC || sx === MSC + 1) && marioY <= 1) { + blitStr("PPP", sx, horizon - 1); // squished flat + return; + } + blitStr("QQQ", sx, horizon - 2); + blitStr(Math.floor(t / 200) % 2 ? "APA" : "PAP", sx, horizon - 1); // waddle + }; + + const render = (t) => { + const camX = (t / 1000) * SCROLL; + camCol = Math.floor(camX); + buf.fill(1); // sky + + for (const c of CLOUDS) at(c.x, (sx) => blit(CLOUD, sx, Math.round(horizon * c.f))); + for (const hx of HILLS) at(hx, (sx) => blit(HILL, sx, horizon - HILL.h)); + for (const bx of BUSHES) at(bx, (sx) => blit(BUSH, sx, horizon - BUSH.h)); + + // Ground, per screen column, with a pit here and there. + for (let x = 0; x < W; x++) { + const local = ((camCol + x) % P + P) % P; + if (isPit(local)) continue; + const seam = local % 4 === 0; + setPx(x, horizon, seam ? 4 : 5); + for (let y = horizon + 1; y < rows; y++) setPx(x, y, (local + y) % 4 === 0 ? 4 : 3); + } + + const blink = Math.floor(t / 350) % 4 === 0; + for (const p of PIPES) at(p.x, (sx) => drawPipe(sx, p.h)); + for (const q of QSINGLE) at(q.x, (sx) => setPx(sx, horizon - q.u, blink ? 5 : 7)); + at(HIGHQ.x, (sx) => setPx(sx, horizon - HIGHQ.u, blink ? 5 : 7)); + for (const r of RUN) at(r.x, (sx) => setPx(sx, horizon - 4, r.t === "q" ? (blink ? 5 : 7) : 6)); + for (const s of STAIRS) at(s.x, (sx) => drawStep(sx, s.h)); + at(FLAGX, drawFlag); + at(CASTLEX, drawCastle); + + // Goombas walk left; a full period of travel loops seamlessly. + const phase = ((t / 1000) * 3) % P; + eachBase((b) => { + for (const s of GOOMBAS) { + const sx = Math.round(b + s - phase - camCol); + if (sx > -3 && sx < W + 1) drawGoomba(sx, t); + } + }); + + // Mario, pinned to screen column MSC, hopping the level as it scrolls by. + const local = ((camCol + MSC) % P + P) % P; + marioY = Math.min(horizon - 4, Math.round(jumpY(local))); + const legs = marioY > 0 ? LEGS_AIR : LEGS[Math.floor(t / 90) % LEGS.length]; + const topY = horizon - 1 - marioY - 3; + const body = [MTOP[0], MTOP[1], MTOP[2], legs]; + for (let r = 0; r < 4; r++) blitStr(body[r], MSC, topY + r); + + // Commit only the dots that changed. + for (let i = 0; i < count; i++) { + const hex = PAL[buf[(i / W | 0) * W + (i % W)]] || SKY; + if (hex !== last[i]) { + dots[i].style.backgroundColor = hex; + last[i] = hex; + } + } + }; + + if (reduce) { + render(2600); // one static frame, no animation + return () => {}; + } + + let raf = 0; + let running = false; + const loop = (t) => { + render(t); + raf = requestAnimationFrame(loop); + }; + const start = () => { + if (running) return; + running = true; + raf = requestAnimationFrame(loop); + }; + const stop = () => { + running = false; + cancelAnimationFrame(raf); + }; + + // Only animate while the punchcard is on screen. + const io = new IntersectionObserver((es) => (es.some((e) => e.isIntersecting) ? start() : stop())); + io.observe(card); + start(); + + return () => { + stop(); + io.disconnect(); + }; +}; + +// Boot, and re-bind across htmx navigations (matching the reference effect). +let target = null; +let teardown = null; +const boot = () => { + const el = document.querySelector("[data-punchcard]"); + if (el === target) return; + if (teardown) teardown(); + target = el; + teardown = el ? run(el) : null; +}; +boot(); +document.addEventListener("htmx:load", boot); diff --git a/appview/pages/profile-fx/kandake.js b/appview/pages/profile-fx/kandake.js new file mode 100644 index 00000000..77b736c3 --- /dev/null +++ b/appview/pages/profile-fx/kandake.js @@ -0,0 +1,236 @@ +// neon-terrain.js — profile fx: renders the punchcard as a polycss-style 3D +// terrain (perspective + rotateX/rotateZ + translateZ elevation, as in the +// terrain demo) lit with twinkl "y2kringe" neon — cyan/magenta pulses sweeping +// across the grid. One self-contained ES module, vanilla JS, no imports. +// +// CSS is loaded by injecting a