Monorepo for Tangled
0

Configure Feed

Select the types of activity you want to include in your feed.

appview/pages/profile-fx: add brickblaster

Signed-off-by: oppiliappan <me@oppi.li>

authored by

oppiliappan and committed by
Tangled
(Jul 14, 2026, 1:37 PM +0300) 0b1e3e3d a00289e3

+327
+326
appview/pages/profile-fx/oppili.js
··· 1 + // Brickblaster — punchcard edition 2 + // Vanilla ES module. No imports, no build step. 3 + 4 + const HS_KEY = 'brickblaster-hs'; 5 + const WQ = '(min-width: 768px)'; 6 + const BALL_D = 10; 7 + const BALL_R = BALL_D / 2; 8 + const PAD_H = 6; 9 + const PAD_GAP = 10; 10 + const PAD_CELLS = 5; 11 + 12 + const lsGet = k => { try { return localStorage.getItem(k); } catch (e) { return null; } }; 13 + const lsSet = (k, v) => { try { localStorage.setItem(k, v); } catch (e) {} }; 14 + 15 + // True if the dot has a contribution colour (not gray). 16 + // Uses HSL saturation: gray dots have s < 0.25, green contribution dots are much higher. 17 + const isGreen = (el) => { 18 + const c = getComputedStyle(el).backgroundColor; 19 + const m = c.match(/[\d.]+/g); 20 + if (!m || m.length < 3) return false; 21 + const r = +m[0] / 255, g = +m[1] / 255, b = +m[2] / 255; 22 + const mx = Math.max(r, g, b), mn = Math.min(r, g, b); 23 + if (mx === mn) return false; 24 + const l = (mx + mn) / 2; 25 + return (mx - mn) / (l > 0.5 ? 2 - mx - mn : mx + mn) > 0.25; 26 + }; 27 + 28 + const run = (N) => { 29 + if (matchMedia('(prefers-reduced-motion: reduce)').matches) return () => {}; 30 + 31 + const ac = new AbortController(); 32 + const mq = matchMedia(WQ); 33 + const sig = { signal: ac.signal }; 34 + 35 + const getCols = () => mq.matches ? 14 : 28; 36 + const resetDot = el => { 37 + el.style.backgroundColor = ''; 38 + el.style.transform = ''; 39 + el.style.opacity = ''; 40 + }; 41 + 42 + const readGrid = () => { 43 + const dots = Array.from(N.children, w => w.firstElementChild).filter(Boolean); 44 + const cols = getCols(); 45 + return { dots, cols, rows: Math.ceil(dots.length / cols) }; 46 + }; 47 + 48 + let G = readGrid(); 49 + 50 + G.dots.forEach(el => { 51 + el.style.transition = 'none'; 52 + el.style.transformOrigin = 'center'; 53 + el.style.willChange = 'transform, opacity, background-color'; 54 + }); 55 + 56 + const origPos = N.style.position; 57 + N.style.position = 'relative'; 58 + N.style.cursor = 'default'; 59 + 60 + // Ball element — inside N, can visually overflow below 61 + const ballEl = document.createElement('div'); 62 + Object.assign(ballEl.style, { 63 + position: 'absolute', 64 + width: BALL_D + 'px', 65 + height: BALL_D + 'px', 66 + borderRadius: '50%', 67 + backgroundColor: '#3b82f6', 68 + boxShadow: '0 0 6px #3b82f680', 69 + pointerEvents: 'none', 70 + zIndex: '10', 71 + display: 'none', 72 + }); 73 + N.appendChild(ballEl); 74 + 75 + // Paddle — block element below N 76 + const padWrap = document.createElement('div'); 77 + padWrap.style.cssText = `position:relative; height:${PAD_H}px; margin-top:${PAD_GAP}px;`; 78 + N.parentNode.insertBefore(padWrap, N.nextSibling); 79 + 80 + const padEl = document.createElement('div'); 81 + padEl.style.cssText = `position:absolute; height:${PAD_H}px; border-radius:3px; background:#3b82f6; box-shadow:0 0 6px #3b82f680; top:0;`; 82 + padWrap.appendChild(padEl); 83 + 84 + // HUD 85 + const hud = document.createElement('div'); 86 + Object.assign(hud.style, { 87 + fontSize: '11px', 88 + fontFamily: 'ui-monospace, monospace', 89 + color: '#6b7280', 90 + marginBottom: '6px', 91 + userSelect: 'none', 92 + letterSpacing: '0.04em', 93 + }); 94 + N.parentNode.insertBefore(hud, N); 95 + 96 + // State 97 + let phase = 'idle'; 98 + let score = 0; 99 + let hs = +(lsGet(HS_KEY) || 0); 100 + let brickMap = []; // which dots are green/collidable 101 + let alive = []; // which bricks haven't been destroyed yet 102 + let bx = 0, by = 0, bvx = 0, bvy = 0; 103 + let padX = 0; 104 + let padXReady = false; 105 + 106 + // Arrow keys 107 + const keys = { left: false, right: false }; 108 + window.addEventListener('keydown', e => { 109 + if (e.key === 'ArrowLeft') { keys.left = true; if (phase === 'playing') e.preventDefault(); } 110 + if (e.key === 'ArrowRight') { keys.right = true; if (phase === 'playing') e.preventDefault(); } 111 + }, sig); 112 + window.addEventListener('keyup', e => { 113 + if (e.key === 'ArrowLeft') keys.left = false; 114 + if (e.key === 'ArrowRight') keys.right = false; 115 + }, sig); 116 + 117 + const getMetrics = () => { 118 + const gridW = N.offsetWidth; 119 + const gridH = N.offsetHeight; 120 + const cellW = gridW / G.cols; 121 + const cellH = gridH / G.rows; 122 + const padW = cellW * PAD_CELLS; 123 + const paddleY = gridH + PAD_GAP + PAD_H / 2; 124 + const padSpeed = Math.max(100, gridW / 0.6); 125 + const ballSpeed = Math.max(80, gridH / 1.2); 126 + return { gridW, gridH, cellW, cellH, padW, paddleY, padSpeed, ballSpeed }; 127 + }; 128 + 129 + const showHud = () => { 130 + if (phase === 'idle') hud.textContent = `BRICKBLASTER | BEST: ${hs} | click to start`; 131 + else if (phase === 'playing') hud.textContent = `SCORE: ${score} | BEST: ${hs}`; 132 + else hud.textContent = `GAME OVER | SCORE: ${score} | BEST: ${hs} | click to replay`; 133 + }; 134 + 135 + const startGame = () => { 136 + score = 0; 137 + brickMap = G.dots.map(el => isGreen(el)); // snapshot which dots are green 138 + alive = brickMap.slice(); 139 + 140 + const m = getMetrics(); 141 + if (!padXReady) { padX = m.gridW / 2; padXReady = true; } 142 + 143 + const a = Math.PI * (0.3 + Math.random() * 0.4); // 54–126°, always upward 144 + bx = padX; 145 + by = m.gridH * 0.7; 146 + bvx = Math.cos(a) * m.ballSpeed; 147 + bvy = -Math.sin(a) * m.ballSpeed; 148 + 149 + phase = 'playing'; 150 + ballEl.style.display = 'block'; 151 + showHud(); 152 + }; 153 + 154 + const endGame = () => { 155 + if (score > hs) { hs = score; lsSet(HS_KEY, hs); } 156 + phase = 'gameover'; 157 + ballEl.style.display = 'none'; 158 + showHud(); 159 + }; 160 + 161 + const update = (dt, m) => { 162 + if (phase !== 'playing') return; 163 + 164 + const hw = m.padW / 2; 165 + if (keys.left) padX = Math.max(hw, padX - m.padSpeed * dt); 166 + if (keys.right) padX = Math.min(m.gridW - hw, padX + m.padSpeed * dt); 167 + 168 + bx += bvx * dt; 169 + by += bvy * dt; 170 + 171 + // Wall bounces 172 + if (bx < BALL_R) { bx = BALL_R; bvx = Math.abs(bvx); } 173 + if (bx > m.gridW - BALL_R) { bx = m.gridW - BALL_R; bvx = -Math.abs(bvx); } 174 + if (by < BALL_R) { by = BALL_R; bvy = Math.abs(bvy); } 175 + 176 + // Ball falls past paddle → game over 177 + if (by > m.paddleY + PAD_H + BALL_R) { endGame(); return; } 178 + 179 + // Paddle collision 180 + const padTop = m.paddleY - PAD_H / 2 - BALL_R; 181 + if (bvy > 0 && by >= padTop && by <= m.paddleY + PAD_H) { 182 + if (Math.abs(bx - padX) <= hw + BALL_R) { 183 + by = padTop; 184 + const offset = (bx - padX) / hw; 185 + const speed = Math.hypot(bvx, bvy); 186 + bvx = offset * speed * 0.8; 187 + const sp = Math.hypot(bvx, bvy) || speed; 188 + bvx = (bvx / sp) * speed; 189 + bvy = -Math.abs((bvy / sp) * speed); 190 + } 191 + } 192 + 193 + // Brick collision — only green (brickMap) dots that are still alive 194 + const HIT_R = BALL_R + Math.min(m.cellW, m.cellH) * 0.4; 195 + const c0 = Math.round(bx / m.cellW - 0.5); 196 + const r0 = Math.round(by / m.cellH - 0.5); 197 + let hitDone = false; 198 + for (let dr = -1; dr <= 1 && !hitDone; dr++) { 199 + for (let dc = -1; dc <= 1 && !hitDone; dc++) { 200 + const c = c0 + dc, r = r0 + dr; 201 + if (c < 0 || c >= G.cols || r < 0 || r >= G.rows) continue; 202 + const idx = r * G.cols + c; 203 + if (!alive[idx]) continue; 204 + const dotX = (c + 0.5) * m.cellW; 205 + const dotY = (r + 0.5) * m.cellH; 206 + const dist = Math.hypot(bx - dotX, by - dotY); 207 + if (dist >= HIT_R) continue; 208 + 209 + alive[idx] = false; 210 + score += 1; 211 + if (score > hs) { hs = score; lsSet(HS_KEY, hs); } 212 + showHud(); 213 + 214 + const nx = (bx - dotX) / (dist || 1); 215 + const ny = (by - dotY) / (dist || 1); 216 + const dp = bvx * nx + bvy * ny; 217 + if (dp < 0) { bvx -= 2 * dp * nx; bvy -= 2 * dp * ny; } 218 + hitDone = true; 219 + } 220 + } 221 + 222 + if (!alive.some(a => a)) endGame(); 223 + }; 224 + 225 + const render = (m) => { 226 + if (!padXReady && m.gridW > 0) { padX = m.gridW / 2; padXReady = true; } 227 + 228 + const hw = m.padW / 2; 229 + const cx = Math.max(hw, Math.min(m.gridW - hw, padX)); 230 + 231 + padEl.style.left = (cx - hw) + 'px'; 232 + padEl.style.width = m.padW + 'px'; 233 + 234 + if (phase === 'playing') { 235 + ballEl.style.left = (bx - BALL_R) + 'px'; 236 + ballEl.style.top = (by - BALL_R) + 'px'; 237 + } 238 + 239 + G.dots.forEach((el, i) => { 240 + // Idle or non-brick gray dot: restore natural appearance 241 + if (phase === 'idle' || !brickMap[i]) { resetDot(el); return; } 242 + 243 + if (alive[i]) { 244 + // Alive green brick: keep natural color, scale up so it reads as a target 245 + el.style.backgroundColor = ''; 246 + el.style.transform = 'scale(1.5)'; 247 + el.style.opacity = ''; 248 + } else { 249 + // Destroyed: shrink and turn gray 250 + el.style.backgroundColor = '#6b7280'; 251 + el.style.transform = 'scale(0.6)'; 252 + el.style.opacity = ''; 253 + } 254 + }); 255 + }; 256 + 257 + N.addEventListener('pointerdown', e => { 258 + if (e.button !== 0) return; 259 + if (phase !== 'playing') startGame(); 260 + }, sig); 261 + 262 + let raf = 0, lastT = 0, visible = true; 263 + 264 + const frame = t => { 265 + const dt = lastT ? Math.min((t - lastT) / 1000, 0.05) : 0; 266 + lastT = t; 267 + const m = getMetrics(); 268 + update(dt, m); 269 + render(m); 270 + if (visible) raf = requestAnimationFrame(frame); 271 + }; 272 + 273 + const io = new IntersectionObserver(entries => { 274 + const v = entries.some(e => e.isIntersecting); 275 + if (v === visible) return; 276 + visible = v; 277 + if (visible) { lastT = 0; raf = requestAnimationFrame(frame); } 278 + else cancelAnimationFrame(raf); 279 + }); 280 + io.observe(N); 281 + 282 + raf = requestAnimationFrame(frame); 283 + 284 + mq.addEventListener('change', () => { 285 + G = readGrid(); 286 + G.dots.forEach(el => { 287 + el.style.transition = 'none'; 288 + el.style.transformOrigin = 'center'; 289 + el.style.willChange = 'transform, opacity, background-color'; 290 + }); 291 + padXReady = false; 292 + phase = 'idle'; 293 + ballEl.style.display = 'none'; 294 + G.dots.forEach(resetDot); 295 + showHud(); 296 + }, sig); 297 + 298 + showHud(); 299 + 300 + return () => { 301 + cancelAnimationFrame(raf); 302 + io.disconnect(); 303 + ac.abort(); 304 + hud.remove(); 305 + ballEl.remove(); 306 + padWrap.remove(); 307 + N.style.position = origPos; 308 + N.style.cursor = ''; 309 + G.dots.forEach(resetDot); 310 + }; 311 + }; 312 + 313 + // Bootstrap — mirrors orrery.js pattern, survives htmx navigation 314 + let _cleanup = null; 315 + let _el = null; 316 + 317 + const _setup = () => { 318 + const el = document.querySelector('[data-punchcard]'); 319 + if (el === _el) return; 320 + if (_cleanup) _cleanup(); 321 + _el = el; 322 + _cleanup = el ? run(el) : null; 323 + }; 324 + 325 + _setup(); 326 + document.addEventListener('htmx:load', _setup);
+1
appview/state/profile.go
··· 55 55 56 56 var profileScripts = map[string]string{ 57 57 "did:plc:3fwecdnvtcscjnrx2p4n7alz": "/static/profile-fx/orrery.js", 58 + "did:plc:qfpnj4og54vl56wngdriaxug": "/static/profile-fx/oppili.js", 58 59 } 59 60 60 61 func (s *State) profile(r *http.Request) (*pages.ProfileCard, error) {