This repository has no description
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);