This repository has no description
0

Configure Feed

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

core / appview / pages / profile-fx / oppili.js
10 kB 326 lines
1// Brickblaster — punchcard edition 2// Vanilla ES module. No imports, no build step. 3 4const HS_KEY = 'brickblaster-hs'; 5const WQ = '(min-width: 768px)'; 6const BALL_D = 10; 7const BALL_R = BALL_D / 2; 8const PAD_H = 6; 9const PAD_GAP = 10; 10const PAD_CELLS = 5; 11 12const lsGet = k => { try { return localStorage.getItem(k); } catch (e) { return null; } }; 13const 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. 17const 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 28const 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 314let _cleanup = null; 315let _el = null; 316 317const _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(); 326document.addEventListener('htmx:load', _setup);