// Contribution Graph Breakout (Phase 2 foundations) (function () { "use strict"; const DEFAULTS = { frameMs: 1000 / 60, maxBalls: 6, paddleMinWidth: 1, paddleMaxWidth: 12, paddleSpeed: 6, ballSpeed: 2.4, ballRadius: 3, itemDropChance: 0.18, }; const GAME_STATUS = { NOT_STARTED: "not_started", RUNNING: "running", CLEAR: "clear", GAME_OVER: "game_over", }; const INPUT_MODE = { MOUSE: "mouse", KEYBOARD: "keyboard", }; const DEBUG = { enabled: true, prefix: "[ContribBreakout]", }; function logDebug(message, data) { if (!DEBUG.enabled) return; if (data !== undefined) { console.log(`${DEBUG.prefix} ${message}`, data); } else { console.log(`${DEBUG.prefix} ${message}`); } } function createInitialState() { return { status: GAME_STATUS.NOT_STARTED, inputMode: INPUT_MODE.MOUSE, blocks: [], balls: [], items: [], paddle: { x: 0, width: 6, }, graph: { element: null, cells: [], cols: 0, rows: 0, cellSize: 0, offsetX: 0, offsetY: 0, }, lastFrameAt: 0, rafId: null, }; } function createLifecycle() { const state = createInitialState(); function clamp(value, min, max) { return Math.max(min, Math.min(max, value)); } function clearExistingOverlay() { const existing = document.getElementById("contrib-breakout-overlay"); if (existing && existing.parentNode) { existing.parentNode.removeChild(existing); } const msg = document.getElementById("contrib-breakout-message"); if (msg && msg.parentNode) { msg.parentNode.removeChild(msg); } } function showMessage(text) { clearExistingOverlay(); logDebug(`Message: ${text}`); const msg = document.createElement("div"); msg.id = "contrib-breakout-message"; msg.textContent = text; msg.style.position = "absolute"; msg.style.zIndex = "9999"; msg.style.padding = "6px 10px"; msg.style.background = "rgba(0,0,0,0.75)"; msg.style.color = "#fff"; msg.style.font = "12px/1.4 sans-serif"; msg.style.borderRadius = "4px"; msg.style.pointerEvents = "none"; const anchor = state.graph.element || document.body; const rect = anchor.getBoundingClientRect(); msg.style.left = `${rect.left + window.scrollX}px`; msg.style.top = `${rect.bottom + window.scrollY + 6}px`; document.body.appendChild(msg); } function findContributionCells() { const containerSelectors = [ "[data-test-selector='profile-contribution-graph']", ".js-yearly-contributions", "#user-activity-overview", ".js-profile-timeline-yearly-contributions", ]; for (const selector of containerSelectors) { const container = document.querySelector(selector); if (!container) continue; const cells = Array.from(container.querySelectorAll("[data-date][data-level], td[data-date]")); if (cells.length) { logDebug("Found contribution cells via container", { selector, cells: cells.length }); return cells; } } const globalCells = Array.from(document.querySelectorAll("[data-date][data-level], td[data-date]")); logDebug("Global cell scan", { cells: globalCells.length, fragments: document.querySelectorAll("include-fragment").length }); return globalCells; } function extractCellsFromElements(elements) { const cells = elements .map((el) => { const rect = el.getBoundingClientRect(); if (!rect.width || !rect.height) return null; const levelAttr = el.getAttribute("data-level"); const level = levelAttr ? Number(levelAttr) : 0; return { x: rect.left, y: rect.top, w: rect.width, h: rect.height, level, }; }) .filter(Boolean); return cells; } function buildBlocks(cells) { const blocks = []; for (const cell of cells) { const level = typeof cell.level === "number" ? cell.level : 0; if (level <= 0) continue; const intensity = Math.min(Math.max(level, 1), 4); blocks.push({ x: cell.x, y: cell.y, w: cell.w, h: cell.h, intensity, hits: intensity, }); } logDebug("Blocks built", { totalCells: cells.length, blocks: blocks.length }); return blocks; } function setupOverlay(bounds) { clearExistingOverlay(); const canvas = document.createElement("canvas"); canvas.id = "contrib-breakout-overlay"; canvas.width = Math.ceil(bounds.width); canvas.height = Math.ceil(bounds.height); canvas.style.position = "absolute"; canvas.style.left = `${bounds.left + window.scrollX}px`; canvas.style.top = `${bounds.top + window.scrollY}px`; canvas.style.zIndex = "9998"; canvas.style.pointerEvents = "none"; document.body.appendChild(canvas); state.ctx = canvas.getContext("2d"); logDebug("Overlay created", { width: canvas.width, height: canvas.height }); return canvas; } function renderStatic() { if (!state.canvas) return; const ctx = state.ctx || state.canvas.getContext("2d"); ctx.clearRect(0, 0, state.canvas.width, state.canvas.height); for (const block of state.blocks) { const shade = ["#9be9a8", "#40c463", "#30a14e", "#216e39"][block.intensity - 1] || "#2da44e"; ctx.fillStyle = shade; ctx.fillRect(block.x, block.y, block.w, block.h); } if (state.paddle) { ctx.fillStyle = "#1f6feb"; ctx.fillRect(state.paddle.x, state.paddle.y, state.paddle.width, state.paddle.height); } if (state.balls.length > 0) { ctx.fillStyle = "#f0f6fc"; for (const ball of state.balls) { ctx.beginPath(); ctx.arc(ball.x, ball.y, ball.r, 0, Math.PI * 2); ctx.fill(); } } if (state.items.length > 0) { for (const item of state.items) { ctx.fillStyle = item.type === "add_ball" ? "#ffd33d" : item.type === "extend_paddle" ? "#1f6feb" : "#f85149"; ctx.beginPath(); ctx.arc(item.x, item.y, item.r, 0, Math.PI * 2); ctx.fill(); } } renderCountdown(); } function renderCountdown() { if (!state.countdownUntil) return; const remainingMs = state.countdownUntil - performance.now(); if (remainingMs <= 0) return; const seconds = Math.ceil(remainingMs / 1000); const ctx = state.ctx || state.canvas.getContext("2d"); ctx.save(); ctx.fillStyle = "rgba(0, 0, 0, 0.6)"; ctx.fillRect(0, 0, state.canvas.width, state.canvas.height); ctx.fillStyle = "#fff"; ctx.font = "bold 32px sans-serif"; ctx.textAlign = "center"; ctx.textBaseline = "middle"; ctx.fillText(String(seconds), state.canvas.width / 2, state.canvas.height / 2); ctx.restore(); } function setPaddleX(x) { if (!state.paddle) return; const minX = state.graph.minX; const maxX = state.graph.maxX - state.paddle.width + state.graph.cellSize; state.paddle.x = clamp(x, minX, maxX); } function bindInputHandlers() { if (state._inputBound) return; state._inputBound = true; state._keys = { left: false, right: false }; document.addEventListener("mousemove", (event) => { if (!state.canvas) return; const rect = state.canvas.getBoundingClientRect(); const x = event.clientX - rect.left + state.graph.minX; state.inputMode = INPUT_MODE.MOUSE; setPaddleX(x - state.paddle.width / 2); }); document.addEventListener("keydown", (event) => { if (event.key === "ArrowLeft" || event.key === "h" || event.key === "H") { state._keys.left = true; state.inputMode = INPUT_MODE.KEYBOARD; } if (event.key === "ArrowRight" || event.key === "l" || event.key === "L") { state._keys.right = true; state.inputMode = INPUT_MODE.KEYBOARD; } }); document.addEventListener("keyup", (event) => { if (event.key === "ArrowLeft" || event.key === "h" || event.key === "H") { state._keys.left = false; } if (event.key === "ArrowRight" || event.key === "l" || event.key === "L") { state._keys.right = false; } }); } function updatePaddle() { if (!state.paddle) return; if (state.inputMode !== INPUT_MODE.KEYBOARD) return; const speed = DEFAULTS.paddleSpeed; if (state._keys.left) setPaddleX(state.paddle.x - speed); if (state._keys.right) setPaddleX(state.paddle.x + speed); } function updateBalls() { if (state.balls.length === 0) return; const width = state.graph.width; const height = state.graph.height; const minX = state.graph.minX; const minY = state.graph.minY; const maxX = minX + width; const maxY = minY + height; for (const ball of state.balls) { ball.x += ball.vx; ball.y += ball.vy; if (ball.x - ball.r <= minX || ball.x + ball.r >= maxX) { ball.vx *= -1; ball.x = clamp(ball.x, minX + ball.r, maxX - ball.r); } if (ball.y - ball.r <= minY) { ball.vy *= -1; ball.y = minY + ball.r; } const paddle = state.paddle; if (paddle) { const paddleTop = paddle.y; const paddleBottom = paddle.y + paddle.height; if ( ball.y + ball.r >= paddleTop && ball.y + ball.r <= paddleBottom && ball.x >= paddle.x && ball.x <= paddle.x + paddle.width && ball.vy > 0 ) { ball.vy *= -1; ball.y = paddleTop - ball.r; } } if (ball.y - ball.r > maxY + state.graph.cellSize) { ball._lost = true; } } state.balls = state.balls.filter((ball) => !ball._lost); } function handleBlockCollisions() { if (state.blocks.length === 0 || state.balls.length === 0) return; for (const ball of state.balls) { for (const block of state.blocks) { if ( ball.x + ball.r >= block.x && ball.x - ball.r <= block.x + block.w && ball.y + ball.r >= block.y && ball.y - ball.r <= block.y + block.h ) { block.hits -= 1; ball.vy *= -1; if (block.hits <= 0) { block._cleared = true; maybeSpawnItem(block); } break; } } } state.blocks = state.blocks.filter((block) => !block._cleared); } function maybeSpawnItem(block) { if (Math.random() > DEFAULTS.itemDropChance) return; const types = ["add_ball", "extend_paddle", "shrink_paddle"]; const type = types[Math.floor(Math.random() * types.length)]; state.items.push({ x: block.x + block.w / 2, y: block.y + block.h / 2, vy: 1.6, type, r: DEFAULTS.ballRadius, }); } function updateItems() { if (state.items.length === 0) return; const paddle = state.paddle; const maxY = state.graph.maxY + state.graph.cellSize * 2; for (const item of state.items) { item.y += item.vy; if (paddle) { if ( item.y + item.r >= paddle.y && item.y - item.r <= paddle.y + paddle.height && item.x >= paddle.x && item.x <= paddle.x + paddle.width ) { applyItemEffect(item.type); item._caught = true; } } if (item.y > maxY) item._missed = true; } state.items = state.items.filter((item) => !item._caught && !item._missed); } function applyItemEffect(type) { if (!state.paddle) return; const cell = state.graph.cellSize; if (type === "add_ball") { if (state.balls.length >= DEFAULTS.maxBalls) return; state.balls.push({ x: state.paddle.x + state.paddle.width / 2, y: state.paddle.y - cell / 2, r: DEFAULTS.ballRadius, vx: DEFAULTS.ballSpeed * (Math.random() > 0.5 ? 1 : -1), vy: -DEFAULTS.ballSpeed, }); return; } if (type === "extend_paddle") { state.paddle.width = clamp( state.paddle.width + cell * 2, cell * DEFAULTS.paddleMinWidth, cell * DEFAULTS.paddleMaxWidth ); setPaddleX(state.paddle.x); return; } if (type === "shrink_paddle") { state.paddle.width = clamp( state.paddle.width - cell * 2, cell * DEFAULTS.paddleMinWidth, cell * DEFAULTS.paddleMaxWidth ); setPaddleX(state.paddle.x); } } function checkEndStates() { if (state.blocks.length === 0) { showMessage("Clear!"); stop(GAME_STATUS.CLEAR); return true; } if (state.balls.length === 0) { showMessage("Game Over"); stop(GAME_STATUS.GAME_OVER); return true; } return false; } function init() { state.status = GAME_STATUS.NOT_STARTED; state.blocks = []; state.balls = []; state.items = []; state.lastFrameAt = 0; state.rafId = null; return state; } function start() { if (state.status === GAME_STATUS.RUNNING) return state; state.status = GAME_STATUS.RUNNING; state.lastFrameAt = performance.now(); logDebug("Game started"); scheduleNextTick(); return state; } function stop(nextStatus) { if (state.rafId) { cancelAnimationFrame(state.rafId); state.rafId = null; } state.status = nextStatus || GAME_STATUS.NOT_STARTED; logDebug("Game stopped", { status: state.status }); return state; } function scheduleNextTick() { state.rafId = requestAnimationFrame(loop); } function loop(now) { if (state.status !== GAME_STATUS.RUNNING) { stop(state.status); return; } const elapsed = now - state.lastFrameAt; if (elapsed >= DEFAULTS.frameMs) { state.lastFrameAt = now; if (state.countdownUntil && now < state.countdownUntil) { if (typeof state.render === "function") state.render(); scheduleNextTick(); return; } updatePaddle(); updateBalls(); handleBlockCollisions(); updateItems(); if (!checkEndStates() && typeof state.render === "function") { state.render(); } } scheduleNextTick(); } function setupFromGraph() { logDebug("Setup started"); const elements = findContributionCells(); if (!elements || elements.length === 0) { showMessage("Contribution graph not found. Open a profile with a visible graph."); stop(GAME_STATUS.NOT_STARTED); return false; } const rawCells = extractCellsFromElements(elements); if (rawCells.length === 0) { showMessage("Contribution graph has no cells."); stop(GAME_STATUS.NOT_STARTED); return false; } const avgCell = Math.round(rawCells.reduce((sum, c) => sum + c.w, 0) / rawCells.length) || 10; const minX = Math.min(...rawCells.map((c) => c.x)); const minY = Math.min(...rawCells.map((c) => c.y)); const maxX = Math.max(...rawCells.map((c) => c.x + c.w)); const maxY = Math.max(...rawCells.map((c) => c.y + c.h)); const extraBottom = avgCell * 2; const bounds = { left: minX, top: minY, width: maxX - minX, height: maxY - minY + extraBottom, }; const cells = rawCells.map((c) => ({ x: c.x - bounds.left, y: c.y - bounds.top, w: c.w, h: c.h, level: c.level, })); state.graph.element = elements[0]; state.graph.cells = cells; logDebug("Cells extracted", { count: cells.length }); if (cells.length === 0) { showMessage("Contribution graph has no cells."); stop(GAME_STATUS.NOT_STARTED); return false; } const blocks = buildBlocks(cells); if (blocks.length === 0) { showMessage("No blocks to clear."); stop(GAME_STATUS.NOT_STARTED); return false; } state.blocks = blocks; const xs = cells.map((c) => c.x); const ys = cells.map((c) => c.y); state.graph.cols = new Set(xs).size; state.graph.rows = new Set(ys).size; state.graph.cellSize = avgCell; const maxYLocal = Math.max(...ys); const maxXLocal = Math.max(...xs); state.graph.minX = 0; state.graph.minY = 0; state.graph.maxX = maxXLocal; state.graph.maxY = maxYLocal; state.graph.width = bounds.width; state.graph.height = bounds.height; const canvas = setupOverlay(bounds); state.canvas = canvas; const paddleWidth = clamp(state.graph.cellSize * 6, state.graph.cellSize * DEFAULTS.paddleMinWidth, state.graph.cellSize * DEFAULTS.paddleMaxWidth); state.paddle = { x: maxXLocal - paddleWidth, y: maxYLocal + state.graph.cellSize, width: paddleWidth, height: state.graph.cellSize, }; setPaddleX(state.paddle.x); state.balls = [ { x: state.paddle.x + state.paddle.width / 2, y: state.paddle.y - state.graph.cellSize / 2, r: DEFAULTS.ballRadius, vx: DEFAULTS.ballSpeed, vy: -DEFAULTS.ballSpeed, }, ]; state.render = renderStatic; state.countdownUntil = performance.now() + 3000; bindInputHandlers(); renderStatic(); logDebug("Setup complete", { blocks: state.blocks.length, balls: state.balls.length, paddleWidth: state.paddle.width, }); return true; } function boot() { if (state._booting) return; state._booting = true; let attempts = 0; const maxAttempts = 10; const retryMs = 500; const trySetup = () => { attempts += 1; if (setupFromGraph()) { state._booting = false; start(); return; } if (attempts >= maxAttempts) { state._booting = false; logDebug("Boot failed after retries"); return; } state._retryTimer = setTimeout(trySetup, retryMs); }; trySetup(); } return { state, init, start, stop, setupFromGraph, boot }; } // Expose minimal API for later phases. const api = createLifecycle(); api.init(); // Start immediately if graph is present, otherwise retry briefly. api.boot(); window.__contribBreakout = api; })();